synta 0.2.3

ASN.1 parser, decoder, and encoder library with DER/BER support and C FFI
Documentation
#!/usr/bin/env python3
"""
Tests for RFC 9925 unsigned X.509 certificate support and NameBuilder.

Verifies that:
- synta.NameBuilder produces valid DER-encoded X.509 Names.
- synta.CertificateBuilder.sign_unsigned() builds an unsigned certificate
  carrying id-alg-unsigned (1.3.6.1.5.5.7.6.36) as the signatureAlgorithm
  with a zero-length BIT STRING signatureValue.
- synta.CertificateBuilder.sign() builds a normal signed certificate whose
  signatureAlgorithm matches the key type used for signing.
- Extension values built with synta.ext helpers round-trip correctly through
  get_extension_value_der().
"""

import datetime
import traceback

import pytest

import synta
import synta.ext as ext
import synta.oids as oids


# ── NameBuilder ───────────────────────────────────────────────────────────────

def test_name_builder_common_name():
    """NameBuilder.common_name() produces non-empty DER bytes."""
    name_der = synta.NameBuilder().common_name("Test CA").build()
    assert isinstance(name_der, bytes)
    assert len(name_der) > 0
    # Outer tag must be SEQUENCE (0x30).
    assert name_der[0] == 0x30


def test_name_builder_chaining():
    """Multiple attributes can be chained; all appear in the DER output."""
    name_der = (
        synta.NameBuilder()
        .country("US")
        .organization("Example Corp")
        .organizational_unit("Engineering")
        .common_name("example.com")
        .build()
    )
    assert isinstance(name_der, bytes)
    assert len(name_der) > 0
    # The DER should be larger than a single-attr name.
    single = synta.NameBuilder().common_name("example.com").build()
    assert len(name_der) > len(single)


def test_name_builder_empty():
    """Empty NameBuilder produces DER empty SEQUENCE (30 00)."""
    name_der = synta.NameBuilder().build()
    assert name_der == bytes([0x30, 0x00])


def test_name_builder_add_attr():
    """add_attr() accepts a dotted-decimal OID string."""
    # 2.5.4.3 is commonName
    name_der = synta.NameBuilder().add_attr("2.5.4.3", "via add_attr").build()
    assert isinstance(name_der, bytes)
    assert len(name_der) > 0


def test_name_builder_add_attr_invalid_oid():
    """add_attr() raises ValueError for an invalid OID string."""
    with pytest.raises(ValueError, match="invalid OID"):
        synta.NameBuilder().add_attr("not.an.oid", "value")


