159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
|
|
import asyncio
|
||
|
|
import base64
|
||
|
|
import io
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from asn1crypto import x509 as asn1_x509
|
||
|
|
from cryptography import x509
|
||
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
||
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||
|
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||
|
|
from cryptography.x509.oid import NameOID
|
||
|
|
from pyhanko.pdf_utils import generic
|
||
|
|
from pyhanko.pdf_utils.reader import PdfFileReader
|
||
|
|
from pyhanko.pdf_utils.writer import PageObject, PdfFileWriter
|
||
|
|
from pyhanko.sign.validation import ValidationContext, validate_pdf_signature
|
||
|
|
|
||
|
|
from signing_service.infrastructure.pdf.pdf_validator_error import PDFValidationError
|
||
|
|
from signing_service.infrastructure.pdf.pyhanko_pdf_signer import PyHankoPDFSigner
|
||
|
|
|
||
|
|
PASSWORD = "ephemeral-test-password"
|
||
|
|
|
||
|
|
|
||
|
|
def _test_pdf() -> bytes:
|
||
|
|
writer = PdfFileWriter()
|
||
|
|
contents = writer.add_object(generic.StreamObject(stream_data=b""))
|
||
|
|
writer.insert_page(PageObject(contents, media_box=(0, 0, 595, 842)))
|
||
|
|
output = io.BytesIO()
|
||
|
|
writer.write(output)
|
||
|
|
return output.getvalue()
|
||
|
|
|
||
|
|
|
||
|
|
def _pfx_material() -> tuple[str, x509.Certificate, x509.Certificate]:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||
|
|
root_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test Root CA")])
|
||
|
|
root_certificate = (
|
||
|
|
x509.CertificateBuilder()
|
||
|
|
.subject_name(root_name)
|
||
|
|
.issuer_name(root_name)
|
||
|
|
.public_key(root_key.public_key())
|
||
|
|
.serial_number(x509.random_serial_number())
|
||
|
|
.not_valid_before(now - timedelta(minutes=1))
|
||
|
|
.not_valid_after(now + timedelta(days=1))
|
||
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||
|
|
.add_extension(
|
||
|
|
x509.KeyUsage(
|
||
|
|
digital_signature=True,
|
||
|
|
content_commitment=False,
|
||
|
|
key_encipherment=False,
|
||
|
|
data_encipherment=False,
|
||
|
|
key_agreement=False,
|
||
|
|
key_cert_sign=True,
|
||
|
|
crl_sign=True,
|
||
|
|
encipher_only=None,
|
||
|
|
decipher_only=None,
|
||
|
|
),
|
||
|
|
critical=True,
|
||
|
|
)
|
||
|
|
.sign(root_key, hashes.SHA256())
|
||
|
|
)
|
||
|
|
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||
|
|
leaf_name = x509.Name(
|
||
|
|
[
|
||
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "FactuGES Test Signing"),
|
||
|
|
x509.NameAttribute(NameOID.COMMON_NAME, "Ephemeral PDF Signer"),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
leaf_certificate = (
|
||
|
|
x509.CertificateBuilder()
|
||
|
|
.subject_name(leaf_name)
|
||
|
|
.issuer_name(root_certificate.subject)
|
||
|
|
.public_key(leaf_key.public_key())
|
||
|
|
.serial_number(x509.random_serial_number())
|
||
|
|
.not_valid_before(now - timedelta(minutes=1))
|
||
|
|
.not_valid_after(now + timedelta(days=1))
|
||
|
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||
|
|
.add_extension(
|
||
|
|
x509.KeyUsage(
|
||
|
|
digital_signature=True,
|
||
|
|
content_commitment=True,
|
||
|
|
key_encipherment=False,
|
||
|
|
data_encipherment=False,
|
||
|
|
key_agreement=False,
|
||
|
|
key_cert_sign=False,
|
||
|
|
crl_sign=False,
|
||
|
|
encipher_only=None,
|
||
|
|
decipher_only=None,
|
||
|
|
),
|
||
|
|
critical=True,
|
||
|
|
)
|
||
|
|
.sign(root_key, hashes.SHA256())
|
||
|
|
)
|
||
|
|
pfx = pkcs12.serialize_key_and_certificates(
|
||
|
|
name=b"ephemeral-pdf-signer",
|
||
|
|
key=leaf_key,
|
||
|
|
cert=leaf_certificate,
|
||
|
|
cas=[root_certificate],
|
||
|
|
encryption_algorithm=serialization.BestAvailableEncryption(PASSWORD.encode()),
|
||
|
|
)
|
||
|
|
return base64.b64encode(pfx).decode("ascii"), leaf_certificate, root_certificate
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_valid_signature(
|
||
|
|
signed_pdf: bytes, expected_certificate: x509.Certificate, root_certificate: x509.Certificate
|
||
|
|
) -> None:
|
||
|
|
reader = PdfFileReader(io.BytesIO(signed_pdf))
|
||
|
|
assert len(reader.embedded_signatures) == 1
|
||
|
|
embedded_signature = reader.embedded_signatures[0]
|
||
|
|
validation_context = ValidationContext(
|
||
|
|
trust_roots=[
|
||
|
|
asn1_x509.Certificate.load(
|
||
|
|
root_certificate.public_bytes(serialization.Encoding.DER)
|
||
|
|
)
|
||
|
|
],
|
||
|
|
allow_fetching=False,
|
||
|
|
)
|
||
|
|
status = validate_pdf_signature(
|
||
|
|
embedded_signature, signer_validation_context=validation_context
|
||
|
|
)
|
||
|
|
assert status.intact
|
||
|
|
assert status.valid
|
||
|
|
assert status.trusted
|
||
|
|
assert (
|
||
|
|
embedded_signature.signer_cert.sha256_fingerprint.replace(" ", "").lower()
|
||
|
|
== expected_certificate.fingerprint(hashes.SHA256()).hex()
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_signs_pfx_and_produces_a_cryptographically_valid_pdf() -> None:
|
||
|
|
pfx_base64, expected_certificate, root_certificate = _pfx_material()
|
||
|
|
|
||
|
|
signed_pdf = asyncio.run(
|
||
|
|
PyHankoPDFSigner().sign(_test_pdf(), pfx_base64, PASSWORD)
|
||
|
|
)
|
||
|
|
|
||
|
|
_assert_valid_signature(signed_pdf, expected_certificate, root_certificate)
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_an_incorrect_pfx_password() -> None:
|
||
|
|
pfx_base64, _, _ = _pfx_material()
|
||
|
|
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
asyncio.run(PyHankoPDFSigner().sign(_test_pdf(), pfx_base64, "wrong-password"))
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_base64_that_is_not_a_pkcs12_container() -> None:
|
||
|
|
not_a_pfx = base64.b64encode(b"not a PKCS#12 container").decode("ascii")
|
||
|
|
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
asyncio.run(PyHankoPDFSigner().sign(_test_pdf(), not_a_pfx, PASSWORD))
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_an_invalid_pdf_before_signing() -> None:
|
||
|
|
pfx_base64, _, _ = _pfx_material()
|
||
|
|
|
||
|
|
with pytest.raises(PDFValidationError):
|
||
|
|
asyncio.run(PyHankoPDFSigner().sign(b"not a PDF", pfx_base64, PASSWORD))
|