def test_name_builder_roundtrip():
    """Name DER built by NameBuilder can be read back as a certificate issuer/subject."""
    name_der = synta.NameBuilder().common_name("Round-trip Test").build()
    key = synta.PrivateKey.generate_ec("P-256")
    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)

    bc_der = ext.basic_constraints(ca=True)
    cert = (
        synta.CertificateBuilder()
        .issuer_name(name_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(1)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .add_extension(str(oids.BASIC_CONSTRAINTS), True, bc_der)
        .sign(key, "sha256")
    )

    # Parse the issuer back and check the CN appears.
    from synta import parse_name_attrs
    attrs = parse_name_attrs(cert.issuer_raw_der)
    assert any(v == "Round-trip Test" for _, v in attrs)


# ── sign_unsigned() ───────────────────────────────────────────────────────────

def test_sign_unsigned_signature_algorithm():
    """sign_unsigned() sets id-alg-unsigned as signatureAlgorithm."""
    key = synta.PrivateKey.generate_ec("P-256")
    name_der = synta.NameBuilder().common_name("Unsigned Test Root").build()

    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
    bc_der = ext.basic_constraints(ca=True)
    ku_der = ext.key_usage(ext.KU_KEY_CERT_SIGN | ext.KU_CRL_SIGN)

    cert = (
        synta.CertificateBuilder()
        .issuer_name(name_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(1)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .add_extension(str(oids.BASIC_CONSTRAINTS), True, bc_der)
        .add_extension(str(oids.KEY_USAGE), True, ku_der)
        .sign_unsigned()
    )

    # signatureAlgorithm must be id-alg-unsigned.
    assert cert.signature_algorithm_oid == oids.ALG_UNSIGNED


def test_sign_unsigned_signature_value_empty():
    """sign_unsigned() produces a zero-length BIT STRING signatureValue."""
    key = synta.PrivateKey.generate_ec("P-256")
    name_der = synta.NameBuilder().common_name("Empty Sig Test").build()

    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)

    cert = (
        synta.CertificateBuilder()
        .issuer_name(name_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(2)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .sign_unsigned()
    )

    # signatureValue raw bytes should be empty (zero-length BIT STRING value).
    assert cert.signature_value == b""


def test_sign_unsigned_missing_field():
    """sign_unsigned() raises ValueError when a required field is missing."""
    name_der = synta.NameBuilder().common_name("Incomplete").build()
    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)

    # Missing public key — should raise ValueError.
    with pytest.raises(ValueError):
        (
            synta.CertificateBuilder()
            .issuer_name(name_der)
            .subject_name(name_der)
            # no public_key / public_key_der
            .serial_number(1)
            .not_valid_before_utc(now)
            .not_valid_after_utc(later)
            .sign_unsigned()
        )


# ── sign() ────────────────────────────────────────────────────────────────────

def test_sign_ec_sha256():
    """sign() with EC P-256 key produces an ECDSA-SHA256 certificate."""
    key = synta.PrivateKey.generate_ec("P-256")
    name_der = synta.NameBuilder().common_name("EC Signed Leaf").build()
    issuer_der = synta.NameBuilder().common_name("EC Test CA").build()

    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
    ku_der = ext.key_usage(ext.KU_DIGITAL_SIGNATURE)

    cert = (
        synta.CertificateBuilder()
        .issuer_name(issuer_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(42)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .add_extension(str(oids.KEY_USAGE), True, ku_der)
        .sign(key, "sha256")
    )

    assert cert.signature_algorithm_oid == oids.ECDSA_WITH_SHA256


# ── Extension round-trip ──────────────────────────────────────────────────────

def test_extension_roundtrip_basic_constraints():
    """BasicConstraints extension value survives add_extension / get_extension_value_der."""
    key = synta.PrivateKey.generate_ec("P-256")
    name_der = synta.NameBuilder().common_name("BC Round-trip CA").build()

    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)

    bc_der = ext.basic_constraints(ca=True, path_length=0)

    cert = (
        synta.CertificateBuilder()
        .issuer_name(name_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(1)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .add_extension(str(oids.BASIC_CONSTRAINTS), True, bc_der)
        .sign_unsigned()
    )

    recovered = cert.get_extension_value_der(str(oids.BASIC_CONSTRAINTS))
    assert recovered == bc_der


def test_extension_roundtrip_key_usage():
    """KeyUsage extension value survives add_extension / get_extension_value_der."""
    key = synta.PrivateKey.generate_ec("P-256")
    name_der = synta.NameBuilder().common_name("KU Round-trip").build()

    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)

    ku_der = ext.key_usage(ext.KU_KEY_CERT_SIGN | ext.KU_CRL_SIGN)

    cert = (
        synta.CertificateBuilder()
        .issuer_name(name_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(1)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .add_extension(str(oids.KEY_USAGE), True, ku_der)
        .sign_unsigned()
    )

    recovered = cert.get_extension_value_der(str(oids.KEY_USAGE))
    assert recovered == ku_der


def test_subject_key_identifier_roundtrip():
    """SubjectKeyIdentifier built with ext.subject_key_identifier() round-trips."""
    key = synta.PrivateKey.generate_ec("P-256")
    spki_der = key.public_key.to_der()
    name_der = synta.NameBuilder().common_name("SKI Test").build()

    now = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
    later = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)

    ski_der = ext.subject_key_identifier(spki_der, ext.KEYID_RFC5280)
    assert isinstance(ski_der, bytes)
    assert len(ski_der) > 0

    cert = (
        synta.CertificateBuilder()
        .issuer_name(name_der)
        .subject_name(name_der)
        .public_key(key.public_key)
        .serial_number(1)
        .not_valid_before_utc(now)
        .not_valid_after_utc(later)
        .add_extension(str(oids.SUBJECT_KEY_IDENTIFIER), False, ski_der)
        .sign_unsigned()
    )

    recovered = cert.get_extension_value_der(str(oids.SUBJECT_KEY_IDENTIFIER))
    assert recovered == ski_der


# ── Manual runner (for running without pytest) ────────────────────────────────

def main():
    tests = [
        test_name_builder_common_name,
        test_name_builder_chaining,
        test_name_builder_empty,
        test_name_builder_add_attr,
        test_name_builder_add_attr_invalid_oid,
        test_name_builder_roundtrip,
        test_sign_unsigned_signature_algorithm,
        test_sign_unsigned_signature_value_empty,
        test_sign_unsigned_missing_field,
        test_sign_ec_sha256,
        test_extension_roundtrip_basic_constraints,
        test_extension_roundtrip_key_usage,
        test_subject_key_identifier_roundtrip,
    ]

    passed = 0
    failed = 0
    for test in tests:
        try:
            test()
            print(f"  ok  {test.__name__}")
            passed += 1
        except Exception as e:
            print(f"  FAIL {test.__name__}: {e}")
            traceback.print_exc()
            failed += 1

    print()
    print(f"{passed} passed, {failed} failed")
    if failed:
        raise SystemExit(1)


if __name__ == "__main__":
    main()