From 4876cf03bfca96d195ca4074b0b7af52aeab87dd Mon Sep 17 00:00:00 2001 From: kalsprite Date: Sat, 13 Jun 2026 23:40:08 -0700 Subject: [PATCH] asn1 DER reader/writer and X.509 reader --- core/crypto/x509/doc.odin | 111 +++ core/crypto/x509/parse.odin | 634 +++++++++++++++++ core/crypto/x509/verify.odin | 485 +++++++++++++ core/crypto/x509/x509.odin | 221 ++++++ core/encoding/asn1/asn1.odin | 72 ++ core/encoding/asn1/cursor.odin | 624 +++++++++++++++++ core/encoding/asn1/doc.odin | 29 + core/encoding/asn1/writer.odin | 498 +++++++++++++ core/net/addr.odin | 20 +- tests/core/crypto/x509/fuzz_x509.odin | 133 ++++ tests/core/crypto/x509/oom_x509.odin | 138 ++++ tests/core/crypto/x509/test_core_x509.odin | 661 ++++++++++++++++++ .../x509/testdata/chain_ec_expired_root.der | Bin 0 -> 433 bytes .../crypto/x509/testdata/chain_ec_expleaf.der | Bin 0 -> 447 bytes .../crypto/x509/testdata/chain_ec_inter.der | Bin 0 -> 428 bytes .../crypto/x509/testdata/chain_ec_leaf.der | Bin 0 -> 473 bytes .../x509/testdata/chain_ec_other_root.der | Bin 0 -> 429 bytes .../crypto/x509/testdata/chain_ec_root.der | Bin 0 -> 417 bytes .../crypto/x509/testdata/chain_ed_leaf.der | Bin 0 -> 368 bytes .../crypto/x509/testdata/chain_ed_root.der | Bin 0 -> 353 bytes tests/core/crypto/x509/testdata/cyc_a.der | Bin 0 -> 406 bytes .../core/crypto/x509/testdata/cyc_a_by_b.der | Bin 0 -> 408 bytes tests/core/crypto/x509/testdata/cyc_b.der | Bin 0 -> 407 bytes .../core/crypto/x509/testdata/cyc_b_by_a.der | Bin 0 -> 406 bytes tests/core/crypto/x509/testdata/cyc_leaf.der | Bin 0 -> 438 bytes .../crypto/x509/testdata/dup_extension.der | Bin 0 -> 462 bytes tests/core/crypto/x509/testdata/ec.der | Bin 0 -> 483 bytes tests/core/crypto/x509/testdata/ed.der | Bin 0 -> 316 bytes .../core/crypto/x509/testdata/eku_alt_bad.der | Bin 0 -> 451 bytes .../crypto/x509/testdata/eku_alt_good.der | Bin 0 -> 452 bytes .../crypto/x509/testdata/eku_alt_leaf.der | Bin 0 -> 477 bytes .../crypto/x509/testdata/neg_eku_inter.der | Bin 0 -> 454 bytes .../crypto/x509/testdata/neg_eku_leaf.der | Bin 0 -> 473 bytes .../x509/testdata/neg_expinter_inter.der | Bin 0 -> 430 bytes .../x509/testdata/neg_expinter_leaf.der | Bin 0 -> 442 bytes .../crypto/x509/testdata/neg_nc_inter.der | Bin 0 -> 455 bytes .../core/crypto/x509/testdata/neg_nc_leaf.der | Bin 0 -> 464 bytes .../crypto/x509/testdata/neg_nokcs_inter.der | Bin 0 -> 437 bytes .../crypto/x509/testdata/neg_nokcs_leaf.der | Bin 0 -> 444 bytes .../crypto/x509/testdata/neg_notca_inter.der | Bin 0 -> 425 bytes .../crypto/x509/testdata/neg_notca_leaf.der | Bin 0 -> 434 bytes tests/core/crypto/x509/testdata/neg_pl_a.der | Bin 0 -> 428 bytes tests/core/crypto/x509/testdata/neg_pl_b.der | Bin 0 -> 428 bytes .../core/crypto/x509/testdata/neg_pl_leaf.der | Bin 0 -> 427 bytes tests/core/crypto/x509/testdata/neg_root.der | Bin 0 -> 425 bytes tests/core/crypto/x509/testdata/rsa.der | Bin 0 -> 875 bytes .../core/crypto/x509/testdata/serial_zero.der | Bin 0 -> 969 bytes tests/core/encoding/asn1/fuzz_asn1.odin | 222 ++++++ tests/core/encoding/asn1/fuzz_writer.odin | 158 +++++ tests/core/encoding/asn1/oom_asn1.odin | 119 ++++ tests/core/encoding/asn1/test_core_asn1.odin | 532 ++++++++++++++ tests/core/encoding/asn1/test_writer.odin | 298 ++++++++ tests/core/net/test_core_net.odin | 7 + tests/core/normal.odin | 2 + 54 files changed, 4957 insertions(+), 7 deletions(-) create mode 100644 core/crypto/x509/doc.odin create mode 100644 core/crypto/x509/parse.odin create mode 100644 core/crypto/x509/verify.odin create mode 100644 core/crypto/x509/x509.odin create mode 100644 core/encoding/asn1/asn1.odin create mode 100644 core/encoding/asn1/cursor.odin create mode 100644 core/encoding/asn1/doc.odin create mode 100644 core/encoding/asn1/writer.odin create mode 100644 tests/core/crypto/x509/fuzz_x509.odin create mode 100644 tests/core/crypto/x509/oom_x509.odin create mode 100644 tests/core/crypto/x509/test_core_x509.odin create mode 100644 tests/core/crypto/x509/testdata/chain_ec_expired_root.der create mode 100644 tests/core/crypto/x509/testdata/chain_ec_expleaf.der create mode 100644 tests/core/crypto/x509/testdata/chain_ec_inter.der create mode 100644 tests/core/crypto/x509/testdata/chain_ec_leaf.der create mode 100644 tests/core/crypto/x509/testdata/chain_ec_other_root.der create mode 100644 tests/core/crypto/x509/testdata/chain_ec_root.der create mode 100644 tests/core/crypto/x509/testdata/chain_ed_leaf.der create mode 100644 tests/core/crypto/x509/testdata/chain_ed_root.der create mode 100644 tests/core/crypto/x509/testdata/cyc_a.der create mode 100644 tests/core/crypto/x509/testdata/cyc_a_by_b.der create mode 100644 tests/core/crypto/x509/testdata/cyc_b.der create mode 100644 tests/core/crypto/x509/testdata/cyc_b_by_a.der create mode 100644 tests/core/crypto/x509/testdata/cyc_leaf.der create mode 100644 tests/core/crypto/x509/testdata/dup_extension.der create mode 100644 tests/core/crypto/x509/testdata/ec.der create mode 100644 tests/core/crypto/x509/testdata/ed.der create mode 100644 tests/core/crypto/x509/testdata/eku_alt_bad.der create mode 100644 tests/core/crypto/x509/testdata/eku_alt_good.der create mode 100644 tests/core/crypto/x509/testdata/eku_alt_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_eku_inter.der create mode 100644 tests/core/crypto/x509/testdata/neg_eku_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_expinter_inter.der create mode 100644 tests/core/crypto/x509/testdata/neg_expinter_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_nc_inter.der create mode 100644 tests/core/crypto/x509/testdata/neg_nc_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_nokcs_inter.der create mode 100644 tests/core/crypto/x509/testdata/neg_nokcs_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_notca_inter.der create mode 100644 tests/core/crypto/x509/testdata/neg_notca_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_pl_a.der create mode 100644 tests/core/crypto/x509/testdata/neg_pl_b.der create mode 100644 tests/core/crypto/x509/testdata/neg_pl_leaf.der create mode 100644 tests/core/crypto/x509/testdata/neg_root.der create mode 100644 tests/core/crypto/x509/testdata/rsa.der create mode 100644 tests/core/crypto/x509/testdata/serial_zero.der create mode 100644 tests/core/encoding/asn1/fuzz_asn1.odin create mode 100644 tests/core/encoding/asn1/fuzz_writer.odin create mode 100644 tests/core/encoding/asn1/oom_asn1.odin create mode 100644 tests/core/encoding/asn1/test_core_asn1.odin create mode 100644 tests/core/encoding/asn1/test_writer.odin diff --git a/core/crypto/x509/doc.odin b/core/crypto/x509/doc.odin new file mode 100644 index 000000000..ee9104f94 --- /dev/null +++ b/core/crypto/x509/doc.odin @@ -0,0 +1,111 @@ +/* +X.509 v3 certificate parsing, signature verification, and chain (path) +validation. + +The parser is built on the strict DER reader in core:encoding/asn1 and +is zero-copy where possible: the returned Certificate's byte-slice +fields are views into the input DER, which must outlive it. The few +allocated fields (the extension/SAN tables) are released with +`destroy`. + +Input is DER. To parse a PEM certificate, decode it first with +core:encoding/pem (label "CERTIFICATE") and pass the resulting bytes. + +A successful `parse` means the bytes were well-formed, NOT that the +certificate is valid or trusted. The Certificate carries everything +the verifier needs: `raw_tbs` (the exact byte range a signature covers), +`raw_spki` (the range hashed for tls-server-end-point channel binding, +RFC 5929, and SPKI pinning), and `raw_issuer`/`raw_subject` (for the RFC +5280 binary-comparison rule). + +Hostname verification (`verify_hostname`) implements the RFC 6125 +subset modern clients use: subject alternative names only (no +CommonName fallback), with at most one wildcard as the entire +left-most label. + +Trust is established by `verify_chain`, which builds a path from a leaf +to a supplied trust anchor through supplied intermediates and checks, +for each certificate, validity, signature, name chaining, and the CA / +keyCertSign / pathLenConstraint rules; `verify_signature` exposes the +single-edge signature check on its own. + +LIMITATIONS: + + - RSA & ECDSA P-521 signatures are not implemented in core, these paths + return .Unsupported_Algorithm. + - Name constraints are NOT enforced yet; verify_chain fails CLOSED on + them (a chain through a name-constrained CA is automatically rejected. + - REVOCATION IS NOT CHECKED. verify_chain performs NO CRL or OCSP + revocation checking. Callers that need revocation (e.g. TLS clients) + MUST supply it separately (OCSP stapling, CRLite, …). + - Certificate policies / policy constraints are not evaluated, and + there is no Public Suffix List: a (CABF-forbidden) wildcard such as + "*.com" would match "host.com". As a backstop, verify_chain still + fails closed on any uninterpreted CRITICAL extension + (.Unhandled_Critical_Extension). + - EKU is checked only when opts.required_eku is set, and then by RFC + 5280 semantics: a certificate with no EKU extension is unrestricted + (it is not required to assert the purpose); a certificate that DOES + assert EKU must include the purpose, enforced across the leaf and + every intermediate (EKU nesting). Leaf KeyUsage is not checked + against the intended protocol use. + +Name constraints (Future PR): + + verify_chain does not yet DECODE name constraints, and it fails CLOSED + on them: any CA, intermediate or trust anchor, that asserts a + nameConstraints extension, critical or not, is refused as an issuer, + so a chain through a name-constrained CA is rejected, never accepted + unchecked. RFC 5280 section 6.1.4(g) requires a validator that + processes name constraints to enforce them regardless of criticality; + until we do, refusing is the only safe stand-in. + + Planned order: + 1. Enforce dNSName and iPAddress constraints, the forms real + name-constrained CAs almost always use, still failing closed + when a constraint uses a form we do not evaluate (directoryName, + rfc822Name, URI, otherName). A name-form constraint restricts + only names of that form (RFC 5280 section 4.2.1.10), so dNSName + constraints can be checked against dNSName SANs with no + distinguished-name decoding; this recovers the large majority of + name-constrained chains. + 2. Full section 4.2.1.10 enforcement: the remaining GeneralName + forms plus distinguished-name parsing and comparison, built and + validated test-first against the x509-limbo / BetterTLS + name-constraints corpus. + +Parsing is deliberately lenient wherever strictness is a validation +concern rather than a structural one. Exception: Parser rejects +duplicate extension OIDs (Duplicate_Extension, RFC 5280 section 4.2). + + - Only dNSName and iPAddress subject alternative names are decoded + (into `dns_names` / `ip_addresses`). Other GeneralName forms (URI, + rfc822Name, directoryName, otherName) are skipped; the raw SAN + extension is still available via `extensions`. + - Only the extensions path validation needs are decoded + (BasicConstraints, KeyUsage, ExtKeyUsage, SubjectAltName, + Subject/Authority Key Identifier). All others (AIA, CRL + distribution points, certificate policies, name constraints, …) + are left raw in `extensions`. + - Subject and issuer are exposed only as raw DER (`raw_subject` / + `raw_issuer`); distinguished-name attribute decoding (CN, O, …) is + not performed. + - Unsupported public-key curves yield Public_Key_Algorithm.Unknown. + - Non-conformant-but-extractable values are preserved: negative or + over-long serials, and validity dates far in the future. Validity + is stored as core:time.Time, which tops out near year 2262; dates + beyond that (the RFC 5280 "99991231235959Z" no-expiration sentinel) + saturate to that bound at parse time rather than failing, so they + read as "effectively never expires". + - Per-extension criticality rules (e.g. that subjectKeyIdentifier be + non-critical) are left to the caller via `Extension.critical`, and + a critical extension this package does not understand sets + `unhandled_critical` rather than failing the parse. + + +See: +- [[ https://www.rfc-editor.org/rfc/rfc5280 ]] +- [[ https://www.rfc-editor.org/rfc/rfc6125 ]] +- [[ https://www.rfc-editor.org/rfc/rfc5929 ]] +*/ +package x509 diff --git a/core/crypto/x509/parse.odin b/core/crypto/x509/parse.odin new file mode 100644 index 000000000..c50e6d5d0 --- /dev/null +++ b/core/crypto/x509/parse.odin @@ -0,0 +1,634 @@ +package x509 + +import "core:bytes" +import "core:encoding/asn1" + +/* +Ref: RFC 5280, Section 4.1, wire format: + +This grammar is decoded via a cursor over the DER. The contents flatten into +the certificate (see x509.odin). + + Certificate / TBSCertificate -> Certificate (flattened; raw, raw_tbs) + signatureAlgorithm + value -> signature_algorithm, signature_oid, signature + Version -> version (int: 1 / 2 / 3) + CertificateSerialNumber -> serial ([]byte, raw INTEGER content) + Validity / Time -> not_before, not_after (time.Time) + Name (issuer / subject) -> raw_issuer, raw_subject ([]byte DER; not decoded) + SubjectPublicKeyInfo -> raw_spki, public_key_algorithm, rsa_n/rsa_e/ec_point + Extension -> Extension struct, in `extensions` + issuer/subjectUniqueID -> skipped (obsolete) + +Certificate ::= SEQUENCE { + tbsCertificate TBSCertificate, + signatureAlgorithm AlgorithmIdentifier, + signatureValue BIT STRING +} + +TBSCertificate ::= SEQUENCE { + version [0] EXPLICIT Version DEFAULT v1, + serialNumber CertificateSerialNumber, + signature AlgorithmIdentifier, + issuer Name, + validity Validity, + subject Name, + subjectPublicKeyInfo SubjectPublicKeyInfo, + issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL, + subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, + extensions [3] EXPLICIT Extensions OPTIONAL +} + +Version ::= INTEGER { v1(0), v2(1), v3(2) } + +CertificateSerialNumber ::= INTEGER + +Validity ::= SEQUENCE { + notBefore Time, + notAfter Time } + +Time ::= CHOICE { + utcTime UTCTime, + generalTime GeneralizedTime } + +UniqueIdentifier ::= BIT STRING + +SubjectPublicKeyInfo ::= SEQUENCE { + algorithm AlgorithmIdentifier, + subjectPublicKey BIT STRING } + +Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension + +Extension ::= SEQUENCE { + extnID OBJECT IDENTIFIER, + critical BOOLEAN DEFAULT FALSE, + extnValue OCTET STRING + -- contains the DER encoding of an ASN.1 value + -- corresponding to the extension type identified + -- by extnID +} + +AlgorithmIdentifier ::= SEQUENCE { + algorithm OBJECT IDENTIFIER, + parameters ANY DEFINED BY algorithm OPTIONAL +} + +Name ::= CHOICE { -- only one possibility for now -- + rdnSequence RDNSequence +} + +RDNSequence ::= SEQUENCE OF RelativeDistinguishedName + +RelativeDistinguishedName ::= SET SIZE (1..MAX) OF AttributeTypeAndValue + +AttributeTypeAndValue ::= SEQUENCE { + type AttributeType, + value AttributeValue +} + +AttributeType ::= OBJECT IDENTIFIER + +AttributeValue ::= ANY -- DEFINED BY AttributeType + +DirectoryString ::= CHOICE { + teletexString TeletexString (SIZE (1..MAX)), + printableString PrintableString (SIZE (1..MAX)), + universalString UniversalString (SIZE (1..MAX)), + utf8String UTF8String (SIZE (1..MAX)), + bmpString BMPString (SIZE (1..MAX)) +} + +*/ + +// parse decodes one DER certificate. The returned Certificate holds views into `der`, which must outlive it; +// the allocated tables are released with destroy(). Trailing bytes after the certificate are an error. +@(require_results) +parse :: proc(der: []byte, allocator := context.allocator) -> (cert: Certificate, err: Error) { + r: asn1.Cursor + asn1.cursor_init(&r, der) + + outer, oerr := asn1.read_sequence(&r) + if oerr != .None || asn1.done(&r) != .None { + return {}, .Malformed + } + cert.raw = der[:r.pos] + + // tbsCertificate: capture the full element (header included); + tbs_start := outer.pos + tbs, terr := asn1.read_sequence(&outer) + if terr != .None { + return {}, .Malformed + } + cert.raw_tbs = outer.data[tbs_start:outer.pos] + + // signatureAlgorithm + signatureValue. + sig_oid, _, serr := _read_algorithm_identifier(&outer) + if serr != .None { + return {}, .Malformed + } + cert.signature_oid = sig_oid + cert.signature_algorithm = _signature_algorithm(sig_oid) + + sig_bits, sberr := asn1.read_bit_string_octets(&outer) + if sberr != .None || asn1.done(&outer) != .None { + return {}, .Malformed + } + cert.signature = sig_bits + + // ---- TBSCertificate ---- + + // [0] EXPLICIT version (DEFAULT v1). + cert.version = 1 + vr, has_version, verr := asn1.read_explicit(&tbs, 0) + if verr != .None { + return {}, .Malformed + } + if has_version { + v, vierr := asn1.read_i64(&vr) + if vierr != .None || asn1.done(&vr) != .None { + return {}, .Malformed + } + if v < 0 || v > 2 { + return {}, .Unsupported_Version + } + cert.version = int(v) + 1 + } + + // The serial is read as a raw INTEGER (not unsigned): RFC 5280 requires it to be positive, but non-conformant CAs issue negative + // serials and rejecting them is a validation policy, not a parsing one. The two's-complement content is preserved verbatim. + serial, snerr := asn1.read_integer_bytes(&tbs) + if snerr != .None { + return {}, .Malformed + } + cert.serial = serial + + // signature must match the outer signatureAlgorithm per RFC 5280 section 4.1.1.2. + tbs_sig_oid, _, tserr := _read_algorithm_identifier(&tbs) + if tserr != .None { + return {}, .Malformed + } + if !bytes.equal(tbs_sig_oid, sig_oid) { + return {}, .Malformed + } + + // issuer + issuer_start := tbs.pos + if _, ierr := asn1.read_sequence(&tbs); ierr != .None { + return {}, .Malformed + } + cert.raw_issuer = tbs.data[issuer_start:tbs.pos] + + // validity + validity, vderr := asn1.read_sequence(&tbs) + if vderr != .None { + return {}, .Invalid_Validity + } + nb, nberr := asn1.read_time(&validity) + na, naerr := asn1.read_time(&validity) + if nberr != .None || naerr != .None || asn1.done(&validity) != .None { + return {}, .Invalid_Validity + } + cert.not_before = nb + cert.not_after = na + + // subject + subject_start := tbs.pos + if _, suberr := asn1.read_sequence(&tbs); suberr != .None { + return {}, .Malformed + } + cert.raw_subject = tbs.data[subject_start:tbs.pos] + + // subjectPublicKeyInfo, full element preserved for hashing + spki_start := tbs.pos + spki, sperr := asn1.read_sequence(&tbs) + if sperr != .None { + return {}, .Malformed + } + cert.raw_spki = tbs.data[spki_start:tbs.pos] + if kerr := _parse_spki(&cert, &spki); kerr != .None { + return {}, kerr + } + + // issuerUniqueID / subjectUniqueID - obsolete; skip if present + for number in u32(1) ..= u32(2) { + if asn1.is_empty(&tbs) { + break + } + tag, perr := asn1.peek_tag(&tbs) + if perr != .None { + return {}, .Malformed + } + if tag.class == .Context_Specific && tag.number == number { + if asn1.skip(&tbs) != .None { + return {}, .Malformed + } + } + } + + // [3] EXPLICIT extensions. + cert.max_path_len = -1 + er, has_exts, eerr := asn1.read_explicit(&tbs, 3) + if eerr != .None || asn1.done(&tbs) != .None { + return {}, .Malformed + } + if has_exts { + if cert.version != 3 { + return {}, .Malformed + } + if xerr := _parse_extensions(&cert, &er, allocator); xerr != .None { + destroy(&cert, allocator) + return {}, xerr + } + } + + return cert, .None +} + +// Internals. + +// _read_algorithm_identifier reads SEQUENCE { OID, params ANY OPTIONAL }, returning the OID content and the raw parameter element +// (nil when absent). +@(private) +_read_algorithm_identifier :: proc( + r: ^asn1.Cursor, +) -> ( + oid: []byte, + params: []byte, + err: asn1.Error, +) { + alg, aerr := asn1.read_sequence(r) + if aerr != .None { + return nil, nil, aerr + } + oid, err = asn1.read_oid(&alg) + if err != .None { + return nil, nil, err + } + if !asn1.is_empty(&alg) { + params_start := alg.pos + if serr := asn1.skip(&alg); serr != .None { + return nil, nil, serr + } + params = alg.data[params_start:alg.pos] + } + if derr := asn1.done(&alg); derr != .None { + return nil, nil, derr + } + return oid, params, .None +} + +@(private) +_signature_algorithm :: proc(oid: []byte) -> Signature_Algorithm { + switch { + case bytes.equal(oid, _OID_SIG_RSA_SHA256): + return .RSA_SHA256 + case bytes.equal(oid, _OID_SIG_ECDSA_SHA256): + return .ECDSA_SHA256 + case bytes.equal(oid, _OID_SIG_RSA_SHA384): + return .RSA_SHA384 + case bytes.equal(oid, _OID_SIG_RSA_SHA512): + return .RSA_SHA512 + case bytes.equal(oid, _OID_SIG_ECDSA_SHA384): + return .ECDSA_SHA384 + case bytes.equal(oid, _OID_SIG_ECDSA_SHA512): + return .ECDSA_SHA512 + case bytes.equal(oid, _OID_ED25519): + return .Ed25519 + case bytes.equal(oid, _OID_SIG_RSA_PSS): + return .RSA_PSS + case bytes.equal(oid, _OID_SIG_RSA_SHA1): + return .RSA_SHA1 + } + return .Unknown +} + +@(private) +_parse_spki :: proc(cert: ^Certificate, spki: ^asn1.Cursor) -> Error { + key_oid, key_params, aerr := _read_algorithm_identifier(spki) + if aerr != .None { + return .Malformed + } + key_bits, kberr := asn1.read_bit_string_octets(spki) + if kberr != .None || asn1.done(spki) != .None { + return .Malformed + } + + switch { + case bytes.equal(key_oid, _OID_KEY_RSA): + cert.public_key_algorithm = .RSA + // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } + kr: asn1.Cursor + asn1.cursor_init(&kr, key_bits) + rsa, rerr := asn1.read_sequence(&kr) + if rerr != .None || asn1.done(&kr) != .None { + return .Malformed + } + n, nerr := asn1.read_unsigned_integer_bytes(&rsa) + e, eerr := asn1.read_unsigned_integer_bytes(&rsa) + if nerr != .None || eerr != .None || asn1.done(&rsa) != .None { + return .Malformed + } + cert.rsa_n = n + cert.rsa_e = e + + case bytes.equal(key_oid, _OID_KEY_EC): + // Parameters carry the named curve: OID wrapped in the params element we captured raw + pr: asn1.Cursor + asn1.cursor_init(&pr, key_params) + curve_oid, cerr := asn1.read_oid(&pr) + if cerr != .None || asn1.done(&pr) != .None { + return .Malformed + } + switch { + case bytes.equal(curve_oid, _OID_CURVE_P256): + cert.public_key_algorithm = .ECDSA_P256 + case bytes.equal(curve_oid, _OID_CURVE_P384): + cert.public_key_algorithm = .ECDSA_P384 + case bytes.equal(curve_oid, _OID_CURVE_P521): + cert.public_key_algorithm = .ECDSA_P521 + case: + cert.public_key_algorithm = .Unknown + } + cert.ec_point = key_bits + + case bytes.equal(key_oid, _OID_ED25519): + cert.public_key_algorithm = .Ed25519 + if len(key_bits) != 32 { + return .Malformed + } + cert.ec_point = key_bits + + case: + cert.public_key_algorithm = .Unknown + } + return .None +} + +@(private) +_parse_extensions :: proc( + cert: ^Certificate, + er: ^asn1.Cursor, + allocator := context.allocator, +) -> Error { + exts, xerr := asn1.read_sequence(er) + if xerr != .None || asn1.done(er) != .None { + return .Malformed + } + + // Find allocation size + count := 0 + { + tmp := exts + for !asn1.is_empty(&tmp) { + if asn1.skip(&tmp) != .None { + return .Malformed + } + count += 1 + } + } + exts_table, merr := make([]Extension, count, allocator) + if merr != nil { + return .Allocation_Failed + } + cert.extensions = exts_table + + for i in 0 ..< count { + // Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING } + ext, eerr := asn1.read_sequence(&exts) + if eerr != .None { + return .Malformed + } + oid, oerr := asn1.read_oid(&ext) + if oerr != .None { + return .Malformed + } + critical := false + tag, perr := asn1.peek_tag(&ext) + if perr != .None { + return .Malformed + } + if tag == asn1.universal(.Boolean) { + c, berr := asn1.read_boolean(&ext) + if berr != .None { + return .Malformed + } + critical = c + } + value, verr := asn1.read_octet_string(&ext) + if verr != .None || asn1.done(&ext) != .None { + return .Malformed + } + + // RFC 5280 section 4.2: "A certificate MUST NOT include more than one instance of a particular extension." + for j in 0 ..< i { + if bytes.equal(cert.extensions[j].oid, oid) { + return .Duplicate_Extension + } + } + cert.extensions[i] = Extension { + oid = oid, + critical = critical, + value = value, + } + + if herr := _parse_known_extension(cert, oid, critical, value, allocator); herr != .None { + return herr + } + } + return .None +} + +@(private) +_parse_known_extension :: proc( + cert: ^Certificate, + oid: []byte, + critical: bool, + value: []byte, + allocator := context.allocator, +) -> Error { + vr: asn1.Cursor + asn1.cursor_init(&vr, value) + + switch { + case bytes.equal(oid, _OID_EXT_BASIC_CONSTRAINTS): + // BasicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL } + bc, err := asn1.read_sequence(&vr) + if err != .None || asn1.done(&vr) != .None { + return .Invalid_Extension + } + if !asn1.is_empty(&bc) { + tag, perr := asn1.peek_tag(&bc) + if perr != .None { + return .Invalid_Extension + } + if tag == asn1.universal(.Boolean) { + ca, berr := asn1.read_boolean(&bc) + if berr != .None { + return .Invalid_Extension + } + cert.is_ca = ca + } + } + if !asn1.is_empty(&bc) { + depth, derr := asn1.read_i64(&bc) + if derr != .None || depth < 0 { + return .Invalid_Extension + } + cert.max_path_len = int(depth) + } + if asn1.done(&bc) != .None { + return .Invalid_Extension + } + cert.basic_constraints_valid = true + + case bytes.equal(oid, _OID_EXT_KEY_USAGE): + bits, unused, err := asn1.read_bit_string(&vr) + if err != .None || asn1.done(&vr) != .None { + return .Invalid_Extension + } + total := len(bits) * 8 - unused + usage: Key_Usage + for bit in Key_Usage_Bit { + i := int(bit) + if i >= total { + continue + } + if bits[i / 8] & (0x80 >> uint(i % 8)) != 0 { + usage += {bit} + } + } + cert.key_usage = usage + cert.has_key_usage = true + + case bytes.equal(oid, _OID_EXT_EXT_KEY_USAGE): + seq, err := asn1.read_sequence(&vr) + if err != .None || asn1.done(&vr) != .None { + return .Invalid_Extension + } + for !asn1.is_empty(&seq) { + purpose, perr := asn1.read_oid(&seq) + if perr != .None { + return .Invalid_Extension + } + switch { + case bytes.equal(purpose, _OID_EKU_SERVER_AUTH): + cert.ext_key_usage += {.Server_Auth} + case bytes.equal(purpose, _OID_EKU_CLIENT_AUTH): + cert.ext_key_usage += {.Client_Auth} + case bytes.equal(purpose, _OID_EKU_CODE_SIGNING): + cert.ext_key_usage += {.Code_Signing} + case bytes.equal(purpose, _OID_EKU_EMAIL_PROTECTION): + cert.ext_key_usage += {.Email_Protection} + case bytes.equal(purpose, _OID_EKU_TIME_STAMPING): + cert.ext_key_usage += {.Time_Stamping} + case bytes.equal(purpose, _OID_EKU_OCSP_SIGNING): + cert.ext_key_usage += {.OCSP_Signing} + case bytes.equal(purpose, _OID_EKU_ANY): + cert.ext_key_usage += {.Any} + case: + cert.eku_has_unknown = true + } + } + cert.has_ext_key_usage = true + + case bytes.equal(oid, _OID_EXT_SAN): + return _parse_san(cert, &vr, allocator) + + case bytes.equal(oid, _OID_EXT_SUBJECT_KEY_ID): + ski, err := asn1.read_octet_string(&vr) + if err != .None || asn1.done(&vr) != .None { + return .Invalid_Extension + } + cert.subject_key_id = ski + + case bytes.equal(oid, _OID_EXT_AUTHORITY_KEY_ID): + // AuthorityKeyIdentifier ::= SEQUENCE { keyIdentifier [0] IMPLICIT OCTET STRING OPTIONAL, ... } + aki, err := asn1.read_sequence(&vr) + if err != .None || asn1.done(&vr) != .None { + return .Invalid_Extension + } + if !asn1.is_empty(&aki) { + tag, perr := asn1.peek_tag(&aki) + if perr != .None { + return .Invalid_Extension + } + if tag == asn1.context_specific(0, false) { + kid, kerr := asn1.expect(&aki, tag) + if kerr != .None { + return .Invalid_Extension + } + cert.authority_key_id = kid + } + } + // authorityCertIssuer [1] and authorityCertSerialNumber [2] are intentionally not decoded: keyIdentifier is the form used in + // practice, and AKI is only a path-building hint (issuers are matched by DN + signature), so the other fields carry no + // validation weight here. + + case: + if critical { + cert.unhandled_critical = true + } + } + return .None +} + +// GeneralNames ::= SEQUENCE OF GeneralName; we extract dNSName ([2] IA5String) and iPAddress ([7] OCTET STRING). +@(private) +_parse_san :: proc(cert: ^Certificate, vr: ^asn1.Cursor, allocator := context.allocator) -> Error { + names, err := asn1.read_sequence(vr) + if err != .None || asn1.done(vr) != .None { + return .Invalid_Extension + } + + dns_count, ip_count := 0, 0 + { + tmp := names + for !asn1.is_empty(&tmp) { + tag, content, gerr := asn1.read_any(&tmp) + if gerr != .None || tag.class != .Context_Specific { + return .Invalid_Extension + } + switch tag.number { + case 2: + dns_count += 1 + case 7: + if len(content) != 4 && len(content) != 16 { + return .Invalid_Extension + } + ip_count += 1 + } + } + } + + // On allocation failure the caller (parse) unwinds every table via destroy. + if dns_count > 0 { + dns_table, derr := make([]string, dns_count, allocator) + if derr != nil { + return .Allocation_Failed + } + cert.dns_names = dns_table + } + if ip_count > 0 { + ip_table, ierr := make([][]byte, ip_count, allocator) + if ierr != nil { + return .Allocation_Failed + } + cert.ip_addresses = ip_table + } + + di, ii := 0, 0 + for !asn1.is_empty(&names) { + tag, content, gerr := asn1.read_any(&names) + if gerr != .None { + return .Invalid_Extension + } + switch tag.number { + case 2: + cert.dns_names[di] = string(content) + di += 1 + case 7: + cert.ip_addresses[ii] = content + ii += 1 + } + } + return .None +} diff --git a/core/crypto/x509/verify.odin b/core/crypto/x509/verify.odin new file mode 100644 index 000000000..80a413902 --- /dev/null +++ b/core/crypto/x509/verify.odin @@ -0,0 +1,485 @@ +package x509 + +import "core:bytes" +import "core:crypto/ecdsa" +import "core:crypto/ed25519" +import "core:crypto/hash" +import "core:net" +import "core:strings" +import "core:time" + +// valid_at returns true if and only if (⟺) `now` falls within the +// certificate's validity window (inclusive on both ends, per RFC 5280 +// section 4.1.2.5). Obtain `now` from e.g. time.now(). +@(require_results) +valid_at :: proc "contextless" (cert: ^Certificate, now: time.Time) -> bool { + return time.diff(cert.not_before, now) >= 0 && time.diff(now, cert.not_after) >= 0 +} + +// verify_hostname checks `host` against the certificate's subject +// alternative names per RFC 6125: +// +// - IP-literal hosts match iPAddress SANs by byte equality only. +// - DNS hosts match dNSName SANs case-insensitively; one wildcard is +// permitted as the ENTIRE left-most label of the SAN +// ("*.example.com" matches "a.example.com" but never +// "a.b.example.com", "example.com", or partial labels like +// "f*.example.com"). +// - The legacy CommonName fallback is not implemented (deprecated +// since RFC 6125). +// +// Returns .None on match, .Hostname_Mismatch when SANs of the right +// kind exist but none match, and .No_SAN when the certificate carries +// no SAN of the queried kind. +@(require_results) +verify_hostname :: proc(cert: ^Certificate, host: string) -> Error { + hostname := strings.trim_suffix(host, ".") + + // IP literal? + if addr := net.parse_address(hostname); addr != nil { + if len(cert.ip_addresses) == 0 { + return .No_SAN + } + addr_bytes: [16]byte + addr_len: int + switch a in addr { + case net.IP4_Address: + tmp := a + copy(addr_bytes[:], tmp[:]) + addr_len = 4 + case net.IP6_Address: + tmp := transmute([16]byte)a + copy(addr_bytes[:], tmp[:]) + addr_len = 16 + } + for san in cert.ip_addresses { + if bytes.equal(san, addr_bytes[:addr_len]) { + return .None + } + } + return .Hostname_Mismatch + } + + if len(cert.dns_names) == 0 { + return .No_SAN + } + for san in cert.dns_names { + if _match_hostname(san, hostname) { + return .None + } + } + return .Hostname_Mismatch +} + +// _match_hostname implements the RFC 6125 section 6.4.3 subset described on verify_hostname. +@(private) +_match_hostname :: proc(pattern, host: string) -> bool { + p := strings.trim_suffix(pattern, ".") + if len(p) == 0 || len(host) == 0 { + return false + } + + if !strings.has_prefix(p, "*.") { + return strings.equal_fold(p, host) + } + + // Wildcard: "*." + base. The host's first label is consumed by the wildcard; the + // remainder must equal the base, and the wildcard must not swallow more than one label. + base := p[2:] + dot := strings.index_byte(host, '.') + if dot < 1 { + // No label boundary (or empty first label); a bare host can't match a wildcard. + return false + } + rest := host[dot + 1:] + if len(rest) == 0 { + return false + } + // The base itself may not contain another wildcard. + if strings.index_byte(base, '*') >= 0 { + return false + } + return strings.equal_fold(base, rest) +} + +// ============================================================ +// Signature verification and chain (path) validation. +// +// verify_signature checks that `cert`'s signature was produced by the +// private key matching `issuer`'s public key, over cert.raw_tbs (the +// signed TBSCertificate). It checks ONLY the cryptographic signature — +// not validity periods, names, basic constraints, or that `issuer` is +// actually authorized to issue `cert`; verify_chain does all of that. +// +// Returns .None on a good signature, .Signature_Invalid on a bad one, +// and .Unsupported_Algorithm when the signature algorithm (RSA, ECDSA +// P-521) or the issuer key type is not implemented here. +@(require_results) +verify_signature :: proc(cert: ^Certificate, issuer: ^Certificate) -> Error { + #partial switch cert.signature_algorithm { + case .RSA_SHA1, .RSA_SHA256, .RSA_SHA384, .RSA_SHA512, .RSA_PSS: + return .Unsupported_Algorithm + + case .ECDSA_SHA256, .ECDSA_SHA384, .ECDSA_SHA512: + curve: ecdsa.Curve + #partial switch issuer.public_key_algorithm { + case .ECDSA_P256: + curve = .SECP256R1 + case .ECDSA_P384: + curve = .SECP384R1 + case: + // P-521 (unsupported by the verifier) or a non-EC issuer key paired with an ECDSA signature. + return .Unsupported_Algorithm + } + h := _hash_for_ecdsa(cert.signature_algorithm) + pub: ecdsa.Public_Key + defer ecdsa.public_key_clear(&pub) + if !ecdsa.public_key_set_bytes(&pub, curve, issuer.ec_point) { + return .Signature_Invalid + } + if !ecdsa.verify_asn1(&pub, h, cert.raw_tbs, cert.signature) { + return .Signature_Invalid + } + return .None + + case .Ed25519: + if issuer.public_key_algorithm != .Ed25519 { + return .Unsupported_Algorithm + } + pub: ed25519.Public_Key + defer ed25519.public_key_clear(&pub) + if !ed25519.public_key_set_bytes(&pub, issuer.ec_point) { + return .Signature_Invalid + } + if !ed25519.verify(&pub, cert.raw_tbs, cert.signature) { + return .Signature_Invalid + } + return .None + } + return .Unsupported_Algorithm +} + +@(private) +_hash_for_ecdsa :: proc "contextless" (s: Signature_Algorithm) -> hash.Algorithm { + #partial switch s { + case .ECDSA_SHA384: + return .SHA384 + case .ECDSA_SHA512: + return .SHA512 + } + return .SHA256 +} + +// Verify_Options parameterizes verify_chain. +Verify_Options :: struct { + // Trust anchors. A chain is accepted iff it terminates at one of + // these (matched by name + signature, as ordinary issuers). Usually + // self-signed roots, but any certificate trusted a priori works. + roots: []^Certificate, + // Untrusted intermediates available to bridge the leaf to a root. + // Order does not matter; verify_chain searches them. + intermediates: []^Certificate, + // Reference time for every certificate's validity window. + current_time: time.Time, + // If non-empty, the leaf must pass verify_hostname for this name. + dns_name: string, + // If set, the leaf's ExtKeyUsage must permit this purpose (a leaf + // with no EKU extension is unrestricted and always passes). TLS + // clients pass .Server_Auth. + required_eku: Maybe(EKU_Bit), +} + +// _MAX_CHAIN_DEPTH bounds path search depth, to stop cycles among +// mutually-issuing intermediates. +@(private) +_MAX_CHAIN_DEPTH :: 10 + +// _MAX_SIG_CHECKS caps the total number of signature verifications a +// single verify_chain may perform across its entire path search. +// Guard for path-building denial of service, RFC 4158 section 2.4.2. +@(private) +_MAX_SIG_CHECKS :: 100 + +// verify_chain builds and validates a certificate path from `leaf` to +// one of opts.roots, using opts.intermediates to bridge the gap. On +// success it returns the verified chain leaf-first (chain[0] == leaf, +// chain[len-1] is the trust anchor); the slice is allocated & freed. +// +// Each non-anchor certificate in the path is checked for: +// - validity at opts.current_time; +// - Signature verifies against the next certificate's key; +// - Name chaining (issuer DN == subject DN); +// For every intermediate issuer: +// - It is a CA (basicConstraints); +// - Is within pathLenConstraint (counting only non-self-issued +// intermediates, per RFC 5280 section 6.1.4); +// - if it declares KeyUsage: permits keyCertSign. + +// A certificate carrying a critical extension this package does not +// interpret fails the path closed (see .Unhandled_Critical_Extension). +// When opts.dns_name is set the leaf must pass verify_hostname; when +// opts.required_eku is set the leaf AND every intermediate must permit +// that purpose (e.g. an email-only sub-CA cannot issue a TLS server leaf). +// +// The trust anchor is checked like any issuer (validity, CA / +// keyCertSign, pathLenConstraint, no uninterpreted critical extension), +// except that its own self-signature is not re-verified, since it is +// trusted a priori. An expired or malformed root is therefore rejected; +// resilience to that comes from the search trying every other available +// anchor and intermediate. +// +@(require_results) +verify_chain :: proc( + leaf: ^Certificate, + opts: Verify_Options, + allocator := context.allocator, +) -> ( + chain: []^Certificate, + err: Error, +) { + // Leaf-definitive checks + if leaf.unhandled_critical { + return nil, .Unhandled_Critical_Extension + } + if verr := _check_validity(leaf, opts.current_time); verr != .None { + return nil, verr + } + // RSA is identifiable from the leaf's signature algorithm OID, report correctly + if leaf.signature_algorithm == .RSA_SHA1 || + leaf.signature_algorithm == .RSA_SHA256 || + leaf.signature_algorithm == .RSA_SHA384 || + leaf.signature_algorithm == .RSA_SHA512 || + leaf.signature_algorithm == .RSA_PSS { + return nil, .Unsupported_Algorithm + } + if opts.dns_name != "" { + if herr := verify_hostname(leaf, opts.dns_name); herr != .None { + return nil, herr + } + } + + // Reserve the whole path up front: with capacity in hand, the appends below should never reallocate, so single point of OOM risk + acc, aerr := make([dynamic]^Certificate, 0, _MAX_CHAIN_DEPTH + 1, allocator) + if aerr != nil { + return nil, .Allocation_Failed + } + append(&acc, leaf) + + saw_unsupported := false + saw_eku_reject := false + opts_local := opts + budget := _MAX_SIG_CHECKS + if _build_to_anchor(leaf, &opts_local, &acc, 0, &saw_unsupported, &saw_eku_reject, &budget) { + // Success: caller owns the returned chain. + // EKU nesting (leaf + every intermediate permits opts.required_eku) + // was enforced as a usability gate during the search itself, so any + // path returned here already satisfies it. + return acc[:], .None + } + delete(acc) + // An unimplemented signature algorithm is a hard capability gap and + // outranks the EKU policy mismatch; both outrank the generic failure. + switch { + case saw_unsupported: + return nil, .Unsupported_Algorithm + case saw_eku_reject: + return nil, .Incompatible_Usage + case: + return nil, .Unknown_Authority + } +} + +// Performs a depth-first search for a path from `cert` up to a trust anchor. +// `acc` holds the chain so far, leaf-first and including `cert`; on success +// the matched issuers (ending at the anchor) are appended. `depth` is the +// recursion depth and len(acc) - 1 is the number of intermediates already +// below the next issuer (used for pathLenConstraint). `budget` is the shared +// remaining signature-verification allowance (see _MAX_SIG_CHECKS); when it is +// exhausted the search stops. `saw_unsupported` / `saw_eku_reject` are +// set when a branch was abandoned only because the signature algorithm +// was unimplemented, or because a cert failed the required-EKU check, so +// verify_chain can report the more specific error when no path is found. +@(private) +_build_to_anchor :: proc( + cert: ^Certificate, + opts: ^Verify_Options, + acc: ^[dynamic]^Certificate, + depth: int, + saw_unsupported: ^bool, + saw_eku_reject: ^bool, + budget: ^int, +) -> (found: bool) { + if depth >= _MAX_CHAIN_DEPTH { + return false + } + + // EKU nesting: when the caller requires a purpose, the leaf and every + // intermediate must permit it. `cert` here is always the leaf (depth 0) + // or an intermediate being extended through, so anchors stay exempt. + // Enforcing EKU as a usability gate lets the search backtrack and try an + // alternative issuer that does permit the purpose (two same-subject/same-key + // intermediates can differ in EKU); a post-build filter would instead commit + // to whichever path was found first and reject the whole verification. + if eku_ask, ok := opts.required_eku.?; ok { + if !_permits_eku(cert, eku_ask) { + saw_eku_reject^ = true + return false + } + } + + // Non-self-issued intermediates already beneath the next issuer, for the pathLenConstraint check (RFC 5280 section 6.1.4) + below := _non_self_issued_below(acc) + + // Prefer terminating at a trust anchor. The anchor must have issued `cert` (name chaining + signature) and, + // like any CA, be fit to act as an issuer: valid at `now`, a CA with keyCertSign, within its pathLenConstraint, + // no uninterpreted critical extension. Its own self-signature is NOT re-verified; it is trusted a priori. + for root in opts.roots { + if !_is_issuer_of(root, cert) || !_issuer_usable(root, opts.current_time, below) { + continue + } + if budget^ <= 0 { + return false + } + budget^ -= 1 + #partial switch verify_signature(cert, root) { + case .None: + append(acc, root) + return true + case .Unsupported_Algorithm: + saw_unsupported^ = true + case: + // bad signature: not this anchor + } + } + + // Otherwise extend through an untrusted intermediate and recurse. + for inter in opts.intermediates { + if inter == cert || _in_chain(acc, inter) { + continue + } + if !_is_issuer_of(inter, cert) || !_issuer_usable(inter, opts.current_time, below) { + continue + } + if budget^ <= 0 { + return false + } + budget^ -= 1 + #partial switch verify_signature(cert, inter) { + case .None: + append(acc, inter) + if _build_to_anchor(inter, opts, acc, depth + 1, saw_unsupported, saw_eku_reject, budget) { + return true + } + pop(acc) // backtrack + case .Unsupported_Algorithm: + saw_unsupported^ = true + case: + // bad signature: not this issuer + } + } + return false +} + +@(private) +_check_validity :: proc "contextless" (cert: ^Certificate, now: time.Time) -> Error { + if time.diff(now, cert.not_before) > 0 { + return .Not_Yet_Valid + } + if time.diff(cert.not_after, now) > 0 { + return .Expired + } + return .None +} + +// Reports whether `issuer` could have issued `cert`: the issuer's +// subject DN must equal cert's issuer DN (RFC 5280 section 6.1 +// name chaining, by binary DER comparison). The authority/subject key +// identifiers are NOT used as a filter, RFC 5280 section 4.2.1.1 +// (and RFC 4158) make them a non-authoritative path-building hint. +@(private) +_is_issuer_of :: proc(issuer, cert: ^Certificate) -> bool { + return bytes.equal(issuer.raw_subject, cert.raw_issuer) +} + +// Counts the non-self-issued intermediates already in the path below +// the next issuer, i.e. everything in `acc` except the leaf (index 0). +// A self-issued certificate (subject DN == issuer DN, used for CA key +// rollover) does not count against pathLenConstraint (RFC 5280 sections +// 4.2.1.9 and 6.1.4(l)). +@(private) +_non_self_issued_below :: proc(acc: ^[dynamic]^Certificate) -> int { + n := 0 + // Skip the leaf at index 0; it is the end-entity, not an intermediate. + for i in 1 ..< len(acc) { + c := acc[i] + if !bytes.equal(c.raw_subject, c.raw_issuer) { + n += 1 + } + } + return n +} + +// Applies the RFC 5280 section 6.1.4 checks to an INTERMEDIATE issuer: +// a CA with (if asserted) keyCertSign, valid at `now`, within its +// pathLenConstraint given `below` non-self-issued intermediates beneath +// it, and with no uninterpreted critical extension. +@(private) +_issuer_usable :: proc(issuer: ^Certificate, now: time.Time, below: int) -> bool { + if issuer.unhandled_critical { + return false + } + // Name constraints are NOT decoded yet. RFC 5280 section 6.1.4(g) + // requires a validator that processes NC to enforce it regardless of + // criticality, so we fail to prevent escapements + if _has_extension(issuer, _OID_EXT_NAME_CONSTRAINTS) { + return false + } + if !issuer.basic_constraints_valid || !issuer.is_ca { + return false + } + if issuer.has_key_usage && .Key_Cert_Sign not_in issuer.key_usage { + return false + } + if issuer.max_path_len >= 0 && below > issuer.max_path_len { + return false + } + if _check_validity(issuer, now) != .None { + return false + } + return true +} + +// Reports whether `cert` allows the given Extended Key Usage purpose: +// no EKU extension means unrestricted, anyExtendedKeyUsage +// permits everything, otherwise the purpose must be listed. Applied to +// the leaf and every intermediate for EKU nesting (see verify_chain). +@(private) +_permits_eku :: proc(cert: ^Certificate, ask: EKU_Bit) -> bool { + if !cert.has_ext_key_usage { + return true // no EKU extension: unrestricted + } + if .Any in cert.ext_key_usage { + return true + } + return ask in cert.ext_key_usage +} + +@(private) +_has_extension :: proc(cert: ^Certificate, oid: []byte) -> bool { + for ext in cert.extensions { + if bytes.equal(ext.oid, oid) { + return true + } + } + return false +} + +@(private) +_in_chain :: proc(acc: ^[dynamic]^Certificate, cert: ^Certificate) -> bool { + for c in acc { + if c == cert { + return true + } + } + return false +} diff --git a/core/crypto/x509/x509.odin b/core/crypto/x509/x509.odin new file mode 100644 index 000000000..242c2bb38 --- /dev/null +++ b/core/crypto/x509/x509.odin @@ -0,0 +1,221 @@ +package x509 + +import "core:time" + +Error :: enum { + None, + Malformed, // DER-level violation, structural mismatch, or trailing garbage. + Unsupported_Version, // Certificate version beyond v3. + Invalid_Validity, // notBefore/notAfter missing or unparseable. + Invalid_Extension, // A recognized extension's content didn't match its schema. + Duplicate_Extension, // The same extension OID appeared more than once (RFC 5280 section 4.2 forbids this). + Hostname_Mismatch, // Hostname verification: no SAN matched. + No_SAN, // Hostname verification: the certificate has no usable SANs of the queried kind. + Allocation_Failed, // Allocating the extension/SAN tables failed. + + // --- verification (verify_signature / verify_chain) --- + Signature_Invalid, // A signature did not verify against the issuer's public key. + Unsupported_Algorithm, // The signature or public-key algorithm is recognized but not implemented here. + Not_Yet_Valid, // A certificate's notBefore is in the future relative to the supplied time. + Expired, // A certificate's notAfter is in the past relative to the supplied time. + Unknown_Authority, // (no issuer found, failed name chaining, validity, CA constraints, or signature verification). + Unhandled_Critical_Extension, // Failed to handle a critical extension, automatic rejection + Incompatible_Usage, // Lacks EKU, or EKU not authorized +} + +// Signature_Algorithm covers the PKIX signature algorithms a client +// encounters in practice. RSA_PSS parameters are not interpreted; the +// raw AlgorithmIdentifier is preserved on the Certificate. +Signature_Algorithm :: enum { + Unknown, + RSA_SHA1, // obsolete; parsed for identification only + RSA_SHA256, + RSA_SHA384, + RSA_SHA512, + RSA_PSS, + ECDSA_SHA256, + ECDSA_SHA384, + ECDSA_SHA512, + Ed25519, +} + +// Public_Key_Algorithm identifies the certificate's subject public key +// type. Unknown covers key algorithms (or EC curves) this package does +// not decode; the SubjectPublicKeyInfo bytes remain available in +// raw_spki. +Public_Key_Algorithm :: enum { + Unknown, + RSA, + ECDSA_P256, + ECDSA_P384, + ECDSA_P521, + Ed25519, +} + +// Key_Usage bits per RFC 5280 section 4.2.1.3 +Key_Usage_Bit :: enum u16 { + Digital_Signature = 0, + Content_Commitment = 1, + Key_Encipherment = 2, + Data_Encipherment = 3, + Key_Agreement = 4, + Key_Cert_Sign = 5, + CRL_Sign = 6, + Encipher_Only = 7, + Decipher_Only = 8, +} +// Key_Usage is the decoded KeyUsage extension bit set. +Key_Usage :: bit_set[Key_Usage_Bit;u16] + +// Extended key usage purposes (RFC 5280 section 4.2.1.12) recognized by name; +// unrecognized purposes set `eku_has_unknown`. +EKU_Bit :: enum u8 { + Server_Auth, + Client_Auth, + Code_Signing, + Email_Protection, + Time_Stamping, + OCSP_Signing, + Any, +} +// Ext_Key_Usage is the decoded set of recognized ExtKeyUsage purposes; +// unrecognized purposes set Certificate.eku_has_unknown. +Ext_Key_Usage :: bit_set[EKU_Bit;u8] + +// Extension is one raw entry from the TBS extensions list. `oid` is +// the OID content octets; `value` the extnValue OCTET STRING content. +Extension :: struct { + oid: []byte, + critical: bool, + value: []byte, +} + +// Certificate is a parsed X.509 v3 certificate. +// Byte-slice fields are views into the input DER (which must outlive the Certificate) +// The dns_names / ip_addresses / extensions slices are *allocated* (their elements still view the DER) and released by destroy. +Certificate :: struct { + // Raw views into the input DER. + raw: []byte, // the whole Certificate element + raw_tbs: []byte, // TBSCertificate, header included, the signed bytes + raw_issuer: []byte, // issuer Name element (RFC 5280 binary comparison) + raw_subject: []byte, // subject Name element + raw_spki: []byte, // SubjectPublicKeyInfo element; hash for tls-server-end-point / SPKI pinning + version: int, // 1, 2, or 3 + // Certificate serial number as the raw DER INTEGER content (minimal two's-complement). + // It is an opaque identifier, compare and display by these bytes. A positive serial whose top + // bit is set carries a leading 0x00 sign octet (as openssl shows it); a serial of 0 is the single octet {0x00}. RFC 5280 requires + // serials to be positive and <= 20 octets, but non-conformant (negative, zero, or over-long) serials are preserved + serial: []byte, + signature_algorithm: Signature_Algorithm, + signature_oid: []byte, // OID content octets + signature: []byte, // signatureValue payload (whole octets) + + // Validity bounds. time.Time is i64 nanoseconds and tops out near year 2262; X.509 dates beyond that (notably the RFC 5280 + // "99991231235959Z" no-expiration sentinel) saturate to that bound at parse time, so they compare as "effectively never expires" + // rather than overflowing. See asn1's _time_from_unix. + not_before: time.Time, + not_after: time.Time, + public_key_algorithm: Public_Key_Algorithm, + // RSA: modulus and exponent magnitudes. + rsa_n: []byte, + rsa_e: []byte, + // ECDSA: the uncompressed point (0x04 || X || Y); Ed25519: the 32-byte key. + ec_point: []byte, + + // BasicConstraints (basic_constraints_valid reports presence). + basic_constraints_valid: bool, + is_ca: bool, + max_path_len: int, // -1 when absent + has_key_usage: bool, + key_usage: Key_Usage, + has_ext_key_usage: bool, + ext_key_usage: Ext_Key_Usage, + eku_has_unknown: bool, + subject_key_id: []byte, + authority_key_id: []byte, + + dns_names: []string, // ALLOCATED + ip_addresses: [][]byte, // ALLOCATED + + // Every extension, in order, including ones this package does not interpret. + extensions: []Extension, // ALLOCATED + + // True if a critical extension other than the ones interpreted + // here was present. RFC 5280 requires a relying party to reject + // such a certificate at validation time; parsing still succeeds so + // the caller can inspect. + // + // The specific unhandled OIDs are recoverable by walking `extensions` + // for entries with `critical = true` whose OID is none of the handled + // ones (_OID_EXT_*). + unhandled_critical: bool, +} + +destroy :: proc(cert: ^Certificate, allocator := context.allocator) { + delete(cert.dns_names, allocator) + delete(cert.ip_addresses, allocator) + delete(cert.extensions, allocator) + cert^ = {} +} + +// PKIX object identifiers as DER content octets, for direct comparison against asn1.read_oid results. +@(rodata, private) +_OID_SIG_RSA_SHA1 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05} // sha1WithRSAEncryption (1.2.840.113549.1.1.5) +@(rodata, private) +_OID_SIG_RSA_SHA256 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B} // sha256WithRSAEncryption (1.2.840.113549.1.1.11) +@(rodata, private) +_OID_SIG_RSA_SHA384 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0C} // sha384WithRSAEncryption (1.2.840.113549.1.1.12) +@(rodata, private) +_OID_SIG_RSA_SHA512 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0D} // sha512WithRSAEncryption (1.2.840.113549.1.1.13) +@(rodata, private) +_OID_SIG_RSA_PSS := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0A} // id-RSASSA-PSS (1.2.840.113549.1.1.10) +@(rodata, private) +_OID_SIG_ECDSA_SHA256 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02} // ecdsa-with-SHA256 (1.2.840.10045.4.3.2) +@(rodata, private) +_OID_SIG_ECDSA_SHA384 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x03} // ecdsa-with-SHA384 (1.2.840.10045.4.3.3) +@(rodata, private) +_OID_SIG_ECDSA_SHA512 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x04} // ecdsa-with-SHA512 (1.2.840.10045.4.3.4) +@(rodata, private) +_OID_ED25519 := []byte{0x2B, 0x65, 0x70} // id-Ed25519 (1.3.101.112), RFC 8410 + +@(rodata, private) +_OID_KEY_RSA := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} // rsaEncryption (1.2.840.113549.1.1.1) +@(rodata, private) +_OID_KEY_EC := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01} // id-ecPublicKey (1.2.840.10045.2.1) + +@(rodata, private) +_OID_CURVE_P256 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07} // secp256r1 (1.2.840.10045.3.1.7) +@(rodata, private) +_OID_CURVE_P384 := []byte{0x2B, 0x81, 0x04, 0x00, 0x22} // secp384r1 (1.3.132.0.34) +@(rodata, private) +_OID_CURVE_P521 := []byte{0x2B, 0x81, 0x04, 0x00, 0x23} // secp521r1 (1.3.132.0.35) + +@(rodata, private) +_OID_EXT_SUBJECT_KEY_ID := []byte{0x55, 0x1D, 0x0E} // id-ce-subjectKeyIdentifier (2.5.29.14) +@(rodata, private) +_OID_EXT_KEY_USAGE := []byte{0x55, 0x1D, 0x0F} // id-ce-keyUsage (2.5.29.15) +@(rodata, private) +_OID_EXT_SAN := []byte{0x55, 0x1D, 0x11} // id-ce-subjectAltName (2.5.29.17) +@(rodata, private) +_OID_EXT_BASIC_CONSTRAINTS := []byte{0x55, 0x1D, 0x13} // id-ce-basicConstraints (2.5.29.19) +@(rodata, private) +_OID_EXT_NAME_CONSTRAINTS := []byte{0x55, 0x1D, 0x1E} // id-ce-nameConstraints (2.5.29.30) +@(rodata, private) +_OID_EXT_AUTHORITY_KEY_ID := []byte{0x55, 0x1D, 0x23} // id-ce-authorityKeyIdentifier (2.5.29.35) +@(rodata, private) +_OID_EXT_EXT_KEY_USAGE := []byte{0x55, 0x1D, 0x25} // id-ce-extKeyUsage (2.5.29.37) + +@(rodata, private) +_OID_EKU_ANY := []byte{0x55, 0x1D, 0x25, 0x00} // anyExtendedKeyUsage (2.5.29.37.0) +@(rodata, private) +_OID_EKU_SERVER_AUTH := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01} // id-kp-serverAuth (1.3.6.1.5.5.7.3.1) +@(rodata, private) +_OID_EKU_CLIENT_AUTH := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x02} // id-kp-clientAuth (1.3.6.1.5.5.7.3.2) +@(rodata, private) +_OID_EKU_CODE_SIGNING := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x03} // id-kp-codeSigning (1.3.6.1.5.5.7.3.3) +@(rodata, private) +_OID_EKU_EMAIL_PROTECTION := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x04} // id-kp-emailProtection (1.3.6.1.5.5.7.3.4) +@(rodata, private) +_OID_EKU_TIME_STAMPING := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08} // id-kp-timeStamping (1.3.6.1.5.5.7.3.8) +@(rodata, private) +_OID_EKU_OCSP_SIGNING := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x09} // id-kp-OCSPSigning (1.3.6.1.5.5.7.3.9) diff --git a/core/encoding/asn1/asn1.odin b/core/encoding/asn1/asn1.odin new file mode 100644 index 000000000..2e0dd09f2 --- /dev/null +++ b/core/encoding/asn1/asn1.odin @@ -0,0 +1,72 @@ +package asn1 + +// Tag class from the identifier octet (X.690 section 8.1.2.2). +Class :: enum u8 { + Universal = 0, + Application = 1, + Context_Specific = 2, + Private = 3, +} + +// Tag_Number enumerates the UNIVERSAL tag numbers relevant to DER as +// used by PKIX. Context-specific tags carry their number directly in +// Tag.number. +Tag_Number :: enum u32 { + Boolean = 1, + Integer = 2, + Bit_String = 3, + Octet_String = 4, + Null = 5, + Object_Identifier = 6, + Enumerated = 10, + UTF8_String = 12, + Sequence = 16, + Set = 17, + Numeric_String = 18, + Printable_String = 19, + Teletex_String = 20, + IA5_String = 22, + UTC_Time = 23, + Generalized_Time = 24, + Visible_String = 26, + BMP_String = 30, +} + +// Tag is a decoded identifier octet (plus high-tag-number form). +Tag :: struct { + class: Class, + constructed: bool, + number: u32, +} + +Error :: enum { + None, + Truncated, // The element (or its header) extends past the end of the input. + Invalid_Tag, // Malformed identifier octets (non-minimal high-tag-number form, or a tag number that overflows u32). + Invalid_Length, // Indefinite length, non-minimal length encoding, or a length beyond what this implementation supports. + Unexpected_Tag, // An expect_*/read_* procedure found a different tag than required. + Invalid_Boolean, // BOOLEAN content was not exactly one octet of 0x00 or 0xFF. + Invalid_Integer, // INTEGER content was empty or not minimally encoded. + Integer_Overflow, // INTEGER does not fit the requested machine type. + Negative_Integer, // INTEGER was negative where an unsigned value is required. + Invalid_Bit_String, // BIT STRING content violated X.690 sections 8.6/11.2 (bad unused-bit count, non-zero padding bits, or unused bits where none are permitted). + Invalid_Null, // NULL with non-empty content. + Invalid_Object_Identifier, // OBJECT IDENTIFIER content was empty or not minimally encoded. + Invalid_Time, // UTCTime/GeneralizedTime outside the RFC 5280 DER profile (YYMMDDHHMMSSZ / YYYYMMDDHHMMSSZ, Zulu only, seconds present, no fractional seconds), or an impossible date. + Leftover_Bytes, // done() was called with input remaining. + // An OBJECT IDENTIFIER arc exceeds u64. Arc magnitude is unbounded per X.660 (e.g. 2.25 UUID-derived OIDs carry a 128-bit arc), so + // this is a representation limit of oid_components/oid_to_string, not a malformed input; compare such OIDs by their raw bytes. + Arc_Overflow, + Allocation_Failed, // OOM + Buffer_Too_Small, // encode: the destination slice is shorter than encoded_len. +} + +// universal builds a UNIVERSAL-class tag. +universal :: proc "contextless" (number: Tag_Number, constructed := false) -> Tag { + return Tag{class = .Universal, constructed = constructed, number = u32(number)} +} + +// context_specific builds a CONTEXT-SPECIFIC-class tag ("[n]"). +context_specific :: proc "contextless" (number: u32, constructed := true) -> Tag { + return Tag{class = .Context_Specific, constructed = constructed, number = number} +} diff --git a/core/encoding/asn1/cursor.odin b/core/encoding/asn1/cursor.odin new file mode 100644 index 000000000..508d3ff3d --- /dev/null +++ b/core/encoding/asn1/cursor.odin @@ -0,0 +1,624 @@ +package asn1 + +import "core:strings" +import dt "core:time" + +// Cursor is a position into DER input, advanced by the read_* procs. +Cursor :: struct { + data: []byte, + pos: int, +} + +// Points a Cursor at `data` and rewinds it to the start. +cursor_init :: proc "contextless" (r: ^Cursor, data: []byte) { + r.data = data + r.pos = 0 +} + +// Returns the number of unconsumed bytes. +remaining :: proc "contextless" (r: ^Cursor) -> int { + return len(r.data) - r.pos +} + +// Reports whether the Cursor has been fully consumed. +is_empty :: proc "contextless" (r: ^Cursor) -> bool { + return r.pos >= len(r.data) +} + +// Returns Leftover_Bytes if input remains. DER structures are exact: every SEQUENCE walk should end with done() on its sub-cursor. +done :: proc "contextless" (r: ^Cursor) -> Error { + if r.pos < len(r.data) { + return .Leftover_Bytes + } + return .None +} + +// Reads one complete TLV element of any tag, returning the tag and a view of the content octets. +read_any :: proc "contextless" (r: ^Cursor) -> (tag: Tag, content: []byte, err: Error) { + tag, err = _read_tag(r) + if err != .None { + return + } + length: int + length, err = _read_length(r) + if err != .None { + return + } + if length > remaining(r) { + err = .Truncated + return + } + content = r.data[r.pos:r.pos + length] + r.pos += length + return +} + +// Decodes the next element's tag without consuming anything. +peek_tag :: proc "contextless" (r: ^Cursor) -> (tag: Tag, err: Error) { + tmp := r^ + return _read_tag(&tmp) +} + +// Consumes one complete element of any tag. +skip :: proc "contextless" (r: ^Cursor) -> Error { + _, _, err := read_any(r) + return err +} + +// Reads one element and requires its tag to match exactly. +expect :: proc "contextless" (r: ^Cursor, tag: Tag) -> (content: []byte, err: Error) { + got: Tag + got, content, err = read_any(r) + if err != .None { + return + } + if got != tag { + err = .Unexpected_Tag + } + return +} + +// Enters a SEQUENCE, returning a sub-cursor over its content. +read_sequence :: proc "contextless" (r: ^Cursor) -> (seq: Cursor, err: Error) { + content, eerr := expect(r, universal(.Sequence, true)) + if eerr != .None { + return {}, eerr + } + return Cursor{data = content}, .None +} + +// Enters a SET, returning a sub-cursor over its content. +// NOTE: DER requires SET OF contents to be sorted; this cursor does +// not verify ordering, consumers that care (none in PKIX cert parsing) must check. +read_set :: proc "contextless" (r: ^Cursor) -> (set: Cursor, err: Error) { + content, eerr := expect(r, universal(.Set, true)) + if eerr != .None { + return {}, eerr + } + return Cursor{data = content}, .None +} + +// Handles `[number] EXPLICIT ... OPTIONAL`: if the next element is the given +// constructed context-specific tag, it is consumed and a sub-cursor over its +// content returned with present=true. Otherwise nothing is consumed. +read_explicit :: proc "contextless" (r: ^Cursor, number: u32) -> (inner: Cursor, present: bool, err: Error) { + if is_empty(r) { + return {}, false, .None + } + tag, perr := peek_tag(r) + if perr != .None { + return {}, false, perr + } + if tag != context_specific(number, true) { + return {}, false, .None + } + content, eerr := expect(r, tag) + if eerr != .None { + return {}, false, eerr + } + return Cursor{data = content}, true, .None +} + +// Reads a BOOLEAN. DER: exactly one octet, 0x00 or 0xFF. +read_boolean :: proc "contextless" (r: ^Cursor) -> (value: bool, err: Error) { + content, eerr := expect(r, universal(.Boolean)) + if eerr != .None { + return false, eerr + } + if len(content) != 1 { + return false, .Invalid_Boolean + } + switch content[0] { + case 0x00: + return false, .None + case 0xFF: + return true, .None + } + return false, .Invalid_Boolean +} + +// Reads an INTEGER and returns the validated, minimally-encoded two's-complement content octets. +read_integer_bytes :: proc "contextless" (r: ^Cursor) -> (content: []byte, err: Error) { + content, err = expect(r, universal(.Integer)) + if err != .None { + return + } + err = _check_integer(content) + return +} + +// Reads an INTEGER that must fit in an i64. +read_i64 :: proc "contextless" (r: ^Cursor) -> (value: i64, err: Error) { + content, ierr := read_integer_bytes(r) + if ierr != .None { + return 0, ierr + } + if len(content) > 8 { + return 0, .Integer_Overflow + } + if content[0] & 0x80 != 0 { + value = -1 // sign-extend + } + for b in content { + value = value << 8 | i64(b) + } + return value, .None +} + +// Reads a non-negative INTEGER and returns its magnitude octets with any leading 0x00 +// sign octet stripped, the shape RSA moduli, public exponents, and certificate serials +// are consumed in. +read_unsigned_integer_bytes :: proc "contextless" (r: ^Cursor) -> (magnitude: []byte, err: Error) { + content, ierr := read_integer_bytes(r) + if ierr != .None { + return nil, ierr + } + if content[0] & 0x80 != 0 { + return nil, .Negative_Integer + } + if len(content) > 1 && content[0] == 0x00 { + content = content[1:] + } + return content, .None +} + +// Reads a BIT STRING, returning the payload octets and the count of unused trailing bits +// in the final octet. DER: primitive form only, unused count 0..7 (0 if the payload is +// empty), and the unused bits themselves must be zero. +read_bit_string :: proc "contextless" (r: ^Cursor) -> (bits: []byte, unused: int, err: Error) { + content, eerr := expect(r, universal(.Bit_String)) + if eerr != .None { + return nil, 0, eerr + } + if len(content) < 1 { + return nil, 0, .Invalid_Bit_String + } + unused = int(content[0]) + bits = content[1:] + if unused > 7 { + return nil, 0, .Invalid_Bit_String + } + if len(bits) == 0 && unused != 0 { + return nil, 0, .Invalid_Bit_String + } + if unused > 0 { + mask := byte(1 << uint(unused)) - 1 + if bits[len(bits) - 1] & mask != 0 { + return nil, 0, .Invalid_Bit_String + } + } + return bits, unused, .None +} + +// Reads a BIT STRING that must be a whole number of octets (unused == 0), +// the only form PKIX uses for SubjectPublicKeyInfo keys and signature values. +read_bit_string_octets :: proc "contextless" (r: ^Cursor) -> (octets: []byte, err: Error) { + bits, unused, berr := read_bit_string(r) + if berr != .None { + return nil, berr + } + if unused != 0 { + return nil, .Invalid_Bit_String + } + return bits, .None +} + +// Reads an OCTET STRING (primitive form only). +read_octet_string :: proc "contextless" (r: ^Cursor) -> (octets: []byte, err: Error) { + return expect(r, universal(.Octet_String)) +} + +// Reads a NULL (content must be empty). +read_null :: proc "contextless" (r: ^Cursor) -> Error { + content, err := expect(r, universal(.Null)) + if err != .None { + return err + } + if len(content) != 0 { + return .Invalid_Null + } + return .None +} + +// Reads an OBJECT IDENTIFIER and returns a view of its content octets, +// validated for minimal base-128 encoding. The validation is structural +// only: arc magnitude is unbounded per X.660, so PKIX consumers should +// compare these bytes directly against known-OID constants. +// oid_components/oid_to_string decode arcs when needed, reporting +// Arc_Overflow for arcs beyond u64. +read_oid :: proc "contextless" (r: ^Cursor) -> (raw: []byte, err: Error) { + raw, err = expect(r, universal(.Object_Identifier)) + if err != .None { + return + } + if len(raw) == 0 { + return nil, .Invalid_Object_Identifier + } + // Validate: each subidentifier is base-128 with minimal encoding + // (no 0x80 lead octet) and terminates (last octet has bit 8 clear). + expect_start := true + for b in raw { + if expect_start && b == 0x80 { + return nil, .Invalid_Object_Identifier + } + expect_start = b & 0x80 == 0 + } + if !expect_start { + return nil, .Invalid_Object_Identifier + } + return raw, .None +} + +// Times are returned as core:time.Time. time.Time is i64 nanoseconds +// and so tops out near year 2262, while UTCTime/GeneralizedTime reach +// year 9999; dates beyond what time.Time can hold (notably RFC 5280's +// "99991231235959Z" no-well-defined-expiration sentinel) saturate to +// time.Time's bound rather than erroring, so a far-future cert still +// parses and reads as "effectively never expires". See _time_from_unix. + +// read_utc_time reads a UTCTime in the RFC 5280 DER profile: +// "YYMMDDHHMMSSZ", with the sliding century window (00-49 → 20xx, +// 50-99 → 19xx). +read_utc_time :: proc "contextless" (r: ^Cursor) -> (value: dt.Time, err: Error) { + content, eerr := expect(r, universal(.UTC_Time)) + if eerr != .None { + return {}, eerr + } + if len(content) != 13 || content[12] != 'Z' { + return {}, .Invalid_Time + } + yy, ok := _two_digits(content[0:2]) + if !ok { + return {}, .Invalid_Time + } + year := 2000 + yy + if yy >= 50 { + year = 1900 + yy + } + secs := _unix_from_fields(year, content[2:12]) or_return + return _time_from_unix(secs), .None +} + +// Reads a GeneralizedTime in the RFC 5280 DER profile: "YYYYMMDDHHMMSSZ", Zulu only, no fractional seconds. +read_generalized_time :: proc "contextless" (r: ^Cursor) -> (value: dt.Time, err: Error) { + content, eerr := expect(r, universal(.Generalized_Time)) + if eerr != .None { + return {}, eerr + } + if len(content) != 15 || content[14] != 'Z' { + return {}, .Invalid_Time + } + hi, ok1 := _two_digits(content[0:2]) + lo, ok2 := _two_digits(content[2:4]) + if !ok1 || !ok2 { + return {}, .Invalid_Time + } + secs := _unix_from_fields(hi * 100 + lo, content[4:14]) or_return + return _time_from_unix(secs), .None +} + +// Reads either time form, PKIX Validity uses UTCTime for dates through 2049 and GeneralizedTime from 2050 on. +read_time :: proc "contextless" (r: ^Cursor) -> (value: dt.Time, err: Error) { + tag, perr := peek_tag(r) + if perr != .None { + return {}, perr + } + if tag == universal(.Generalized_Time) { + return read_generalized_time(r) + } + return read_utc_time(r) +} + +// OBJECT IDENTIFIER helpers (allocating). + +// Decodes validated OID content octets (from read_oid) into their integer arcs, +// e.g. {1, 2, 840, 113549, 1, 1, 1}. Arcs beyond u64 (legal per X.660, see +// Arc_Overflow) are not representable; compare such OIDs by their raw bytes instead. +oid_components :: proc(raw: []byte, allocator := context.allocator) -> (arcs: []u64, err: Error) { + if len(raw) == 0 { + return nil, .Invalid_Object_Identifier + } + count := 1 // the first octet encodes two arcs + for b in raw { + if b & 0x80 == 0 { + count += 1 + } + } + + out, merr := make([]u64, count, allocator) + if merr != nil { + return nil, .Allocation_Failed + } + idx := 0 + acc: u64 = 0 + first := true + for b in raw { + if acc > max(u64) >> 7 { + delete(out, allocator) + return nil, .Arc_Overflow + } + acc = acc << 7 | u64(b & 0x7F) + if b & 0x80 != 0 { + continue + } + if first { + // X.690 section 8.19.4: the first subidentifier encodes the first + // two arcs as arc1*40 + arc2 (arc1 limited to 0..2; arc2 + // unbounded only when arc1 == 2). + switch { + case acc < 40: + out[idx] = 0 + out[idx + 1] = acc + case acc < 80: + out[idx] = 1 + out[idx + 1] = acc - 40 + case: + out[idx] = 2 + out[idx + 1] = acc - 80 + } + idx += 2 + first = false + } else { + out[idx] = acc + idx += 1 + } + acc = 0 + } + return out, .None +} + +// Renders OID content octets in dotted-decimal form ("1.2.840.113549.1.1.1") for diagnostics. +// The arcs are streamed directly into the result; the only allocation is the returned string. +oid_to_string :: proc(raw: []byte, allocator := context.allocator) -> (str: string, err: Error) { + if len(raw) == 0 { + return "", .Invalid_Object_Identifier + } + + sb: strings.Builder + if _, berr := strings.builder_init(&sb, allocator); berr != nil { + return "", .Allocation_Failed + } + defer if err != .None { + strings.builder_destroy(&sb) + } + + // Builder writes swallow allocator failures, so tally the written + // vs expected lengths and treat any shortfall as an allocation + // failure (the same defense pem.encode uses). + written, expected := 0, 0 + acc: u64 = 0 + first := true + for b in raw { + if acc > max(u64) >> 7 { + err = .Arc_Overflow + return "", err + } + acc = acc << 7 | u64(b & 0x7F) + if b & 0x80 != 0 { + continue + } + if first { + // See oid_components for the X.690 section 8.19.4 split of the first subidentifier. + arc1, arc2: u64 + switch { + case acc < 40: + arc1, arc2 = 0, acc + case acc < 80: + arc1, arc2 = 1, acc - 40 + case: + arc1, arc2 = 2, acc - 80 + } + written += strings.write_u64(&sb, arc1) + written += strings.write_byte(&sb, '.') + written += strings.write_u64(&sb, arc2) + expected += _decimal_len(arc1) + 1 + _decimal_len(arc2) + first = false + } else { + written += strings.write_byte(&sb, '.') + written += strings.write_u64(&sb, acc) + expected += 1 + _decimal_len(acc) + } + acc = 0 + } + if written != expected { + err = .Allocation_Failed + return "", err + } + return strings.to_string(sb), .None +} + +@(private) +_decimal_len :: proc "contextless" (v: u64) -> (n: int) { + n = 1 + x := v + for x >= 10 { + x /= 10 + n += 1 + } + return n +} + +@(private) +_read_tag :: proc "contextless" (r: ^Cursor) -> (tag: Tag, err: Error) { + if is_empty(r) { + return {}, .Truncated + } + b := r.data[r.pos] + r.pos += 1 + + tag.class = Class(b >> 6) + tag.constructed = b & 0x20 != 0 + number := u32(b & 0x1F) + + if number != 0x1F { + tag.number = number + return tag, .None + } + + // High-tag-number form (X.690 section 8.1.2.4): base-128, minimal (first + // octet may not be 0x80), and the resulting number must be >= 31. + number = 0 + for i := 0; ; i += 1 { + if is_empty(r) { + return {}, .Truncated + } + nb := r.data[r.pos] + r.pos += 1 + if i == 0 && nb == 0x80 { + return {}, .Invalid_Tag + } + if number > (max(u32) >> 7) { + return {}, .Invalid_Tag + } + number = number << 7 | u32(nb & 0x7F) + if nb & 0x80 == 0 { + break + } + } + if number < 0x1F { + return {}, .Invalid_Tag + } + tag.number = number + return tag, .None +} + +@(private) +_read_length :: proc "contextless" (r: ^Cursor) -> (length: int, err: Error) { + if is_empty(r) { + return 0, .Truncated + } + b := r.data[r.pos] + r.pos += 1 + + if b & 0x80 == 0 { + return int(b), .None + } + + n := int(b & 0x7F) + if n == 0 { + // 0x80: indefinite length, BER only. + return 0, .Invalid_Length + } + if n > 4 { + // Lengths beyond 2^31 are not plausible inputs here. + return 0, .Invalid_Length + } + if remaining(r) < n { + return 0, .Truncated + } + + value := 0 + for i in 0 ..< n { + value = value << 8 | int(r.data[r.pos + i]) + } + r.pos += n + + // DER minimality: no leading zero octet, and the long form may only be used for lengths >= 128. + if r.data[r.pos - n] == 0 || value < 0x80 { + return 0, .Invalid_Length + } + if value < 0 { + return 0, .Invalid_Length + } + return value, .None +} + +// Enforces X.690 section 8.3: at least one octet, and minimal (the first nine bits may not be all-zero or all-one). +@(private) +_check_integer :: proc "contextless" (content: []byte) -> Error { + switch len(content) { + case 0: + return .Invalid_Integer + case 1: + return .None + } + if content[0] == 0x00 && content[1] & 0x80 == 0 { + return .Invalid_Integer + } + if content[0] == 0xFF && content[1] & 0x80 != 0 { + return .Invalid_Integer + } + return .None +} + +@(private) +_two_digits :: proc "contextless" (b: []byte) -> (value: int, ok: bool) { + if b[0] < '0' || b[0] > '9' || b[1] < '0' || b[1] > '9' { + return 0, false + } + return int(b[0] - '0') * 10 + int(b[1] - '0'), true +} + +// Converts Unix seconds to a time.Time, saturating at time.Time's +// representable bounds. time.Time counts i64 nanoseconds, so it tops +// out near year 2262; a far-future X.509 date (notably RFC 5280's +// "99991231235959Z" no-expiration sentinel) saturates to that bound +// rather than overflowing, and so reads as "effectively never". +@(private) +_time_from_unix :: proc "contextless" (secs: i64) -> dt.Time { + NS_PER_SEC :: i64(1_000_000_000) + if secs > max(i64) / NS_PER_SEC { + return dt.Time{_nsec = max(i64)} + } + if secs < min(i64) / NS_PER_SEC { + return dt.Time{_nsec = min(i64)} + } + return dt.Time{_nsec = secs * NS_PER_SEC} +} + +// Converts a year plus "MMDDHHMMSS" into seconds since the Unix epoch, +// validating field ranges. Computed directly (via the civil-date algorithm +// below) rather than through time.Time so the whole year 1..9999 range is +// computable before _time_from_unix decides how to represent it. +@(private) +_unix_from_fields :: proc "contextless" (year: int, fields: []byte) -> (unix_seconds: i64, err: Error) { + month, mo_ok := _two_digits(fields[0:2]) + day, d_ok := _two_digits(fields[2:4]) + hour, h_ok := _two_digits(fields[4:6]) + minute, min_ok := _two_digits(fields[6:8]) + second, s_ok := _two_digits(fields[8:10]) + if !mo_ok || !d_ok || !h_ok || !min_ok || !s_ok { + return 0, .Invalid_Time + } + if month < 1 || month > 12 || day < 1 || day > 31 { + return 0, .Invalid_Time + } + if hour > 23 || minute > 59 || second > 59 { + return 0, .Invalid_Time + } + days := _days_from_civil(i64(year), month, day) + return days * 86400 + i64(hour) * 3600 + i64(minute) * 60 + i64(second), .None +} + +// Returns the number of days since 1970-01-01 for a proleptic-Gregorian date +// (Ref: http://howardhinnant.github.io/date_algorithms.html#days_from_civil). +// Exact for any representable year; no epoch-range limit. +@(private) +_days_from_civil :: proc "contextless" (y: i64, m, d: int) -> i64 { + yy := y - (m <= 2 ? 1 : 0) + era := (yy >= 0 ? yy : yy - 399) / 400 + yoe := yy - era * 400 // [0, 399] + doy := i64((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1) // [0, 365] + doe := yoe * 365 + yoe / 4 - yoe / 100 + doy // [0, 146096] + return era * 146097 + doe - 719468 +} diff --git a/core/encoding/asn1/doc.odin b/core/encoding/asn1/doc.odin new file mode 100644 index 000000000..4931be10a --- /dev/null +++ b/core/encoding/asn1/doc.odin @@ -0,0 +1,29 @@ +/* +Strict DER (Distinguished Encoding Rules) reader and writer for the PKIX +subset of ASN.1, the substrate for X.509 certificates and related structures. + +Reader: a `Cursor` over the input; `read_*` procs return VIEWS into it (only +`oid_components` / `oid_to_string` take an allocator). The input must outlive +the results. + +Writer: build a declarative tree of `Value` nodes with the constructors, then +`encoded_len` + `encode` (no allocation, into a caller buffer) or `marshal` +(one allocation). Constructors BORROW their inputs, so build and encode +within one expression (or back children with a slice that outlives the call); +the encoded output is self-contained and aliases nothing. + +Scope & limitations: + +- DER only (no BER/CER); strict (minimal lengths, minimal integers, ...). +- PKIX subset: no typed readers for STRING/REAL/... (walk with `read_any`); + the writer emits low-tag-number identifiers only (tag number <= 30). + +Times use core:time.Time per the RFC 5280 DER profile (Zulu, seconds present, +no fractional seconds). time.Time is i64 nanoseconds (tops out near year 2262) +while UTCTime/GeneralizedTime reach 9999; on read, dates beyond that saturate. + +See: +- [[ https://www.itu.int/rec/T-REC-X.690 ]] +- [[ https://www.rfc-editor.org/rfc/rfc5280 ]] +*/ +package asn1 diff --git a/core/encoding/asn1/writer.odin b/core/encoding/asn1/writer.odin new file mode 100644 index 000000000..f34a37234 --- /dev/null +++ b/core/encoding/asn1/writer.odin @@ -0,0 +1,498 @@ +package asn1 + +/* +DER (Distinguished Encoding Rules) writer, the inverse of the cursor +reader, and the encoding substrate for signatures, keys, and certificates. + +The model is declarative: build a tree of `Value` nodes with the +constructors below, then turn it into bytes with `encoded_len` + `encode` +(no allocation, into a caller buffer) or `marshal` (one allocation, owned +slice). A SEQUENCE/SET simply holds its children, so length is discovered +by a measure pass rather than back-patched, and `set` can sort its +children (DER SET OF ordering) without disturbing this surface, both are +additive. + +Zero-copy, with a lifetime caveat: the constructors BORROW their byte and +child inputs (no copies), so a Value tree is only valid while those inputs +live. In practice build and encode the tree within one expression, or back +its children with a slice/array that outlives the encode call: + + // one expression (inputs r, s outlive the call): + out := marshal(sequence({integer_unsigned(r), integer_unsigned(s)})) or_return + +DER is canonical by construction: definite minimal-length headers, minimal +INTEGER magnitudes with the sign octet inserted only when required. The +writer emits low-tag-number identifiers only (tag number <= 30), which +covers all of PKIX; high-tag-number form is a future addition. + +See: +- [[ https://www.itu.int/rec/T-REC-X.690 ]] +*/ + +import dt "core:time" + +// Selects how a Value's content octets are produced when encoding. +@(private) +_Form :: enum u8 { + Primitive, // content holds the exact content octets, emitted verbatim. + Constructed, // children holds the sub-values, emitted in order. + Integer_Magnitude, // content holds an unsigned big-endian magnitude; DER INTEGER rules are applied on emit. + Bit_String_Octets, // content holds whole-octet payload; a leading 0x00 unused-bits count is added on emit. + Bit_String_Wrapped, // children's DER becomes the payload, behind the 0x00 unused-bits count. + Time, // _when is formatted to UTCTime/GeneralizedTime per the tag on emit. + Raw, // content is a complete pre-encoded element, emitted verbatim (no added tag/length). +} + +// Value is a node in a to-be-encoded DER tree. Construct it with the +// helpers below rather than by hand; the fields are an implementation +// detail. The byte/child inputs are borrowed (see the package lifetime note). +Value :: struct { + tag: Tag, + form: _Form, + content: []byte, + children: []Value, + _when: dt.Time, // meaningful only when form == .Time: the instant to format on emit +} + +@(rodata, private) +_BOOL_FALSE := []byte{0x00} +@(rodata, private) +_BOOL_TRUE := []byte{0xFF} + +// Builds a primitive value with `tag` and the exact `content` +// octets (emitted verbatim). The caller owns canonical-form correctness. +primitive :: proc "contextless" (tag: Tag, content: []byte) -> Value { + return Value{tag = tag, form = .Primitive, content = content} +} + +// Builds a value whose encoding IS `encoded` verbatim, a complete +// already-DER-encoded element spliced in as-is (no tag/length added). The +// composition primitive for nesting an independently-marshalled structure +// (a signed CertificationRequestInfo, a pre-built TBSCertificate) inside a +// parent without re-encoding it. +raw :: proc "contextless" (encoded: []byte) -> Value { + return Value{form = .Raw, content = encoded} +} + +// Builds a BOOLEAN (DER: 0x00 / 0xFF). +boolean :: proc "contextless" (v: bool) -> Value { + return Value{tag = universal(.Boolean), form = .Primitive, content = v ? _BOOL_TRUE : _BOOL_FALSE} +} + +// Builds an INTEGER from an unsigned big-endian magnitude: leading zero +// octets are dropped (minimal encoding) and a single 0x00 sign octet is +// inserted when the top bit would otherwise read as negative. An empty +// or all-zero magnitude encodes as 0. This is the shape RSA moduli / +// exponents and certificate serials are written in, and the inverse of +// read_unsigned_integer_bytes. +integer_unsigned :: proc "contextless" (magnitude: []byte) -> Value { + return Value{tag = universal(.Integer), form = .Integer_Magnitude, content = magnitude} +} + +// Builds an INTEGER from content octets that are ALREADY a minimal +// two's-complement encoding (e.g. a serial preserved verbatim from +// read_integer_bytes). No normalization is applied. +integer_raw :: proc "contextless" (content: []byte) -> Value { + return Value{tag = universal(.Integer), form = .Primitive, content = content} +} + +// Builds an OCTET STRING wrapping `content`. +octet_string :: proc "contextless" (content: []byte) -> Value { + return Value{tag = universal(.Octet_String), form = .Primitive, content = content} +} + +// Builds an OCTET STRING whose content is the DER encoding of `children`, +// the form X.509 Extension.extnValue uses to carry an extension's value. The +// OCTET STRING stays primitive (0x04); its content is just the children's +// concatenated DER, so this reuses the constructed-content machinery without +// a wrapper octet. +octet_string_wrap :: proc "contextless" (children: []Value) -> Value { + return Value{tag = universal(.Octet_String), form = .Constructed, children = children} +} + +// Builds a SEQUENCE from its sub-values (borrowed). +sequence :: proc "contextless" (children: []Value) -> Value { + return Value{tag = universal(.Sequence, true), form = .Constructed, children = children} +} + +// Builds a SET from its sub-values, emitted in the given order. DER SET OF +// requires components sorted by their encoding (X.690 section 11.6); this +// constructor does NOT sort, so a SET OF with more than one element must be +// given pre-sorted (a single-element RDN, the common PKIX case, is trivially +// ordered). +set :: proc "contextless" (children: []Value) -> Value { + return Value{tag = universal(.Set, true), form = .Constructed, children = children} +} + +// Builds a SET OF from its sub-values, sorted into the DER canonical order +// (X.690 section 11.6: component encodings ascending, shorter padded with +// trailing 0-octets). Unlike the other constructors this one ALLOCATES, it +// must encode each child to compare them, and it sorts `children` in place; +// the returned Value then borrows that reordered slice as usual. Scratch is +// taken from and released to `allocator`. 0/1-element sets need no work. +@(require_results) +set_of :: proc(children: []Value, allocator := context.allocator) -> (value: Value, err: Error) { + n := len(children) + if n <= 1 { + return set(children), .None + } + encs, merr := make([][]byte, n, allocator) + if merr != nil { + return {}, .Allocation_Failed + } + defer { + for e in encs { + delete(e, allocator) + } + delete(encs, allocator) + } + for i in 0 ..< n { + encs[i] = marshal(children[i], allocator) or_return + } + // Insertion sort (n is small for a SET OF) keying children on their encodings. + for i in 1 ..< n { + for j := i; j > 0 && _der_less(encs[j], encs[j - 1]); j -= 1 { + encs[j], encs[j - 1] = encs[j - 1], encs[j] + children[j], children[j - 1] = children[j - 1], children[j] + } + } + return set(children), .None +} + +// Compares two encodings as octet strings with the shorter padded at its +// trailing end with 0-octets (X.690 section 11.6 SET OF ordering). +@(private) +_der_less :: proc "contextless" (a, b: []byte) -> bool { + n := min(len(a), len(b)) + for i in 0 ..< n { + if a[i] != b[i] { + return a[i] < b[i] + } + } + if len(a) < len(b) { + for i in n ..< len(b) { + if b[i] != 0x00 { + return true // a's zero padding is below b's non-zero tail + } + } + return false + } + for i in n ..< len(a) { + if a[i] != 0x00 { + return false // a's non-zero tail is above b's zero padding + } + } + return false +} + +// Builds a NULL (empty content). +null :: proc "contextless" () -> Value { + return Value{tag = universal(.Null), form = .Primitive, content = nil} +} + +// Builds an OBJECT IDENTIFIER from already-encoded content octets (the form +// PKIX OIDs are held and compared in, e.g. the package's known-OID +// constants). The content is emitted verbatim; the caller owns its validity. +object_identifier :: proc "contextless" (content: []byte) -> Value { + return Value{tag = universal(.Object_Identifier), form = .Primitive, content = content} +} + +// Builds a BIT STRING from a whole-octet payload (unused-bits count 0), the +// only form PKIX uses for SubjectPublicKeyInfo keys and signature values. +bit_string_octets :: proc "contextless" (payload: []byte) -> Value { + return Value{tag = universal(.Bit_String), form = .Bit_String_Octets, content = payload} +} + +// Builds a BIT STRING (whole octets) whose payload is the DER encoding of +// `children`, the form SubjectPublicKeyInfo uses to carry an RSAPublicKey +// SEQUENCE inside the subjectPublicKey bit string. +bit_string_wrap :: proc "contextless" (children: []Value) -> Value { + return Value{tag = universal(.Bit_String), form = .Bit_String_Wrapped, children = children} +} + +// Builds a UTCTime ("YYMMDDHHMMSSZ", RFC 5280 DER profile). Appropriate for +// instants in 1950..=2049; the sliding-window century is what the reader +// (read_utc_time) decodes, so use generalized_time outside that range. +utc_time :: proc "contextless" (at: dt.Time) -> Value { + return Value{tag = universal(.UTC_Time), form = .Time, _when = at} +} + +// Builds a GeneralizedTime ("YYYYMMDDHHMMSSZ", RFC 5280 DER profile: Zulu, +// seconds present, no fractional part). The inverse of read_generalized_time. +generalized_time :: proc "contextless" (at: dt.Time) -> Value { + return Value{tag = universal(.Generalized_Time), form = .Time, _when = at} +} + +// 2050-01-01T00:00:00Z: the RFC 5280 boundary between the two time forms. +@(private) +_UNIX_2050 :: i64(2_524_608_000) + +// Builds a UTCTime or GeneralizedTime, auto-selecting the form per the RFC +// 5280 profile: UTCTime for instants in 1950..=2049, GeneralizedTime from +// 2050 on. (X.509 validity dates are written this way.) +time :: proc "contextless" (at: dt.Time) -> Value { + if dt.to_unix_seconds(at) < _UNIX_2050 { + return utc_time(at) + } + return generalized_time(at) +} + +// Builds a primitive [number] IMPLICIT value carrying raw content octets, +// e.g. AuthorityKeyIdentifier's keyIdentifier [0] IMPLICIT OCTET STRING. +context_primitive :: proc "contextless" (number: u32, content: []byte) -> Value { + return Value{tag = context_specific(number, false), form = .Primitive, content = content} +} + +// Builds a constructed [number] EXPLICIT wrapper around the given sub-values, +// e.g. TBSCertificate's version [0] EXPLICIT INTEGER. +context_explicit :: proc "contextless" (number: u32, children: []Value) -> Value { + return Value{tag = context_specific(number, true), form = .Constructed, children = children} +} + +// Returns the exact number of bytes encode/marshal will write +// for `v`, including its identifier and length octets. +encoded_len :: proc(v: Value) -> int { + if v.form == .Raw { + return len(v.content) // already a complete element + } + clen := _content_len(v) + return _tag_len(v.tag) + _length_len(clen) + clen +} + +// Writes the DER encoding of `v` into `dst` and returns the number +// of bytes written. `dst` must be at least encoded_len(v) bytes; if it is +// shorter, nothing is written and Buffer_Too_Small is returned. +encode :: proc(v: Value, dst: []byte) -> (n: int, err: Error) { + need := encoded_len(v) + if len(dst) < need { + return 0, .Buffer_Too_Small + } + _emit(v, dst[:need]) + return need, .None +} + +// Encodes `v` into a freshly allocated slice the caller owns. +marshal :: proc(v: Value, allocator := context.allocator) -> (out: []byte, err: Error) { + n := encoded_len(v) + buf, merr := make([]byte, n, allocator) + if merr != nil { + return nil, .Allocation_Failed + } + _emit(v, buf) + return buf, .None +} + +@(private) +_content_len :: proc(v: Value) -> int { + switch v.form { + case .Primitive: + return len(v.content) + case .Integer_Magnitude: + start, pad := _int_shape(v.content) + if start == len(v.content) { + return 1 // zero encodes as a single 0x00 octet + } + return (len(v.content) - start) + (pad ? 1 : 0) + case .Bit_String_Octets: + return 1 + len(v.content) // leading unused-bits octet (0x00) + case .Bit_String_Wrapped: + total := 1 // leading unused-bits octet (0x00) + for child in v.children { + total += encoded_len(child) + } + return total + case .Time: + return _time_content_len(v.tag) + case .Constructed: + total := 0 + for child in v.children { + total += encoded_len(child) + } + return total + case .Raw: + return len(v.content) // handled in encoded_len; here for exhaustiveness + } + return 0 +} + +// Writes v's complete encoding so that it ENDS at dst[len(dst)], i.e. into +// the tail of dst, and returns the bytes written. `dst` is the exactly-sized +// region this node may occupy (encoded_len(v) == len(dst) at the top call). +// +// Emitting back-to-front means a constructed node's content length falls out +// of where its children landed (no second measure pass): encoded_len does the +// single O(n) sizing pass, this does the single O(n) write pass. DER wants to +// be written this way — it is the tree generalization of the fixed-buffer +// trick in crypto/ecdsa's hand-rolled encoder. +@(private) +_emit :: proc(v: Value, dst: []byte) -> int { + if v.form == .Raw { + n := len(v.content) + copy(dst[len(dst) - n:], v.content) + return n + } + end := len(dst) + switch v.form { + case .Raw: // handled by the early return above + case .Primitive: + end -= len(v.content) + copy(dst[end:], v.content) + case .Integer_Magnitude: + start, pad := _int_shape(v.content) + if start == len(v.content) { + end -= 1 + dst[end] = 0x00 // zero -> single 0x00 octet + } else { + body := v.content[start:] + end -= len(body) + copy(dst[end:], body) + if pad { + end -= 1 + dst[end] = 0x00 // sign octet so the value reads as non-negative + } + } + case .Bit_String_Octets: + end -= len(v.content) + copy(dst[end:], v.content) + end -= 1 + dst[end] = 0x00 // unused-bits count: whole octets + case .Bit_String_Wrapped: + for i := len(v.children) - 1; i >= 0; i -= 1 { + end -= _emit(v.children[i], dst[:end]) + } + end -= 1 + dst[end] = 0x00 // unused-bits count: whole octets + case .Time: + tmp: [15]byte // GeneralizedTime is the longest form + n := _format_time(tmp[:], v._when, v.tag.number == u32(Tag_Number.Generalized_Time)) + end -= n + copy(dst[end:], tmp[:n]) + case .Constructed: + for i := len(v.children) - 1; i >= 0; i -= 1 { + end -= _emit(v.children[i], dst[:end]) + } + } + clen := len(dst) - end // content length, read off the cursor — never recomputed + + tmp: [9]byte // identifier byte + up to 8 length octets covers any int length + lw := _write_length(tmp[:], clen) + end -= lw + copy(dst[end:], tmp[:lw]) + + end -= 1 + dst[end] = _tag_byte(v.tag) + + return len(dst) - end +} + +// UTCTime content is "YYMMDDHHMMSSZ" (13 octets); GeneralizedTime is +// "YYYYMMDDHHMMSSZ" (15). The content length is fixed by the tag, so it is +// known for the measure pass without formatting. +@(private) +_time_content_len :: proc "contextless" (tag: Tag) -> int { + return tag.number == u32(Tag_Number.Generalized_Time) ? 15 : 13 +} + +// Formats `at` into dst as the RFC 5280 DER time profile (Zulu, seconds +// present, no fractional part) and returns the bytes written: 15 for +// GeneralizedTime ("YYYYMMDDHHMMSSZ"), 13 for UTCTime ("YYMMDDHHMMSSZ", with +// the low two year digits). dst must hold the full width. +@(private) +_format_time :: proc "contextless" (dst: []byte, at: dt.Time, generalized: bool) -> int { + // time_to_datetime decomposes the instant into UTC calendar fields; it only + // fails outside time.Time's range, which an in-range time.Time can't reach. + c, _ := dt.time_to_datetime(at) + p := 0 + if generalized { + p += _write_digits(dst[p:], int(c.year), 4) + } else { + p += _write_digits(dst[p:], int(c.year % 100), 2) + } + p += _write_digits(dst[p:], int(c.month), 2) + p += _write_digits(dst[p:], int(c.day), 2) + p += _write_digits(dst[p:], int(c.hour), 2) + p += _write_digits(dst[p:], int(c.minute), 2) + p += _write_digits(dst[p:], int(c.second), 2) + dst[p] = 'Z' + p += 1 + return p +} + +// Writes `value` as exactly `width` zero-padded decimal digits. +@(private) +_write_digits :: proc "contextless" (dst: []byte, value, width: int) -> int { + x := value + for i := width - 1; i >= 0; i -= 1 { + dst[i] = byte('0' + x % 10) + x /= 10 + } + return width +} + +// Reports the index of the first significant magnitude octet +// (== len(mag) when the value is zero) and whether a 0x00 sign octet must +// be prepended so the result reads as non-negative. +@(private) +_int_shape :: proc "contextless" (mag: []byte) -> (start: int, pad: bool) { + start = 0 + for start < len(mag) && mag[start] == 0x00 { + start += 1 + } + if start == len(mag) { + return start, false + } + return start, mag[start] & 0x80 != 0 +} + +// _length_len / _write_length are the inverse of _read_length: definite +// form, minimal (short form below 128, otherwise the fewest octets). +@(private) +_length_len :: proc "contextless" (length: int) -> int { + if length < 0x80 { + return 1 + } + n := 1 + v := length + for v > 0 { + v >>= 8 + n += 1 + } + return n +} + +@(private) +_write_length :: proc "contextless" (dst: []byte, length: int) -> int { + if length < 0x80 { + dst[0] = byte(length) + return 1 + } + nbytes := 0 + v := length + for v > 0 { + v >>= 8 + nbytes += 1 + } + dst[0] = 0x80 | byte(nbytes) + for i in 0 ..< nbytes { + dst[1 + i] = byte(length >> uint(8 * (nbytes - 1 - i))) + } + return 1 + nbytes +} + +// _tag_len / _tag_byte are the inverse of _read_tag, low-tag-number form +// only (number <= 30); PKIX never needs high-tag-number identifiers. +@(private) +_tag_len :: proc "contextless" (tag: Tag) -> int { + return 1 +} + +@(private) +_tag_byte :: proc "contextless" (tag: Tag) -> byte { + assert_contextless(tag.number <= 30, "asn1: high-tag-number form is not supported by the writer") + b := byte(tag.class) << 6 + if tag.constructed { + b |= 0x20 + } + b |= byte(tag.number) & 0x1F + return b +} diff --git a/core/net/addr.odin b/core/net/addr.odin index 888744841..747aacbaf 100644 --- a/core/net/addr.odin +++ b/core/net/addr.odin @@ -425,15 +425,21 @@ parse_hostname_or_endpoint :: proc(endpoint_str: string) -> (target: Host_Or_End // Takes an endpoint string and returns its parts. // Returns ok=false if port is not a number. split_port :: proc(endpoint_str: string) -> (addr_or_host: string, port: int, ok: bool) { - // IP6 [addr_or_host]:port - if i := strings.last_index(endpoint_str, "]:"); i >= 0 { - addr_or_host = endpoint_str[1:i] - port, ok = strconv.parse_int(endpoint_str[i+2:], 10) + // IP6 [addr_or_host]:port — the bracketed form requires a leading + // '[', so only treat a closing "]:" as the port separator when it + // sits at index >= 1 (i.e. an opening '[' is actually present). + // Inputs that contain "]:" without an opening bracket fall through + // to the plain host/port handling below. + if len(endpoint_str) > 0 && endpoint_str[0] == '[' { + if i := strings.last_index(endpoint_str, "]:"); i >= 1 { + addr_or_host = endpoint_str[1:i] + port, ok = strconv.parse_int(endpoint_str[i+2:], 10) - if port > 65535 { - ok = false + if port > 65535 { + ok = false + } + return } - return } if n := strings.count(endpoint_str, ":"); n == 1 { diff --git a/tests/core/crypto/x509/fuzz_x509.odin b/tests/core/crypto/x509/fuzz_x509.odin new file mode 100644 index 000000000..340791869 --- /dev/null +++ b/tests/core/crypto/x509/fuzz_x509.odin @@ -0,0 +1,133 @@ +package test_core_x509 + +// Deterministic mutational fuzzing for the certificate parser, seeded +// (and reproducible) via the test runner's logged random seed. +// +// Invariants on every input: parse never panics, never reads outside +// the input (bounds checks + address sanitizer in CI), never leaks on +// error paths (the runner's tracking allocator), and on success every +// raw view lies within the input buffer. + +import "core:crypto/x509" +import "core:math/rand" +import "core:testing" + +FUZZ_MUTATE_ITERS :: 1500 +FUZZ_RANDOM_ITERS :: 2048 + +// _check_views asserts that a successfully-parsed Certificate only +// references memory inside its input. +@(private="file") +_check_views :: proc(t: ^testing.T, cert: ^x509.Certificate, der: []byte) { + in_bounds :: proc(view: []byte, der: []byte) -> bool { + if len(view) == 0 { + return true + } + base := uintptr(raw_data(der)) + view_start := uintptr(raw_data(view)) + return view_start >= base && view_start + uintptr(len(view)) <= base + uintptr(len(der)) + } + + testing.expect(t, in_bounds(cert.raw, der), "raw out of bounds") + testing.expect(t, in_bounds(cert.raw_tbs, der), "raw_tbs out of bounds") + testing.expect(t, in_bounds(cert.raw_issuer, der), "raw_issuer out of bounds") + testing.expect(t, in_bounds(cert.raw_subject, der), "raw_subject out of bounds") + testing.expect(t, in_bounds(cert.raw_spki, der), "raw_spki out of bounds") + testing.expect(t, in_bounds(cert.serial, der), "serial out of bounds") + testing.expect(t, in_bounds(cert.signature, der), "signature out of bounds") + testing.expect(t, cert.version >= 1 && cert.version <= 3, "version range") + for name in cert.dns_names { + testing.expect(t, in_bounds(transmute([]byte)name, der), "dns name out of bounds") + } + for ip in cert.ip_addresses { + testing.expect(t, in_bounds(ip, der), "ip out of bounds") + testing.expect(t, len(ip) == 4 || len(ip) == 16, "ip width") + } + for ext in cert.extensions { + testing.expect(t, in_bounds(ext.oid, der), "ext oid out of bounds") + testing.expect(t, in_bounds(ext.value, der), "ext value out of bounds") + } +} + +// Every single-bit flip of a real certificate: parse must fail cleanly +// or succeed with intact invariants. +@(test) +test_fuzz_bitflips :: proc(t: ^testing.T) { + buf := make([]byte, len(EC_DER)) + defer delete(buf) + + for i in 0 ..< len(EC_DER) { + for bit in 0 ..< uint(8) { + copy(buf, EC_DER) + buf[i] ~= 1 << bit + cert, err := x509.parse(buf) + if err == .None { + _check_views(t, &cert, buf) + x509.destroy(&cert) + } + } + } +} + +// Random multi-byte mutations across all three fixtures. +@(test) +test_fuzz_mutations :: proc(t: ^testing.T) { + fixtures := [?][]byte{RSA_DER, EC_DER, ED_DER} + + max_len := 0 + for f in fixtures { + max_len = max(max_len, len(f)) + } + buf := make([]byte, max_len) + defer delete(buf) + + for _ in 0 ..< FUZZ_MUTATE_ITERS { + fixture := rand.choice(fixtures[:]) + input := buf[:len(fixture)] + copy(input, fixture) + + // Mutate 1-16 positions, occasionally truncating instead — + // length-field damage is where TLV parsers historically break. + if rand.int_max(8) == 0 { + input = input[:rand.int_max(len(input) + 1)] + } + for _ in 0 ..< 1 + rand.int_max(16) { + if len(input) == 0 { + break + } + input[rand.int_max(len(input))] = byte(rand.uint32()) + } + + cert, err := x509.parse(input) + if err == .None { + _check_views(t, &cert, input) + x509.destroy(&cert) + } + } +} + +// Pure noise: parse must reject (or, vanishingly unlikely, accept with +// invariants intact) without panicking. +@(test) +test_fuzz_random_garbage :: proc(t: ^testing.T) { + buf: [128]byte + + for _ in 0 ..< FUZZ_RANDOM_ITERS { + n := rand.int_max(len(buf) + 1) + input := buf[:n] + for i in 0 ..< n { + input[i] = byte(rand.uint32()) + } + // Bias towards the outer shape so mutation reaches the TBS walk. + if n > 4 && rand.int_max(2) == 0 { + input[0] = 0x30 + input[1] = byte(rand.int_max(n)) + } + + cert, err := x509.parse(input) + if err == .None { + _check_views(t, &cert, input) + x509.destroy(&cert) + } + } +} diff --git a/tests/core/crypto/x509/oom_x509.odin b/tests/core/crypto/x509/oom_x509.odin new file mode 100644 index 000000000..259c3ba17 --- /dev/null +++ b/tests/core/crypto/x509/oom_x509.odin @@ -0,0 +1,138 @@ +package test_core_x509 + +// Out-of-memory robustness: parse must surface Allocation_Failed +// cleanly and leak nothing when any one of its table allocations +// fails. A failing allocator wraps a tracking allocator; sweeping the +// fail point across every allocation site (and verifying zero leaked +// blocks after each) exercises every OOM path and its unwind. + +import "base:runtime" +import "core:mem" +import "core:crypto/x509" +import "core:testing" +import "core:time" + +// Failing_Allocator passes through to a backing allocator but returns +// Out_Of_Memory on the (fail_at)-th allocation request, counting only +// the allocating modes. +@(private="file") +Failing_Allocator :: struct { + backing: runtime.Allocator, + count: int, + fail_at: int, // -1 = never fail +} + +@(private="file") +failing_allocator_proc :: proc( + data: rawptr, mode: runtime.Allocator_Mode, + size, alignment: int, old_memory: rawptr, old_size: int, + loc := #caller_location, +) -> ([]byte, runtime.Allocator_Error) { + fa := (^Failing_Allocator)(data) + #partial switch mode { + case .Alloc, .Alloc_Non_Zeroed, .Resize, .Resize_Non_Zeroed: + if fa.count == fa.fail_at { + fa.count += 1 + return nil, .Out_Of_Memory + } + fa.count += 1 + } + return fa.backing.procedure(fa.backing.data, mode, size, alignment, old_memory, old_size, loc) +} + +@(private="file") +failing_allocator :: proc(fa: ^Failing_Allocator) -> runtime.Allocator { + return {procedure = failing_allocator_proc, data = fa} +} + +@(test) +test_oom_parse_sweep :: proc(t: ^testing.T) { + // A cert that drives all three table allocations (extensions, DNS + // SANs, IP SANs): the EC fixture has SANs + KU + EKU + BC. + der := EC_DER + + // First, learn how many allocations a clean parse makes. + total: int + { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1} + cert, err := x509.parse(der, failing_allocator(&fa)) + testing.expect_value(t, err, x509.Error.None) + x509.destroy(&cert, failing_allocator(&fa)) + total = fa.count + testing.expect(t, total >= 3, "expected at least 3 allocation sites") + testing.expect_value(t, len(track.allocation_map), 0) + } + + // Now fail at each allocation in turn; every one must yield a clean + // Allocation_Failed and leak nothing. + for k in 0 ..< total { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k} + cert, err := x509.parse(der, failing_allocator(&fa)) + + if err == .None { + // This allocation site wasn't on the parse path for this + // input; clean up and move on. + x509.destroy(&cert, failing_allocator(&fa)) + } else { + testing.expectf(t, err == .Allocation_Failed, + "fail_at=%d: expected Allocation_Failed, got %v", k, err) + } + + // Either way, parse must own no memory afterwards (its failure + // path calls destroy internally; the success path we cleaned). + testing.expectf(t, len(track.allocation_map) == 0, + "fail_at=%d: %d block(s) leaked", k, len(track.allocation_map)) + } +} + +// verify_chain allocates exactly one block (the chain buffer). Failing +// it must yield Allocation_Failed and leak nothing. Certificates are +// parsed with the real allocator; only verify_chain gets the failing one. +@(test) +test_oom_verify_chain :: proc(t: ^testing.T) { + leaf, _ := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf) + inter, _ := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter) + root, _ := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root) + opts := x509.Verify_Options{ + roots = {&root}, + intermediates = {&inter}, + current_time = time.unix(CHAIN_NOW, 0), + } + + total: int + { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1} + chain, err := x509.verify_chain(&leaf, opts, failing_allocator(&fa)) + testing.expect_value(t, err, x509.Error.None) + delete(chain, failing_allocator(&fa)) + total = fa.count + testing.expect(t, total >= 1, "verify_chain should allocate at least once") + testing.expect_value(t, len(track.allocation_map), 0) + } + + for k in 0 ..< total { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k} + chain, err := x509.verify_chain(&leaf, opts, failing_allocator(&fa)) + if err == .None { + delete(chain, failing_allocator(&fa)) + } else { + testing.expectf(t, err == .Allocation_Failed, + "fail_at=%d: expected Allocation_Failed, got %v", k, err) + } + testing.expectf(t, len(track.allocation_map) == 0, + "fail_at=%d: %d block(s) leaked", k, len(track.allocation_map)) + } +} diff --git a/tests/core/crypto/x509/test_core_x509.odin b/tests/core/crypto/x509/test_core_x509.odin new file mode 100644 index 000000000..413e34ae9 --- /dev/null +++ b/tests/core/crypto/x509/test_core_x509.odin @@ -0,0 +1,661 @@ +package test_core_x509 + +// Fixtures generated with openssl 3.x (see testdata/): +// +// openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ +// -subj "/C=US/O=Odin Test/CN=localhost" \ +// -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ +// -addext "basicConstraints=critical,CA:TRUE" +// +// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ +// -sha256 -days 3650 -nodes -subj "/CN=example.com" \ +// -addext "subjectAltName=DNS:example.com,DNS:*.example.com,IP:2001:db8::1" \ +// -addext "extendedKeyUsage=serverAuth" \ +// -addext "keyUsage=critical,digitalSignature" \ +// -addext "basicConstraints=critical,CA:FALSE" +// +// openssl req -x509 -newkey ed25519 -days 3650 -nodes -subj "/CN=ed.test" +// +// Field expectations below were cross-checked against +// `openssl x509 -noout -text`. + +import "core:crypto/x509" +import "core:testing" +import "core:time" + +RSA_DER := #load("testdata/rsa.der") +EC_DER := #load("testdata/ec.der") +ED_DER := #load("testdata/ed.der") + +// Chains for verify_chain (generated by testdata/gen_chains.sh): +// P-256: chain_ec_root -> chain_ec_inter (pathlen:0) -> chain_ec_leaf +// (CN=leaf.example.com, EKU serverAuth); chain_ec_other_root is +// an unrelated anchor for the wrong-root case. +// Ed25519: chain_ed_root -> chain_ed_leaf (CN=ed-leaf.example.com). +EC_CHAIN_ROOT := #load("testdata/chain_ec_root.der") +EC_CHAIN_INTER := #load("testdata/chain_ec_inter.der") +EC_CHAIN_LEAF := #load("testdata/chain_ec_leaf.der") +EC_OTHER_ROOT := #load("testdata/chain_ec_other_root.der") +ED_CHAIN_ROOT := #load("testdata/chain_ed_root.der") +ED_CHAIN_LEAF := #load("testdata/chain_ed_leaf.der") +// Expired root (validity 2010-2015) that signed a still-valid leaf; +// RFC 5280 section 6.1 does not validate the trust anchor itself. +EC_EXPIRED_ROOT := #load("testdata/chain_ec_expired_root.der") +EC_EXP_LEAF := #load("testdata/chain_ec_expleaf.der") + +// Negative path-validation fixtures: each chain trips one verify_chain +// rejection. neg_root signs the *_inter certs; pl_a -> pl_b -> pl_leaf +// chains deeper to violate pathlen:0 on pl_a. +NEG_ROOT := #load("testdata/neg_root.der") +NEG_NOKCS_INTER := #load("testdata/neg_nokcs_inter.der") // CA, no keyCertSign +NEG_NOKCS_LEAF := #load("testdata/neg_nokcs_leaf.der") +NEG_NOTCA_INTER := #load("testdata/neg_notca_inter.der") // CA:FALSE +NEG_NOTCA_LEAF := #load("testdata/neg_notca_leaf.der") +NEG_EXPINTER := #load("testdata/neg_expinter_inter.der") // expired CA +NEG_EXPINTER_LEAF := #load("testdata/neg_expinter_leaf.der") +NEG_PL_A := #load("testdata/neg_pl_a.der") // pathlen:0 +NEG_PL_B := #load("testdata/neg_pl_b.der") +NEG_PL_LEAF := #load("testdata/neg_pl_leaf.der") +NEG_EKU_INTER := #load("testdata/neg_eku_inter.der") // emailProtection CA +NEG_EKU_LEAF := #load("testdata/neg_eku_leaf.der") // serverAuth leaf +NEG_NC_INTER := #load("testdata/neg_nc_inter.der") // non-critical nameConstraints +NEG_NC_LEAF := #load("testdata/neg_nc_leaf.der") // name within permitted subtree +// EKU alternative path: two intermediates sharing a subject DN and key, +// one emailProtection-only, one serverAuth; the leaf chains through either. +EKU_ALT_BAD := #load("testdata/eku_alt_bad.der") // same key, emailProtection only +EKU_ALT_GOOD := #load("testdata/eku_alt_good.der") // same key, serverAuth +EKU_ALT_LEAF := #load("testdata/eku_alt_leaf.der") // serverAuth leaf +// Cross-signed cycle: A and B mutually cross-certify; trusting neither +// must terminate (no hang) and report Unknown_Authority. +CYC_A := #load("testdata/cyc_a.der") +CYC_B := #load("testdata/cyc_b.der") +CYC_A_BY_B := #load("testdata/cyc_a_by_b.der") +CYC_B_BY_A := #load("testdata/cyc_b_by_a.der") +CYC_LEAF := #load("testdata/cyc_leaf.der") + +// Reference times relative to the chain fixtures (issued 2026-06-13, +// leaves valid ~10 years). 2027 is inside every window; 2020 precedes +// the leaf; 2040 is past the leaf (which expires first). +CHAIN_NOW :: i64(1798761600) // 2027-01-01Z +CHAIN_BEFORE :: i64(1577836800) // 2020-01-01Z +CHAIN_AFTER :: i64(2208988800) // 2040-01-01Z + +// A real CA root (GoDaddy G2) carrying serial number 0 — a +// non-conformant but in-the-wild case surfaced by differential testing +// against Go's crypto/x509 over the system CA store. +SERIAL_ZERO_DER := #load("testdata/serial_zero.der") + +// A cert with two subjectAltName extensions, from the x509-limbo +// corpus — RFC 5280 section 4.2 forbids duplicate extension OIDs. +DUP_EXTENSION_DER := #load("testdata/dup_extension.der") + +@(test) +test_duplicate_extension :: proc(t: ^testing.T) { + cert, err := x509.parse(DUP_EXTENSION_DER) + if err == .None { + x509.destroy(&cert) + } + testing.expect_value(t, err, x509.Error.Duplicate_Extension) +} + +@(test) +test_parse_rsa :: proc(t: ^testing.T) { + cert, err := x509.parse(RSA_DER) + defer x509.destroy(&cert) + testing.expect_value(t, err, x509.Error.None) + + testing.expect_value(t, cert.version, 3) + testing.expect_value(t, cert.signature_algorithm, x509.Signature_Algorithm.RSA_SHA256) + testing.expect_value(t, cert.public_key_algorithm, x509.Public_Key_Algorithm.RSA) + + // serial=074476066FA3819E72A324BC52CEF3920F0E7156 (20 octets). + testing.expect_value(t, len(cert.serial), 20) + testing.expect_value(t, cert.serial[0], u8(0x07)) + testing.expect_value(t, cert.serial[19], u8(0x56)) + + // 2048-bit modulus, e = 65537. + testing.expect_value(t, len(cert.rsa_n), 256) + testing.expect_value(t, len(cert.rsa_e), 3) + testing.expect_value(t, cert.rsa_e[0], u8(0x01)) + testing.expect_value(t, cert.rsa_e[2], u8(0x01)) + + // notBefore=Jun 12 18:53:09 2026 GMT. + testing.expect_value(t, time.to_unix_seconds(cert.not_before), i64(1781290389)) + + testing.expect_value(t, cert.basic_constraints_valid, true) + testing.expect_value(t, cert.is_ca, true) + testing.expect_value(t, cert.max_path_len, -1) + + testing.expect_value(t, len(cert.dns_names), 1) + testing.expect_value(t, cert.dns_names[0], "localhost") + testing.expect_value(t, len(cert.ip_addresses), 1) + testing.expect_value(t, len(cert.ip_addresses[0]), 4) + testing.expect_value(t, cert.ip_addresses[0][0], u8(127)) + + // Self-signed: issuer bytes == subject bytes. + testing.expect(t, len(cert.raw_issuer) > 0) + testing.expect_value(t, string(cert.raw_issuer), string(cert.raw_subject)) + + // openssl always adds SKI; self-signed adds matching AKI. + testing.expect(t, len(cert.subject_key_id) > 0, "SKI expected") + testing.expect(t, !cert.unhandled_critical, "all criticals understood") + + // The raw views must cover real ranges. + testing.expect_value(t, len(cert.raw), len(RSA_DER)) + testing.expect(t, len(cert.raw_tbs) > 0) + testing.expect(t, len(cert.raw_spki) > 0) + testing.expect_value(t, len(cert.signature), 256) +} + +@(test) +test_parse_ec :: proc(t: ^testing.T) { + cert, err := x509.parse(EC_DER) + defer x509.destroy(&cert) + testing.expect_value(t, err, x509.Error.None) + + testing.expect_value(t, cert.signature_algorithm, x509.Signature_Algorithm.ECDSA_SHA256) + testing.expect_value(t, cert.public_key_algorithm, x509.Public_Key_Algorithm.ECDSA_P256) + testing.expect_value(t, len(cert.ec_point), 65) + testing.expect_value(t, cert.ec_point[0], u8(0x04)) + + testing.expect_value(t, time.to_unix_seconds(cert.not_after), i64(2096650389)) // notAfter Jun 9 18:53:09 2036 GMT + + testing.expect_value(t, cert.basic_constraints_valid, true) + testing.expect_value(t, cert.is_ca, false) + + testing.expect_value(t, cert.has_key_usage, true) + testing.expect(t, .Digital_Signature in cert.key_usage) + testing.expect(t, .Key_Cert_Sign not_in cert.key_usage) + + testing.expect_value(t, cert.has_ext_key_usage, true) + testing.expect(t, .Server_Auth in cert.ext_key_usage) + testing.expect(t, .Client_Auth not_in cert.ext_key_usage) + testing.expect_value(t, cert.eku_has_unknown, false) + + testing.expect_value(t, len(cert.dns_names), 2) + testing.expect_value(t, cert.dns_names[0], "example.com") + testing.expect_value(t, cert.dns_names[1], "*.example.com") + testing.expect_value(t, len(cert.ip_addresses), 1) + testing.expect_value(t, len(cert.ip_addresses[0]), 16) + testing.expect_value(t, cert.ip_addresses[0][0], u8(0x20)) + testing.expect_value(t, cert.ip_addresses[0][1], u8(0x01)) +} + +@(test) +test_parse_ed25519 :: proc(t: ^testing.T) { + cert, err := x509.parse(ED_DER) + defer x509.destroy(&cert) + testing.expect_value(t, err, x509.Error.None) + + testing.expect_value(t, cert.signature_algorithm, x509.Signature_Algorithm.Ed25519) + testing.expect_value(t, cert.public_key_algorithm, x509.Public_Key_Algorithm.Ed25519) + testing.expect_value(t, len(cert.ec_point), 32) + testing.expect_value(t, len(cert.signature), 64) +} + +// Serial 0 decodes to the single octet {0x00}, not empty (the +// openssl-aligned convention; see Certificate.serial). +@(test) +test_serial_zero :: proc(t: ^testing.T) { + cert, err := x509.parse(SERIAL_ZERO_DER) + defer x509.destroy(&cert) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, len(cert.serial), 1) + testing.expect_value(t, cert.serial[0], u8(0x00)) + testing.expect_value(t, cert.is_ca, true) +} + +@(test) +test_valid_at :: proc(t: ^testing.T) { + cert, err := x509.parse(RSA_DER) + defer x509.destroy(&cert) + testing.expect_value(t, err, x509.Error.None) + + INSIDE := time.unix(1893456000, 0) // 2030-01-01Z + BEFORE := time.unix(1577836800, 0) // 2020-01-01Z + AFTER := time.unix(2208988800, 0) // 2040-01-01Z + testing.expect_value(t, x509.valid_at(&cert, INSIDE), true) + testing.expect_value(t, x509.valid_at(&cert, BEFORE), false) + testing.expect_value(t, x509.valid_at(&cert, AFTER), false) + // Boundaries are inclusive. + testing.expect_value(t, x509.valid_at(&cert, cert.not_before), true) + testing.expect_value(t, x509.valid_at(&cert, cert.not_after), true) +} + +@(test) +test_verify_hostname :: proc(t: ^testing.T) { + ec, err := x509.parse(EC_DER) + defer x509.destroy(&ec) + testing.expect_value(t, err, x509.Error.None) + + // Exact and case-insensitive. + testing.expect_value(t, x509.verify_hostname(&ec, "example.com"), x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&ec, "EXAMPLE.COM"), x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&ec, "example.com."), x509.Error.None) + + // Wildcard: exactly one left-most label. + testing.expect_value(t, x509.verify_hostname(&ec, "api.example.com"), x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&ec, "API.Example.Com"), x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&ec, "a.b.example.com"), x509.Error.Hostname_Mismatch) + testing.expect_value(t, x509.verify_hostname(&ec, "example.org"), x509.Error.Hostname_Mismatch) + testing.expect_value(t, x509.verify_hostname(&ec, "com"), x509.Error.Hostname_Mismatch) + + // IPv6 SAN 2001:db8::1. + testing.expect_value(t, x509.verify_hostname(&ec, "2001:db8::1"), x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&ec, "2001:db8::2"), x509.Error.Hostname_Mismatch) + + // IPv4 against the RSA cert (127.0.0.1 SAN). + rsa, rerr := x509.parse(RSA_DER) + defer x509.destroy(&rsa) + testing.expect_value(t, rerr, x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&rsa, "127.0.0.1"), x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&rsa, "127.0.0.2"), x509.Error.Hostname_Mismatch) + testing.expect_value(t, x509.verify_hostname(&rsa, "localhost"), x509.Error.None) + + // Ed cert has no SANs at all. + ed, eerr := x509.parse(ED_DER) + defer x509.destroy(&ed) + testing.expect_value(t, eerr, x509.Error.None) + testing.expect_value(t, x509.verify_hostname(&ed, "ed.test"), x509.Error.No_SAN) +} + +// verify_hostname must accept any bytes in the host string and return a +// result (never fault), whatever odd punctuation the input contains. In +// normal use the host comes from the application, not the certificate; +// this just pins down robust behaviour on unusual inputs. +@(test) +test_verify_hostname_hostile_input :: proc(t: ^testing.T) { + ec, err := x509.parse(EC_DER) + defer x509.destroy(&ec) + testing.expect_value(t, err, x509.Error.None) + + hostile := []string{ + "]:80", + "]:", + "]", + "[", + "[]", + "::::", + ":", + "]:a]:b", + "\x00\x01\x02", + "foo]:bar", + "[not-closed", + "*.*.*", + } + for h in hostile { + // Any result is acceptable; not panicking is the point. + _ = x509.verify_hostname(&ec, h) + } +} + +// Every truncation of a real certificate must fail cleanly. +@(test) +test_truncation_sweep :: proc(t: ^testing.T) { + for n in 0 ..< len(EC_DER) { + cert, err := x509.parse(EC_DER[:n]) + if err == .None { + x509.destroy(&cert) + } + testing.expect(t, err != .None, "truncated certificate must be rejected") + } + // Trailing garbage is rejected too. + { + grown := make([]byte, len(EC_DER) + 1) + defer delete(grown) + copy(grown, EC_DER) + _, err := x509.parse(grown) + testing.expect(t, err != .None, "trailing bytes must be rejected") + } +} + +// ---- signature & chain verification ---- + +@(test) +test_verify_signature_ec :: proc(t: ^testing.T) { + leaf, e1 := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf) + inter, e2 := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter) + root, e3 := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root) + testing.expect_value(t, e1, x509.Error.None) + testing.expect_value(t, e2, x509.Error.None) + testing.expect_value(t, e3, x509.Error.None) + + // Correct issuers verify; cross-pairings do not. + testing.expect_value(t, x509.verify_signature(&leaf, &inter), x509.Error.None) + testing.expect_value(t, x509.verify_signature(&inter, &root), x509.Error.None) + testing.expect_value(t, x509.verify_signature(&leaf, &root), x509.Error.Signature_Invalid) +} + +@(test) +test_verify_signature_ed :: proc(t: ^testing.T) { + leaf, e1 := x509.parse(ED_CHAIN_LEAF); defer x509.destroy(&leaf) + root, e2 := x509.parse(ED_CHAIN_ROOT); defer x509.destroy(&root) + testing.expect_value(t, e1, x509.Error.None) + testing.expect_value(t, e2, x509.Error.None) + testing.expect_value(t, x509.verify_signature(&leaf, &root), x509.Error.None) +} + +// RSA verification is not implemented yet; it must report +// Unsupported_Algorithm rather than a spurious pass or fail. rsa.der is +// self-signed, so it is its own issuer. +@(test) +test_verify_signature_rsa_unsupported :: proc(t: ^testing.T) { + rsa, err := x509.parse(RSA_DER); defer x509.destroy(&rsa) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, x509.verify_signature(&rsa, &rsa), x509.Error.Unsupported_Algorithm) +} + +@(test) +test_verify_chain_ec :: proc(t: ^testing.T) { + leaf, e1 := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf) + inter, e2 := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter) + root, e3 := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root) + testing.expect_value(t, e1, x509.Error.None) + testing.expect_value(t, e2, x509.Error.None) + testing.expect_value(t, e3, x509.Error.None) + + opts := x509.Verify_Options{ + roots = {&root}, + intermediates = {&inter}, + current_time = time.unix(CHAIN_NOW, 0), + dns_name = "leaf.example.com", + required_eku = .Server_Auth, + } + chain, err := x509.verify_chain(&leaf, opts) + defer delete(chain) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, len(chain), 3) + if len(chain) == 3 { + testing.expect(t, chain[0] == &leaf, "chain[0] is the leaf") + testing.expect(t, chain[1] == &inter, "chain[1] is the intermediate") + testing.expect(t, chain[2] == &root, "chain[2] is the anchor") + } +} + +@(test) +test_verify_chain_ed :: proc(t: ^testing.T) { + leaf, e1 := x509.parse(ED_CHAIN_LEAF); defer x509.destroy(&leaf) + root, e2 := x509.parse(ED_CHAIN_ROOT); defer x509.destroy(&root) + testing.expect_value(t, e1, x509.Error.None) + testing.expect_value(t, e2, x509.Error.None) + + opts := x509.Verify_Options{ + roots = {&root}, + current_time = time.unix(CHAIN_NOW, 0), + dns_name = "ed-leaf.example.com", + } + chain, err := x509.verify_chain(&leaf, opts) + defer delete(chain) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, len(chain), 2) +} + +@(test) +test_verify_chain_negatives :: proc(t: ^testing.T) { + leaf, _ := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf) + inter, _ := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter) + root, _ := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root) + other, _ := x509.parse(EC_OTHER_ROOT); defer x509.destroy(&other) + + roots := []^x509.Certificate{&root} + inters := []^x509.Certificate{&inter} + + // Missing the intermediate: cannot bridge leaf -> root. + { + opts := x509.Verify_Options{roots = roots, current_time = time.unix(CHAIN_NOW, 0)} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) + } + // Untrusted anchor. + { + opts := x509.Verify_Options{roots = {&other}, intermediates = inters, current_time = time.unix(CHAIN_NOW, 0)} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) + } + // Expired (now is past the leaf's notAfter). + { + opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_AFTER, 0)} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Expired) + } + // Not yet valid (now precedes the leaf's notBefore). + { + opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_BEFORE, 0)} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Not_Yet_Valid) + } + // Hostname mismatch on an otherwise-valid chain. + { + opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_NOW, 0), dns_name = "wrong.example.com"} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Hostname_Mismatch) + } + // Required EKU the leaf does not carry (it has serverAuth only). + { + opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_NOW, 0), required_eku = .Client_Auth} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Incompatible_Usage) + } +} + +// The trust anchor is validated like any issuer (matching Go/OpenSSL): +// an expired root is rejected, so a chain whose only anchor is expired +// fails to build. (Resilience to expired roots comes from offering an +// alternate valid anchor, not from skipping the check.) +@(test) +test_verify_chain_expired_anchor_rejected :: proc(t: ^testing.T) { + leaf, e1 := x509.parse(EC_EXP_LEAF); defer x509.destroy(&leaf) + root, e2 := x509.parse(EC_EXPIRED_ROOT); defer x509.destroy(&root) + testing.expect_value(t, e1, x509.Error.None) + testing.expect_value(t, e2, x509.Error.None) + + // Sanity: the anchor really is expired at CHAIN_NOW. + testing.expect_value(t, x509.valid_at(&root, time.unix(CHAIN_NOW, 0)), false) + + opts := x509.Verify_Options{roots = {&root}, current_time = time.unix(CHAIN_NOW, 0)} + chain, err := x509.verify_chain(&leaf, opts) + defer delete(chain) + testing.expect_value(t, err, x509.Error.Unknown_Authority) +} + +// EKU nesting (surfaced by x509-limbo): an intermediate constrained to +// emailProtection must not be usable to issue a serverAuth leaf when the +// caller requires serverAuth. Without an EKU requirement the chain still +// builds (EKU is unconstrained). +@(test) +test_verify_chain_eku_nesting :: proc(t: ^testing.T) { + root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root) + inter, _ := x509.parse(NEG_EKU_INTER); defer x509.destroy(&inter) + leaf, _ := x509.parse(NEG_EKU_LEAF); defer x509.destroy(&leaf) + now := time.unix(CHAIN_NOW, 0) + + // No EKU requirement: the email-only intermediate does not block it. + { + opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now} + c, err := x509.verify_chain(&leaf, opts) + defer delete(c) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, len(c), 3) + } + // Requiring serverAuth: the intermediate's emailProtection-only EKU + // forbids it, so the path is rejected. + { + opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now, required_eku = .Server_Auth} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Incompatible_Usage) + } +} + +// EKU nesting must be enforced DURING path-building The two intermediates +// share a subject DN and key (so the leaf signature-verifies through +// either), differing only in EKU, and we offer them in BOTH orders, the +// result must be the same successful chain regardless of ordering. +@(test) +test_verify_chain_eku_alt_path :: proc(t: ^testing.T) { + root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root) + bad, _ := x509.parse(EKU_ALT_BAD); defer x509.destroy(&bad) + good, _ := x509.parse(EKU_ALT_GOOD); defer x509.destroy(&good) + leaf, _ := x509.parse(EKU_ALT_LEAF); defer x509.destroy(&leaf) + now := time.unix(CHAIN_NOW, 0) + + // emailProtection-only intermediate offered first: + { + opts := x509.Verify_Options{ + roots = {&root}, + intermediates = {&bad, &good}, + current_time = now, + required_eku = .Server_Auth, + } + c, err := x509.verify_chain(&leaf, opts) + defer delete(c) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, len(c), 3) + if len(c) == 3 { + testing.expect(t, c[1] == &good, "must select the serverAuth-permitting intermediate") + } + } + // serverAuth intermediate offered first: same successful result. + { + opts := x509.Verify_Options{ + roots = {&root}, + intermediates = {&good, &bad}, + current_time = now, + required_eku = .Server_Auth, + } + c, err := x509.verify_chain(&leaf, opts) + defer delete(c) + testing.expect_value(t, err, x509.Error.None) + testing.expect_value(t, len(c), 3) + if len(c) == 3 { + testing.expect(t, c[1] == &good, "must select the serverAuth-permitting intermediate") + } + } +} + +// Name constraints are not decoded, so verify_chain fails CLOSED: a +// chain through a name-constrained CA is rejected even when the leaf's +// name is within the permitted subtree (here a NON-critical NC, so it is +// the explicit NC refusal, not the unhandled-critical path). +@(test) +test_verify_chain_name_constraints_fail_closed :: proc(t: ^testing.T) { + root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root) + inter, _ := x509.parse(NEG_NC_INTER); defer x509.destroy(&inter) + leaf, _ := x509.parse(NEG_NC_LEAF); defer x509.destroy(&leaf) + // The NC is present but not critical, so it must not have tripped the + // unhandled-critical flag — the refusal is the explicit NC check. + testing.expect(t, !inter.unhandled_critical, "NC is non-critical") + + opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = time.unix(CHAIN_NOW, 0)} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) +} + +// A mutually cross-signed CA cycle (neither anchor trusted) must not +// hang the path search: the cycle/depth/signature-budget bounds make it +// terminate with Unknown_Authority. If this test ever hangs rather than +// failing, the search lost its bound. (CVE-2024-0567 shape.) +@(test) +test_verify_chain_cross_signed_cycle :: proc(t: ^testing.T) { + a, _ := x509.parse(CYC_A); defer x509.destroy(&a) + b, _ := x509.parse(CYC_B); defer x509.destroy(&b) + a_by_b, _ := x509.parse(CYC_A_BY_B); defer x509.destroy(&a_by_b) + b_by_a, _ := x509.parse(CYC_B_BY_A); defer x509.destroy(&b_by_a) + leaf, _ := x509.parse(CYC_LEAF); defer x509.destroy(&leaf) + + // Every cross-cert and self-signed CA is offered as an intermediate; + // no trust anchor is supplied, so the loop can never terminate at a + // root. + opts := x509.Verify_Options{ + intermediates = {&a, &b, &a_by_b, &b_by_a}, + current_time = time.unix(CHAIN_NOW, 0), + } + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) +} + +// An RSA-signed leaf surfaces the RSA gap directly. +@(test) +test_verify_chain_rsa_unsupported :: proc(t: ^testing.T) { + rsa, err := x509.parse(RSA_DER); defer x509.destroy(&rsa) + testing.expect_value(t, err, x509.Error.None) + opts := x509.Verify_Options{roots = {&rsa}, current_time = time.unix(CHAIN_NOW, 0)} + c, verr := x509.verify_chain(&rsa, opts); delete(c) + testing.expect_value(t, verr, x509.Error.Unsupported_Algorithm) +} + +// Each negative chain must be rejected for the right structural reason +// (all collapse to Unknown_Authority: no usable path to the anchor). +// Together these exercise the RFC 5280 6.1.4 issuer checks — keyCertSign, +// cA, intermediate validity, pathLenConstraint — which the happy-path +// tests never trip. +@(test) +test_verify_chain_bad_issuers :: proc(t: ^testing.T) { + root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root) + now := time.unix(CHAIN_NOW, 0) + + // Intermediate is a CA but its KeyUsage omits keyCertSign. + { + inter, _ := x509.parse(NEG_NOKCS_INTER); defer x509.destroy(&inter) + leaf, _ := x509.parse(NEG_NOKCS_LEAF); defer x509.destroy(&leaf) + opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) + } + // Intermediate carries basicConstraints cA=FALSE. + { + inter, _ := x509.parse(NEG_NOTCA_INTER); defer x509.destroy(&inter) + leaf, _ := x509.parse(NEG_NOTCA_LEAF); defer x509.destroy(&leaf) + opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) + } + // Intermediate is expired (validity 2010-2015): unlike the trust + // anchor, an intermediate's validity IS enforced. + { + inter, _ := x509.parse(NEG_EXPINTER); defer x509.destroy(&inter) + leaf, _ := x509.parse(NEG_EXPINTER_LEAF); defer x509.destroy(&leaf) + testing.expect_value(t, x509.valid_at(&inter, now), false) + opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) + } + // pathlen:0 on pl_a forbids pl_b (a CA) beneath it. + { + a, _ := x509.parse(NEG_PL_A); defer x509.destroy(&a) + b, _ := x509.parse(NEG_PL_B); defer x509.destroy(&b) + leaf, _ := x509.parse(NEG_PL_LEAF); defer x509.destroy(&leaf) + opts := x509.Verify_Options{roots = {&root}, intermediates = {&a, &b}, current_time = now} + c, err := x509.verify_chain(&leaf, opts); delete(c) + testing.expect_value(t, err, x509.Error.Unknown_Authority) + } +} + +// Single-byte tamper sweep: no one-byte mutation of a signed certificate +// may BOTH parse cleanly AND verify against its true issuer. The whole +// TBSCertificate is signature-covered and the framing/algorithm/signature +// bytes are structurally checked, so every flip must be caught by parse +// or by the signature. A survivor would mean a trusted region the +// signature does not actually protect. +@(test) +test_verify_signature_tamper_sweep :: proc(t: ^testing.T) { + check :: proc(t: ^testing.T, der, issuer_der: []byte) { + issuer, ierr := x509.parse(issuer_der) + defer x509.destroy(&issuer) + testing.expect_value(t, ierr, x509.Error.None) + + buf := make([]byte, len(der)) + defer delete(buf) + for pos in 0 ..< len(der) { + copy(buf, der) + buf[pos] ~= 0x01 + cert, perr := x509.parse(buf) + if perr == .None { + accepted := x509.verify_signature(&cert, &issuer) == .None + x509.destroy(&cert) + testing.expectf(t, !accepted, "tampered byte %d parsed and verified", pos) + } + } + } + check(t, EC_CHAIN_LEAF, EC_CHAIN_INTER) + check(t, ED_CHAIN_LEAF, ED_CHAIN_ROOT) +} diff --git a/tests/core/crypto/x509/testdata/chain_ec_expired_root.der b/tests/core/crypto/x509/testdata/chain_ec_expired_root.der new file mode 100644 index 0000000000000000000000000000000000000000..da27e40ae71360cad255dd2abb27f7a18ec9f5fd GIT binary patch literal 433 zcmXqLVq9y`#2CDQnTe5!NyLnCiGa%cdrp7WHMZ_NBmGEi(b=yCTx=X#Z64=rS(up& zR1B326xf(US(tew{ZlgY6hcyqOB7t46o<;0Fn?Ff%c-A%`2Y2ZMntlY&S8X^tTFNtdo{@DopXe#UKK_v4Uw#$Rj~7ab1~ sczq<6Ns(bWn<1CHT%CjNDuEBzmTqr7mTh`FAW*z|YESTSftIi501IT1!vFvP literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/chain_ec_expleaf.der b/tests/core/crypto/x509/testdata/chain_ec_expleaf.der new file mode 100644 index 0000000000000000000000000000000000000000..4e11b111992c62cfd4d87f78549d60c81cdd3c09 GIT binary patch literal 447 zcmXqLV%%-e#F(^znTe5!NyKE2z_v}@9=eu+-x;I4ZMOVybTBaBV&l+i^EhYA!pvl# zVyI-Gz{VWP!ptM-pOTrU5RzJ4qTuSR;960TS(KWh5R{)^Vjw5ZYh-3%W@v0^X=Gw# z93{?c4B{F z(ca6SoVNJzBo%qpEY6ixS5wyh&=zr=pIyhH>X>>lnyinfI=hmGIQ z;YqWL3uK=DcW#An%fg+DYYeIkc-WXjWrZ0T|FbX|Fc|QGc>Ew93o{dYgMk!?FUTTn zAlQWAFjZE=&sQD{GeD+)>~iFDG?-btAGckq2>?JUj4S{E literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/chain_ec_inter.der b/tests/core/crypto/x509/testdata/chain_ec_inter.der new file mode 100644 index 0000000000000000000000000000000000000000..0a9419d5ca5b0157db6512fd6e35cb9211a03cda GIT binary patch literal 428 zcmXqLVq9U+#OSwxnTe5!NhG0T-K*f~5-RF;I{M4LC*QYUbY0MZi;Y98&EuRc3p0~} zjG>f)1RHZG3p0$FrEmo}%&$(9&^sjGSoO|-I(!nWd#s3v*6(d7cj_cV4Z<1%~FyksHtK(u0 z%sMFV<};(*cyXFRih&RtbEvE^BjbM-4g)qu#{W!=3`6PVs19FE;3He@S0~J50X}9kuVTz5DAo%PCJmyYgRX0Oq|{wq6^LaEoS je~US0H)>@cX!47#o-8zT!An-b$+H7ZTDHvWoM#3AwNr)K literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/chain_ec_leaf.der b/tests/core/crypto/x509/testdata/chain_ec_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..273758afbc719d86927093e85574d9bb26913a11 GIT binary patch literal 473 zcmXqLV!Ue5#8|z6nTe5!NyOsB#-lv>m0K@gEo|6#@V0i7*9ThzE;bIWHji_*EX+&> zDuzl13T(`wEX+KT{wbMx3L&Y*B?_+23Z8i-sYSV|DVd2SsRnZ5yhdgQW`@RwmPRH< z#!=$D#vrZ%lxrYuC}|)LF-0IJH8D*uwIVUMASYEXIX~AR(mJBh+SQMs{W= z29~%dx|7ScB*@iB8f{S0(Lcbk^8fYN*<#&`#TaWByxl72P;J6}_FGu;2_Y?x&-)hV zPKoIi7o9Av_(^y68GbcV|k4&7@^iPGqN)~F|b^Cvhn8SBTV<*!?|k8cBgGf@Y>0K zGWM?9kCetl7LA_E)>@kS5p{q1L<-bZwz(HQlWW(?`O~eaQJ<&bn*MLrmBq;hi3YN4 z%%QS;EMhDorgqaNJ)7QbQ_=SIY1hWjezOluHZYI}Nh`BR7>G4sSHKTaAk4`4pM}+c znUV3o0UtS`5*Q-nxgQ#SYM_1)!Od8yDpjj*!cUul#+Bd+Y%*GD3iHQ+vB{L&Cvl9bLymO1`r|Dtg-)Am8EHCyZ!12zk!+~j5?@D}_ z(zc0y_wxzc*K>ohJRp+E>S~^ceQe>~kT1tymLIoYv`(&AHuT%#WP?NlSvKZSSw0pq z7LmZo5>oO!8wy`Kr+@oqKJ}5GzN4gpJV;uZMZ!R=0lNZzkOE;w#{Vp=2F#3%{|)#+ z0{kEW7G@?!Hso+(_Fyn@Wm06=nUkLDz4)pU|E%>vaogrh{dF(Nr$WlNw?On6hxx0O pSD6&tqbGd(EwRV{seQ(T$0^$bo0N}AE;A2GJJ{*2p6z=t6abnlgEIgC literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/chain_ed_leaf.der b/tests/core/crypto/x509/testdata/chain_ed_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..2faca526ce17ce7819f81f57287fb6d4ab2885e7 GIT binary patch literal 368 zcmXqLV$3mUVw79J%*4pVBvMyg`4*z)mJ@c3h6nVH1BKtdxav0rzOV@ zdogUvwyJ7K?`6KWxWFLKfQOAaR92Xg@jnZb0fT`Yh$qM*ZXnu()pS{qJRge~i^$6C zOaJn!q@*THj;b9S1Wr*{ls zZgXVFUw2#P_9;`0qp$qxfLSbMfo}x1igYF(QJi~|%eMR3`qZw4oW2pq XlV@u92(-VB&hJ;~e>g9o=pP3F-i3De literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/chain_ed_root.der b/tests/core/crypto/x509/testdata/chain_ed_root.der new file mode 100644 index 0000000000000000000000000000000000000000..bf7f83e2c60ef2761ea3711b42540e6ef91aad64 GIT binary patch literal 353 zcmXqLVvIFtV&q@I%*4pVBw{`3FxRxqY_8MVyG(=6>6!i(XWwAJ%EqjnT3{e!C}kkQ z#vIDR%p>TZl9{Ivl3HA%;F_Wkl%HQ>AScdiWM*JyXl!U{WMX6-CC+PNW?*Ju0p$|V ztp&A&S&<>l&bBNt+;h6Yp5z~Ou4wdC& z5n~al?fdx9lk3T$ps(|GdM#a+Dg5+~fq^_oTA4+{K&%0~0)CJJVMfOPEUX61jEw&c z_&@^uAORL;CPp@xa~v7|Hga`aJ-NN3_iIKhYwcwTFIQ9l**DGGdldh^usYZBVf{PH jNW(44^4Fd3pI$E%c_Vr9tFSlWrpkW1xEANR^s)m0RqAvH literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/cyc_a.der b/tests/core/crypto/x509/testdata/cyc_a.der new file mode 100644 index 0000000000000000000000000000000000000000..7bebd52df8be30e52af94ef109fe293a733606e0 GIT binary patch literal 406 zcmXqLVw_~q#AvyInTe5!NknUj<*IxC^Q@%*vj2Iy=fw6!f7?q9xY#(f+C0wLvM@6l zh#QI;2(vMVvM}@T_@`v%DL7Xq=cFn)8pw(B8krfG85$c}8krawM~U;Am>HNESU|bB zHAWf;v$2ECXJUj}#LURf?8Lw#zrCpMdw=W6U;P^BiQD|&Zye2-7GL*m zeZyL3O_v|i*Cw($8@o;QjeNREyZ5()z&u{ZE=&r_J%0k0ut%#n>f}A&>$yI|TqeMJSM`(qr#%mC-t#lpkV%1c kQ@RTOTVK8J3GFq9+eDM@EPlI&Y2WF@jX9=Vtt##h0M#;w#sB~S literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/cyc_a_by_b.der b/tests/core/crypto/x509/testdata/cyc_a_by_b.der new file mode 100644 index 0000000000000000000000000000000000000000..c9a18188f049a52cc1baccb3245252a9f1f1c06b GIT binary patch literal 408 zcmXqLVw_^o#AvyInTe5!NhE;3TK*b)i?6#Chgwe4g&LdwKq*DMH8Up~LLSVBp52$Z%m^g-Dx1O>@z!8+Tm~ zOBr?UNwAx!`=jW#RhRdF;o0|@6d4w&T~y`zaz1U!quPqohunB~ePtK_upCk>Kl_HYi)BI&Wfi<$Uh>(1i;Y98&EuRc3p0~} zxS^kk900jcCw$brp33d62X+i-dt#19k=cAO*sVjQ?3!4VW1j{~Pdu1o%M$ zEX+)dY{;R*?7?8*%B0Bfb^dC(d9ja$y(HOWH)Qm&+{yhr*>>{XrgQ4&`^`%Z)iWvN mb3B`Jku_VMJNr{i#KL;HQ#&mAen_oH%XuW^uAXq5(e}bEvE^BjbM- zRs&{6#{UL*-rn~hkPb4Yea+2tE;FR$b+Pn zStJa^8bqeE_HEl%#>ut*hmTk9|2sCXo1V@0z3)2c|G7NXs0nebOf%5B*LJL7)-=Qm@`ntS-| G4Rrv}bAnR< literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/cyc_leaf.der b/tests/core/crypto/x509/testdata/cyc_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..f0fc442e8bce84a08965f5901db2e34e83ec199b GIT binary patch literal 438 zcmXqLV%%iV#2C4NnTe5!NhEQjOpH+5m>JobofueV=qnaSzDfN$`Pb$- zCC(m8EgF&*-1?qYk-w?xNL7~1>_yk^l}kEiYT0S@E)v#_xT7S_rWl*N(dwes!L3V+ z6Q%uMVJ1~MSNAd9Gha1$1%$%170Sj1RF z-sXN^@UbCqhVYY{tDXHGolVa@>t`Sjl2&GsFc51Hna9*ZLnmUcLYCC}q39 z3`Pz;W_Jby7bXP@E02}!H`aA%DD12d*<)hv953#tEpx8EIcufq0#k;)ObT`<47)iq giXLmJ1qR>udmVT3mAXVDLt$R!GtMU=>l=>(02Ql>wg3PC literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/dup_extension.der b/tests/core/crypto/x509/testdata/dup_extension.der new file mode 100644 index 0000000000000000000000000000000000000000..77919775a09eb02518df6b3c44203fba4d088ac1 GIT binary patch literal 462 zcmXqLVmxKg#8|L^nTe5!Nkk%S%}GI)R&CbrGg>{)XuNp+QQ?#U7aNCGo5wj@7G@>` zDMJYZF*fE<7G@s)3R441-JHzaqnjFR9tvNW?aFf}j+ z@eK^448#mY41^%Mxl=0=a|?1(^^)^*4I&MM+1SB$Ffl?cV`gM$c4A=ppMUy-Yq7g? zRCCFfjz?3}pPD?KuD(z6o_g@LSL;+}b|iesaBghT{a>0Lkoxf6zbB`y9w~61ZpzK{ zE1$b$^|JoOjZ+O8CmG1HF^9_Xv52vVbOfyyS7=O=sC`l9cI}s#!u@+wryIzFq?K7D z48$5lME>@ARq@ncXq)hHdf&V-g|&PfGYz;w3iw%An3>oc41_@(RTdslz-Y5Eva+%> zGlBy}R*;3?fVT<7YoNfwCXN)S%pME|u1pGH?(fPrcwN$wNqsb_=WS=pY_8b#iCaXM yX5E?AaoxP4lu40cr9t97bI*#0Ynq>|*vJ#Mbavg_(ytwIVUMASYEXIX~AxPMp`s%)rdh$k4*n*uXMMoY&aQz|6oB%EhTK z(mk*uyx{KXz iUT#>YJmKXBd$yw-lGmnvSup==;8pLn1}*L`3>N@9VVUj# literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/ed.der b/tests/core/crypto/x509/testdata/ed.der new file mode 100644 index 0000000000000000000000000000000000000000..9e55cee0bcebbc0f9511c95f06e983fb442d119e GIT binary patch literal 316 zcmXqLVze-5e7%5~iIIs(#Cz^WA>P|@Cf&)eb{%-99`@7}y^_@85(7DLUL!LDGeaXo3sYkQ%P4VPV>1IY14}3us~#<=fy{~w)6Q)A z=2Yfl)5!O=;8@-1eU57u-%ZPza;*5w>6|@HT`r4*4FU~h*_cCR`B=nQL@JXDZ+V$| zwY|Pzct+r?wATyMI|T;vAZcY52?MbP>7re}8lr3Oz2H`4I1?_jW5xjkE;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P4RfvTahfg;2NDHIbt^GZ^S6kNSS6&!O)3?dDL+1SC>GBHB!XJ%w)c4A;* zNm!LUll_lc&AIy-=L1C>pX%@5ymW=y2E)@nxr~_;&dmO%T4>V#cB|c^c;5h#6^e_i zemm}ezP9g>Aq{uK~da{6G-~y9~l_zXAuDtqgW{}t$snC<37+21o J_%&vUDFANLj@SSI literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/eku_alt_good.der b/tests/core/crypto/x509/testdata/eku_alt_good.der new file mode 100644 index 0000000000000000000000000000000000000000..c326aab51140c797dd1fcb629a4a726cf758ad2f GIT binary patch literal 452 zcmXqLVmx5b#F)B(nTe5!NrYMc>7re}8lr3Oz2H`4I1?_jW5z)PE;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P4RfvTahfg;2NDHIbt^GZ^S6kNSS6&!O)3?dDL+1SC>GBHB!XJ%w)c4A;* zNm!LUll_lc&AIy-=L1C>pX%@5ymW=y2E)@nxr~_;&dmO%T4>V#cB|c^c;5h#6^e_i zemm}ezP9g>!K!cPlvg-@R#>xsi(mqOo|LIjg^kHWbgAjSm^%E` zRYPS1MKi`} zkHNwzQ;uM{4jgt&`*qB3Qg&7(DvoIMj81R93{2(3+GZTA* zfiQ@#%EANkxi%XkD=RxQqk$|)P>@B;K%@zqlflw_EMhDo8PU?08w3)*NhdFqf5^(W zG`yQ{mw`M;w=#=_fmnmcIo(Bb3{w3sd@ADKIPJi1>FLfZEJ0R*J&-~Q7re}8lr3Oz2H`4I1?_jW5zxME;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P4Rfx4lpfilDd859#d^GZ^S6kKx?Gjsg&aw-iX4TRa)!4@+yLS4Yj$jk! z5MPyr2jqWkHbz!fc4ig>S&$$fix`WD)>HlTHMLyWgridR%Vef z5Ni;ztqk8}W)@^~yw&X1y<4uYB!dq=0GS2$5(_gEvnPXr8*^H%Lbr~x literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_eku_leaf.der b/tests/core/crypto/x509/testdata/neg_eku_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..0882de117b001015fa5791ea81eb2ea1d122a253 GIT binary patch literal 473 zcmXqLV!Ue5#8|U{nTe5!NhGFRm~(y4-F_pX!`X6a)qY1ad-4pp*f_M>JkHs&Ff$pb z8>$*8voVLVF!RXxr)1_SgrpXiD7ZQ+c;=O)7Ad&qCT8aN=jBuy$cghBnHiWF8XFm# zni!f!iSrtRxCT(Jfs~R;OpH*wnHkxc zofufS*Pcs^SoHA5kM-IoGVi~fm-+IVQsl-3Eo2N>ioy-#$Thf_p!UOWSHX9==D?2lzffPtkkVV))unEJxvLHD=7BLnPCpV>{Y0-))br(eDM5h1$ zu{DuT#y}n<^z@R7-lV-r^xPibS9>>q&Y8zI1$0`Hm=ub1)I_eRI|&{A8GNlp?%Ijf UZ<3h2ertIt{Jv$e+=(X#06puJF#rGn literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_expinter_inter.der b/tests/core/crypto/x509/testdata/neg_expinter_inter.der new file mode 100644 index 0000000000000000000000000000000000000000..3d1ffae0e5db154309933a2bcdda789fee0d5d7e GIT binary patch literal 430 zcmXqLVq9g=#2B!EnTe5!NrYMc>7re}8lr3Oz2H`4I1?_jW5yl>E;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyoLq_h6aWPU=StFYiNqZ zHBdEFHc*6^AcbOrXI@Eak%DVQL1s~Eib14-FdIAAS|&!Q{mhK)%uWm}r{$UZ3fH%> z&fC;r@TbFAu5Y8qm!&Z*Q+Sxv{;CUKu5vukwpUH3XJy)e* z=iT`|Kb&taPBut1;AdkFl@(@W{LjK_z|6?_-+&J!zz-5&VP;}vGmr)G`B=nQL>iZe zcE_eKe{*2R|Kl&kAFea{v+bOLJV;uZMZ!R=LBzH)e3O}3kj?Q{vs?FWxxSJNKKKAR z(3m|K3|yHMEEk2CrE5Cw@U8Yg(YxbK{Os>O&u;y?W_P8PdBybBL#0fL3~#O{I~*5L gU3H`A)8cC(_cl&cIXpQdR_fclfJZwm%yOpy0A6~MrvLx| literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_expinter_leaf.der b/tests/core/crypto/x509/testdata/neg_expinter_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..92f80513a18f2b5da08014779c742c84fa8852f5 GIT binary patch literal 442 zcmXqLV%%oX#2CAPnTe5!NyKwL@0FC+rr&SQx@bhb_xb+r$gCv>Tx=X#Z64=rS(up& zR1K946xos!`_j z+o={Y%hn!VHF=uv;Td9=_iKMy+OfFMAm4z8jX6|Sn33^63zGqZfjo#O$Rc4N)`Z

XUIg^6j k6wX?k+Vk(%|L5R#n#5zg&aRLljnAp9e~JKW-OD%c0N;_B-~a#s literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_nc_inter.der b/tests/core/crypto/x509/testdata/neg_nc_inter.der new file mode 100644 index 0000000000000000000000000000000000000000..d480c35b41ab2e7a827e8ba303c16e54494c373f GIT binary patch literal 455 zcmXqLVmxfn#F)8&nTe5!NrYMc>7re}8lr3Oz2H`4I1?_jW5#|1E;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P2*ZWBE7N>Ymy{G1IU4TRa)!8S56LakC$cgyvaWbnZU$f3#X!C>IZq{whZesA-oZGuxD&*U=?irZt(ztHLW zN0V6JGjGm1#TSRIWKsxk64!P8;bP9A@!z)bfM9D|8hgQu_TORrb8eY-t^4~I0Lplg A<^TWy literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_nc_leaf.der b/tests/core/crypto/x509/testdata/neg_nc_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..eab541f616fdb5a7c4417d8d49e513132fe688c9 GIT binary patch literal 464 zcmXqLVmxEe#8|w5nTe5!NyOK6(uJBh>6xO_8^whx>&~B%Z;v2zWM*V%c4A=JYZbGr z_shLyeS+$Vc5J_Mtk~}?m>hCAL?-s_p!UOWK zHX9==D?2lzfh0&!kVVKqpb6c%vLGow7BLo)>E841erhkicIZ=w$Ayi`hAXn3JTs67 zNh`BR7>G59ytUuEVQuP1sY$geC)AGWR&eNk76(}a_ACoC6SF&mfeVv@+jaf>zv5pP zyINK~yJK7W#v`dITa#7h Nmw$0?w$%PQ695M9l1Tsn literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_nokcs_inter.der b/tests/core/crypto/x509/testdata/neg_nokcs_inter.der new file mode 100644 index 0000000000000000000000000000000000000000..7d68bd56143d78f20a80c1e69da86aad7536ad74 GIT binary patch literal 437 zcmXqLV%%uZ#2B`KnTe5!NrYMc>7re}8lr3Oz2H`4I1?_jW5zB6E;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P4RfwrNhfjYzl1r!rJ^GZ^S6#VkNQ!AZQi%Nns)AI}>4TRa)!Im>ILS4bk z$juwKWQCkE%Yr_A9U2^xKL*&Fk9>LwEj<*=^Uv$`R}9 zrz2?~H0QZ9(}J3bB`(W%{hR1KVR5oSq5(e}bEvE^BjbM-Rs&{6#{UL2h|k9&#v=0RO=E@V<;d0Z6>`<-(F sYd8C-7xtoGV$;3lpH4HYAJBO6dxiC{_~gpV9~rz_8ZIdV08nC*x&QzG literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_nokcs_leaf.der b/tests/core/crypto/x509/testdata/neg_nokcs_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..48a795c325dc092375e7d1ab2e45919cbf4688b4 GIT binary patch literal 444 zcmXqLV%%ZS#2CAPnTe5!NyP8Lwa6&`(1QVyYV*q{ow)VhA#sfX7aNCGo5wj@7G@>` zZ9`21bvEWu7G@p=|CG!;g^<+Z5(QUh1<$;a)FK7HeDBms=hUK-;LP+q137VCBQpat zLt`UjQxikeC~;n65Z3_8HIOlsGLV26CYYC>om{M!T9KGrkdvyHoS$nDX&}tT4z`$y z5$XbFMs{W=29~C_tIsD^{<>jycH=yaH)h^n58PPhRc^XG?)Mzi56hU+8?>~;S5J$W zbV+|=XU&rK-BuYpDDiTk|6%`mO#eDd0@KL7oGUo?;hNh`BR7>G59e0tMZA$mD- z_5AoZ&mL;H1)lYjJnH17re}8lr3Oz2H`4I1?_jW5#X+E;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P4RfwG~Zfjq?pEWTlGOV3_KY-UO gEmbS?_vG^jmL{}rmiZE+d+z9-Eoa5c4@DdY0PFdPRsaA1 literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_notca_leaf.der b/tests/core/crypto/x509/testdata/neg_notca_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..42a399b675084f4cc97f13586a2c553c43a8199f GIT binary patch literal 434 zcmXqLVq9m?#2C7OnTe5!Nrdy*qQ~xKKW{((BQL6UVU0lA_tzT@xY#(f+C0wLvM@6l zC>tso$g?qrvM}>V_@`v%DTJgJmngV8D|qIWq!uao<(D`+8pw(B8krfG85$cIo0=G! zMv3zpgSZAzu7Ql9lz{}q5W&3slH^3a)QZI1f}B*nA#Mi@K)un@!Bl={_wr%-1_Z?Cw}ew=zRVg`^k0B_IsyH zlbsXOfA_;>%bn`=hb)ZF$aXBwGsrREVPg)J6=r1o&%$KDU?2nH39^V92sdGIiY!Q$ zk420{7re}8lr3Oz2H`4I1?_jW5!+sE;bIWHji_*EX+&> z3Wjn9GHlGDEX+Kj{wbMx3L&Y*B?_+23Vx~S3PJh#B?fZhyhdgQW`@Q_#-=8QrcvU& zCT0d^1{P4Rfuf0``8!r5krI+4S_`A-%mdO1g>iEX53mf4HA(ahNQ_%q!zC=kIz!h3XH>D?O@D z+&i-LD{mWHtNGf+X$C0Tvcimv|5-Q;*cciAGcht4@PWkmL1HY-OpI(`7s~Rn zh_Q&cT^C#wFOc!~mln5J#8uXm1$8nj4CFz|m02VV#2Q3wE5kRLnFZM#Z#BDh@0RN; z$>4(zki(4Gox#9`NukXA&Cl&A9+s!mlc!%uS)2cyds|}OyFFf9>mD`kf6IEFNx|mJ j>)ti@W?44){5M|C@w=RlxoUz=VDDy^O` zMMHT5SvKZS7G@qX|CG!;g^<+Z5(QUh1<$;a)FOod9|Hq9ab6=c12aQoBV$t&L(?d6 zUK29|GXo1Km#`5|29XBBZ0uk=nHZs#Gc&R?J29|iiDc~k&Xt;$T{vN4l{x=c$p?2# zv;!M`Z!mK2Q+B#@^2s)x+G>-y&j*;A z9l03s)Ic7jT$x3}K&(N;?YiKic!7++zqGi;BCfKgEU1%NfgEDYo(u+VOo|NC&9zo^ zGz9QpVHRR6I4_*5a$UomPkUvj(5?MxMh0{Em=qZf`r8>f-@Iy{_gmrstMsJH-(z2j RMjySbK6~rmn>9~easXjWgYp0X literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_pl_leaf.der b/tests/core/crypto/x509/testdata/neg_pl_leaf.der new file mode 100644 index 0000000000000000000000000000000000000000..ab8f67dcb2cb9831ee5e5a0ec3822fb552b2290d GIT binary patch literal 427 zcmXqLVq9*}#OS+#nTe5!NyIa1QM&Cf#e}|sb?=^~e-vDnmfdH-#m1r4=5fxJg_+4f z(NNw%mW?@-g_%dpKP59yAtbf9M8VZr!85NUwMZer$H_oWoY%UAmg|hU0>>?kKkWabkh*zksg1>|S8|I0rZ|t> literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/neg_root.der b/tests/core/crypto/x509/testdata/neg_root.der new file mode 100644 index 0000000000000000000000000000000000000000..a44f3bd820c17d8ceb1c0404fe2720144d372520 GIT binary patch literal 425 zcmXqLVq9v_#OS?%nTe5!Nkpdj@CvW}N%N0CG*H)g|Mf<~rw2X;Tx=X#Z64=rS(up& z6b$7IWZ0NPS(tf5{ZlgY6hcyqOB7t475q}u6@v2fOAO@1d5z2r%nXf1Q`G>~Ou z4wdC&5n~attqk8}W)@^~yw&X1y<4uYB!dq=FpvjHE3-%#h&5nWzz@A2+G|C?MKn*}oz1wBIe>0uR|O$Jj-Gt`|`?{pA7xb*GBD literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/rsa.der b/tests/core/crypto/x509/testdata/rsa.der new file mode 100644 index 0000000000000000000000000000000000000000..ddec18f65ed1857171043f3624f84df39ddd993b GIT binary patch literal 875 zcmXqLVoo<`V)9?W%*4pVB*N}e#+JXhabD44l|4b{K2PH3D-1K>W#iOp^Jx3d%gD&h z%3xq>$Zf#M#vIDRCd?EXY$#+P0OD}*aQdfY<|%}v7MH-pn0YvJ@{<#DGV+T{4CKUl zjm!+p42=veOpOgJqr`cQ%?!*8ETLR-jcsC7LUte{D+6;ABR_*d6C)Q>6C)$TVb@=i z%NXB2UgouCZ%g=`vX)S3n`3#M`sb_8SQi~oux0*aHLs1yGNI6Y%igR>0l5oKY}tM6 zLgF0vew}rl9miOWL%GY3-aNgz=Y&GcyByX~j-GjT4DS5f=NwPtlqzjn<=Jtz+?9p% zXe_ff>%`Y5CEi6&hhJb`r}Y z8CZV1-TC&x1-oyWY6bl1E18`*zfJsb;6u!kNAJw6^P7t@VsfTUU3w$)^T)^?&9bM; zbFO5rI#u4ld|=^HEsbqL;{Es1(|v4Sc=5mVyX%ttV6LBpOl|Dy)Vp7tm<;wOGMwwx z7oELkXDHj+=>i*G|K_uK*m*%eUFSs5&zrj?v{eqyn01Ehbny|hp73S!KDM4Vc_!W7 zUHSfd*KVd|nVLJpYyPe@lZ$k^v3gB@V9T#fc8XG%`{9eDg# J`c~`B{s1s@QUCw| literal 0 HcmV?d00001 diff --git a/tests/core/crypto/x509/testdata/serial_zero.der b/tests/core/crypto/x509/testdata/serial_zero.der new file mode 100644 index 0000000000000000000000000000000000000000..3cdad66612f4e8cc4818c314745d0fd7e5429ef7 GIT binary patch literal 969 zcmXqLVm@ln#I$w+GZP~d6C;BGFB_*;n@8JsUPeZ4RtAH{WoRXN6YA9tO0TSdA7Ie>dNlZzp)Jx9K z)lu-wOV%?qG|&ghF$-(B=PQ6E6@v2fOB9?_i%K%nGLsWaQWYFaOEU6{GD|8IbQRo< z4CKUl4J-{T4GayyAWEFq*xb;_*wDz>)Y8;4YLK|HiBSnT_!wCkn41{+84Q{jxtN+5 z85#B$CUJcH6>0Ka`lQ6-?+u<2FO`~wmmO7bX0&ek_^j1)zWS?gEz*7qw%jh5aomV; zBdZ;|-@7lr{PiOZl%B7a49kAh!2aj6SZRZ0VD;0VrrS#VR`xatKiOu#lDYZUqJ3SW zS@rk04{wtcVLkQg@CueG)^0UMyq_7;luq7#V@9wbC>VBvESpe zdmUSIqUQGo?cgPok}?BTSresP8hdnBI!{x+zs9O@7N6!(nUybN<+jb6XTW87?qf&8 zZ|~SMMsHe^oK;Pj!qgcjZm}-Vvd`Q2VonER?fff0ZwKcl%-yf^JYw~Zc(*_2jz}}x zaK835jLCG{{J@=wnUR5Uv6F#=0Y4jasH`v}<9`-b17=3X|KLO=%MTJ@VP;}vGmr%- z;A0VE5wV)p$}W(uvF-i0y{wLT3J0g$;=hL+%G}^kW@MOoJGS!b&*_or%!wcCtl11p zbr&l1`KPvT`Mzwjc#DP8E!OHEOr<;iF5P&2-M8!agii+x)OERC zoc?ROp0?oX2^-WKe!iF=_0oL)>L>SeHn)n;wY{n0UpdkAT0G0^hUV3?m~^)ae7P~< zI7_j^+alEkE9bwNw>m>;X85swAuG-$dtLsriJBjr{lsYTTZ yD}?Q6eeG2a{zo$Z>cn@fiTid%Y?Cu5kMFnT*;{6|q@Jqkc=X6+U(EMcGG75Um}y7= literal 0 HcmV?d00001 diff --git a/tests/core/encoding/asn1/fuzz_asn1.odin b/tests/core/encoding/asn1/fuzz_asn1.odin new file mode 100644 index 000000000..e7dbcf24e --- /dev/null +++ b/tests/core/encoding/asn1/fuzz_asn1.odin @@ -0,0 +1,222 @@ +package test_core_asn1 + +// Deterministic structure-aware fuzzing for the DER reader. The test +// runner seeds context.random_generator and logs the seed, so any +// failure reproduces with -define:ODIN_TEST_RANDOM_SEED=n. +// +// The load-bearing invariant: DER is canonical, so for any element the +// strict reader ACCEPTS, re-encoding the header from (tag, len) must +// reproduce the input bytes exactly. Any acceptance of a non-minimal +// encoding fails the oracle without needing a crash. + +import "core:bytes" +import "core:encoding/asn1" +import "core:math/rand" +import "core:testing" + +FUZZ_RANDOM_ITERS :: 4096 +FUZZ_MUTATE_ITERS :: 2048 +FUZZ_MAX_INPUT :: 96 +FUZZ_WALK_DEPTH :: 8 + +// _encode_header re-encodes a DER identifier + length the canonical +// way; used as the acceptance oracle. +@(private="file") +_encode_header :: proc(tag: asn1.Tag, length: int, out: ^[dynamic]byte) { + b := byte(u8(tag.class) << 6) + if tag.constructed { + b |= 0x20 + } + if tag.number < 0x1F { + append(out, b | byte(tag.number)) + } else { + append(out, b | 0x1F) + // Base-128, big-endian, minimal. + tmp: [5]byte + n := 0 + v := tag.number + for { + tmp[n] = byte(v & 0x7F) + n += 1 + v >>= 7 + if v == 0 { + break + } + } + for i := n - 1; i >= 0; i -= 1 { + c := tmp[i] + if i > 0 { + c |= 0x80 + } + append(out, c) + } + } + + if length < 0x80 { + append(out, byte(length)) + } else { + tmp: [4]byte + n := 0 + v := length + for v > 0 { + tmp[n] = byte(v & 0xFF) + n += 1 + v >>= 8 + } + append(out, 0x80 | byte(n)) + for i := n - 1; i >= 0; i -= 1 { + append(out, tmp[i]) + } + } +} + +// _walk recursively consumes every element in the reader, applying the +// canonical re-encode oracle to each accepted element. +@(private="file") +_walk :: proc(t: ^testing.T, r: ^asn1.Cursor, depth: int, scratch: ^[dynamic]byte) { + for !asn1.is_empty(r) { + start := r.pos + tag, content, err := asn1.read_any(r) + if err != .None { + return + } + element := r.data[start:r.pos] + + // Oracle: canonical re-encode must reproduce the element. + clear(scratch) + _encode_header(tag, len(content), scratch) + append(scratch, ..content) + if !bytes.equal(scratch[:], element) { + testing.expectf(t, false, "accepted non-canonical element: % 02x", element) + return + } + + if tag.constructed && depth < FUZZ_WALK_DEPTH { + sub := asn1.Cursor{data = content} + _walk(t, &sub, depth + 1, scratch) + } + } +} + +@(test) +test_fuzz_read_any_random :: proc(t: ^testing.T) { + buf: [FUZZ_MAX_INPUT]byte + scratch: [dynamic]byte + defer delete(scratch) + + for _ in 0 ..< FUZZ_RANDOM_ITERS { + n := rand.int_max(FUZZ_MAX_INPUT + 1) + input := buf[:n] + for i in 0 ..< n { + input[i] = byte(rand.uint32()) + } + // Bias half the inputs towards plausible structure: a known + // universal tag and a length that fits. + if n >= 2 && rand.int_max(2) == 0 { + tags := [?]byte{0x02, 0x03, 0x04, 0x05, 0x06, 0x17, 0x18, 0x30, 0x31, 0xA0} + input[0] = rand.choice(tags[:]) + input[1] = byte(rand.int_max(n)) + } + + r: asn1.Cursor + asn1.cursor_init(&r, input) + _walk(t, &r, 0, &scratch) + } +} + +@(test) +test_fuzz_typed_readers_random :: proc(t: ^testing.T) { + buf: [FUZZ_MAX_INPUT]byte + + for _ in 0 ..< FUZZ_RANDOM_ITERS { + n := rand.int_max(FUZZ_MAX_INPUT + 1) + input := buf[:n] + for i in 0 ..< n { + input[i] = byte(rand.uint32()) + } + + // Every typed reader must fail cleanly or uphold its contract; + // none may panic. Fresh reader per call: a failed read may + // leave the cursor mid-element by design. + { + r: asn1.Cursor + asn1.cursor_init(&r, input) + if v, err := asn1.read_i64(&r); err == .None { + _ = v + } + } + { + r: asn1.Cursor + asn1.cursor_init(&r, input) + if mag, err := asn1.read_unsigned_integer_bytes(&r); err == .None { + // Magnitude is minimal: no leading zero unless the + // value IS zero. + if len(mag) > 1 { + testing.expect(t, mag[0] != 0x00, "non-minimal magnitude") + } + } + } + { + r: asn1.Cursor + asn1.cursor_init(&r, input) + if bits, unused, err := asn1.read_bit_string(&r); err == .None { + testing.expect(t, unused <= 7) + if unused > 0 { + testing.expect(t, len(bits) > 0) + mask := byte(1 << uint(unused)) - 1 + testing.expect(t, bits[len(bits) - 1] & mask == 0, "padding bits set") + } + } + } + { + r: asn1.Cursor + asn1.cursor_init(&r, input) + if raw, err := asn1.read_oid(&r); err == .None { + // Layer contract: structural acceptance by read_oid + // means the decoders yield either arcs or Arc_Overflow + // (X.660 arcs are unbounded) — never a structural error. + // This invariant caught a real bug on this fuzzer's + // first run. + arcs, aerr := asn1.oid_components(raw) + str, serr := asn1.oid_to_string(raw) + testing.expect(t, aerr == .None || aerr == .Arc_Overflow, "components: structural error after acceptance") + testing.expect_value(t, serr, aerr) + if aerr == .None { + testing.expect(t, len(arcs) >= 2) + testing.expect(t, len(str) >= 3) + } + delete(arcs) + delete(str) + } + } + { + r: asn1.Cursor + asn1.cursor_init(&r, input) + if _, err := asn1.read_time(&r); err == .None { + // Acceptance implies the RFC 5280 profile already + // validated ranges; nothing further to check here. + continue + } + } + } +} + +@(test) +test_fuzz_mutated_spki :: proc(t: ^testing.T) { + spki := make_spki() + defer delete(spki) + + buf := make([]byte, len(spki)) + defer delete(buf) + + for _ in 0 ..< FUZZ_MUTATE_ITERS { + copy(buf, spki[:]) + // 1-8 random byte mutations; structure mostly survives, so the + // parser gets dragged deep before hitting the damage. + for _ in 0 ..< 1 + rand.int_max(8) { + buf[rand.int_max(len(buf))] = byte(rand.uint32()) + } + // Must never panic; success or clean error are both fine. + _, _ = parse_spki(buf) + } +} diff --git a/tests/core/encoding/asn1/fuzz_writer.odin b/tests/core/encoding/asn1/fuzz_writer.odin new file mode 100644 index 000000000..db720fb18 --- /dev/null +++ b/tests/core/encoding/asn1/fuzz_writer.odin @@ -0,0 +1,158 @@ +package test_core_asn1 + +// Deterministic fuzzing for the DER WRITER. The runner seeds +// context.random_generator and logs the seed, so any failure reproduces +// with -define:ODIN_TEST_RANDOM_SEED=n. Run under -sanitize:address to turn +// the value-tree's borrow discipline into a checked property: a stray +// borrow of an out-of-scope composite-literal temporary is a stack +// use-after-scope ASan would catch here. +// +// Invariants: +// - marshal of any constructed tree never crashes / writes out of bounds; +// - the output is well-formed DER (a read_any walk consumes it exactly); +// - leaf values survive a marshal -> read round-trip. + +import "core:bytes" +import "core:encoding/asn1" +import "core:math/rand" +import "core:testing" +import "core:time" + +WRITER_FUZZ_ITERS :: 2048 +WRITER_FUZZ_DEPTH :: 6 + +// _gen_bytes allocates 0..max random bytes from the temp arena. +@(private = "file") +_gen_bytes :: proc(max: int) -> []byte { + n := rand.int_max(max) + b := make([]byte, n, context.temp_allocator) + for i in 0 ..< n { + b[i] = byte(rand.int_max(256)) + } + return b +} + +@(private = "file") +_gen_leaf :: proc() -> asn1.Value { + switch rand.int_max(7) { + case 0: + return asn1.boolean(rand.int_max(2) == 1) + case 1: + return asn1.null() + case 2: + return asn1.integer_unsigned(_gen_bytes(20)) + case 3: + return asn1.octet_string(_gen_bytes(20)) + case 4: + return asn1.bit_string_octets(_gen_bytes(20)) + case 5: + return asn1.object_identifier(_gen_bytes(12)) // raw OID octets: read_any tolerates any content + case: + return asn1.generalized_time(time.unix(i64(rand.int_max(2_000_000_000)), 0)) + } +} + +// _gen_value builds a random tree; constructed nodes draw their children +// arrays from the temp arena (pre-sized, so the slices set() / sequence() +// borrow never move), freed wholesale after each iteration. +@(private = "file") +_gen_value :: proc(depth: int) -> asn1.Value { + if depth <= 0 || rand.int_max(3) == 0 { + return _gen_leaf() + } + n := rand.int_max(4) // 0..3 children + kids := make([]asn1.Value, n, context.temp_allocator) + for i in 0 ..< n { + kids[i] = _gen_value(depth - 1) + } + switch rand.int_max(5) { + case 0: + return asn1.sequence(kids) + case 1: + return asn1.set(kids) + case 2: + return asn1.context_explicit(u32(rand.int_max(8)), kids) + case 3: + return asn1.bit_string_wrap(kids) + case: + sv, serr := asn1.set_of(kids, context.temp_allocator) // allocates scratch + sorts in place + if serr != .None { + return asn1.set(kids) + } + return sv + } +} + +// _walk recurses through a marshalled tree with read_any, asserting every +// element frames cleanly and the input is consumed exactly. +@(private = "file") +_walk :: proc(t: ^testing.T, data: []byte, depth: int) { + cur: asn1.Cursor + asn1.cursor_init(&cur, data) + for !asn1.is_empty(&cur) { + tag, content, err := asn1.read_any(&cur) + testing.expect_value(t, err, asn1.Error.None) + if err != .None { + return + } + if tag.constructed && depth > 0 { + _walk(t, content, depth - 1) + } + } +} + +@(test) +test_fuzz_writer_wellformed :: proc(t: ^testing.T) { + for _ in 0 ..< WRITER_FUZZ_ITERS { + tree := _gen_value(WRITER_FUZZ_DEPTH) + out, err := asn1.marshal(tree) + testing.expect_value(t, err, asn1.Error.None) + if err == .None { + testing.expect_value(t, len(out), asn1.encoded_len(tree)) // sizing == emission + _walk(t, out, 64) + delete(out) + } + free_all(context.temp_allocator) + } +} + +@(test) +test_fuzz_writer_roundtrip :: proc(t: ^testing.T) { + for _ in 0 ..< WRITER_FUZZ_ITERS { + // OCTET STRING: content survives verbatim. + payload := _gen_bytes(32) + if o, e := asn1.marshal(asn1.octet_string(payload)); e == .None { + cur: asn1.Cursor + asn1.cursor_init(&cur, o) + got, re := asn1.read_octet_string(&cur) + testing.expect_value(t, re, asn1.Error.None) + testing.expect_value(t, asn1.done(&cur), asn1.Error.None) + testing.expect(t, bytes.equal(got, payload), "octet string round-trip") + delete(o) + } + + // BIT STRING (whole octets): payload survives verbatim. + bits := _gen_bytes(32) + if o, e := asn1.marshal(asn1.bit_string_octets(bits)); e == .None { + cur: asn1.Cursor + asn1.cursor_init(&cur, o) + got, re := asn1.read_bit_string_octets(&cur) + testing.expect_value(t, re, asn1.Error.None) + testing.expect(t, bytes.equal(got, bits), "bit string round-trip") + delete(o) + } + + // BOOLEAN. + bv := rand.int_max(2) == 1 + if o, e := asn1.marshal(asn1.boolean(bv)); e == .None { + cur: asn1.Cursor + asn1.cursor_init(&cur, o) + got, re := asn1.read_boolean(&cur) + testing.expect_value(t, re, asn1.Error.None) + testing.expect_value(t, got, bv) + delete(o) + } + + free_all(context.temp_allocator) + } +} diff --git a/tests/core/encoding/asn1/oom_asn1.odin b/tests/core/encoding/asn1/oom_asn1.odin new file mode 100644 index 000000000..fdbd4e568 --- /dev/null +++ b/tests/core/encoding/asn1/oom_asn1.odin @@ -0,0 +1,119 @@ +package test_core_asn1 + +// Out-of-memory robustness for the allocating OID helpers. A failing +// allocator wraps a tracking allocator; sweeping the fail point across +// every allocation (including the Builder's internal growth in +// oid_to_string) must yield Allocation_Failed with nothing leaked. + +import "base:runtime" +import "core:mem" +import "core:encoding/asn1" +import "core:testing" + +@(private="file") +Failing_Allocator :: struct { + backing: runtime.Allocator, + count: int, + fail_at: int, +} + +@(private="file") +failing_allocator_proc :: proc( + data: rawptr, mode: runtime.Allocator_Mode, + size, alignment: int, old_memory: rawptr, old_size: int, + loc := #caller_location, +) -> ([]byte, runtime.Allocator_Error) { + fa := (^Failing_Allocator)(data) + #partial switch mode { + case .Alloc, .Alloc_Non_Zeroed, .Resize, .Resize_Non_Zeroed: + if fa.count == fa.fail_at { + fa.count += 1 + return nil, .Out_Of_Memory + } + fa.count += 1 + } + return fa.backing.procedure(fa.backing.data, mode, size, alignment, old_memory, old_size, loc) +} + +@(private="file") +failing_allocator :: proc(fa: ^Failing_Allocator) -> runtime.Allocator { + return {procedure = failing_allocator_proc, data = fa} +} + +// rsaEncryption (1.2.840.113549.1.1.1) — seven arcs rendering to a +// 20-char string, enough to drive the Builder in oid_to_string past +// its initial capacity so the resize path is on the sweep. +@(private="file") +LONG_OID := []byte{0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} + +@(test) +test_oom_oid_components :: proc(t: ^testing.T) { + raw: asn1.Cursor + asn1.cursor_init(&raw, LONG_OID) + oid, oerr := asn1.read_oid(&raw) + testing.expect_value(t, oerr, asn1.Error.None) + + total: int + { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1} + arcs, err := asn1.oid_components(oid, failing_allocator(&fa)) + testing.expect_value(t, err, asn1.Error.None) + delete(arcs, failing_allocator(&fa)) + total = fa.count + testing.expect(t, total >= 1) + testing.expect_value(t, len(track.allocation_map), 0) + } + + for k in 0 ..< total { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k} + arcs, err := asn1.oid_components(oid, failing_allocator(&fa)) + if err == .None { + delete(arcs, failing_allocator(&fa)) + } else { + testing.expectf(t, err == .Allocation_Failed, "k=%d: got %v", k, err) + } + testing.expectf(t, len(track.allocation_map) == 0, "k=%d: %d leaked", k, len(track.allocation_map)) + } +} + +@(test) +test_oom_oid_to_string :: proc(t: ^testing.T) { + raw: asn1.Cursor + asn1.cursor_init(&raw, LONG_OID) + oid, oerr := asn1.read_oid(&raw) + testing.expect_value(t, oerr, asn1.Error.None) + + total: int + { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1} + str, err := asn1.oid_to_string(oid, failing_allocator(&fa)) + testing.expect_value(t, err, asn1.Error.None) + delete(str, failing_allocator(&fa)) + total = fa.count + testing.expect(t, total >= 1) + testing.expect_value(t, len(track.allocation_map), 0) + } + + for k in 0 ..< total { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k} + str, err := asn1.oid_to_string(oid, failing_allocator(&fa)) + if err == .None { + delete(str, failing_allocator(&fa)) + } else { + testing.expectf(t, err == .Allocation_Failed, "k=%d: got %v", k, err) + } + testing.expectf(t, len(track.allocation_map) == 0, "k=%d: %d leaked", k, len(track.allocation_map)) + } +} diff --git a/tests/core/encoding/asn1/test_core_asn1.odin b/tests/core/encoding/asn1/test_core_asn1.odin new file mode 100644 index 000000000..f2d0f66ea --- /dev/null +++ b/tests/core/encoding/asn1/test_core_asn1.odin @@ -0,0 +1,532 @@ +package test_core_asn1 + +import "core:encoding/asn1" +import "core:testing" +import "core:time" + +// ============================================================ +// Tag and length forms +// ============================================================ + +@(test) +test_tag_forms :: proc(t: ^testing.T) { + // Low tag, primitive universal. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x02, 0x01, 0x05}) + tag, content, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, tag, asn1.universal(.Integer)) + testing.expect_value(t, len(content), 1) + testing.expect_value(t, asn1.done(&r), asn1.Error.None) + } + // Constructed context-specific [0]. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0xA0, 0x00}) + tag, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, tag, asn1.context_specific(0)) + } + // High-tag-number form: [31] primitive → 9F 1F. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x9F, 0x1F, 0x00}) + tag, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, tag.number, u32(31)) + } + // High-tag form used for a number < 31 is non-minimal → invalid. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x9F, 0x1E, 0x00}) + _, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Tag) + } + // High-tag form with 0x80 lead continuation octet is non-minimal. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x9F, 0x80, 0x1F, 0x00}) + _, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Tag) + } +} + +@(test) +test_length_forms :: proc(t: ^testing.T) { + // Long-form length for 128 bytes: 81 80. + { + buf: [131]byte + buf[0] = 0x04 + buf[1] = 0x81 + buf[2] = 0x80 + r: asn1.Cursor + asn1.cursor_init(&r, buf[:]) + _, content, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, len(content), 128) + } + // Long form for a short length (81 05) is non-minimal. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x04, 0x81, 0x05, 1, 2, 3, 4, 5}) + _, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Length) + } + // Leading zero octet in long form (82 00 80) is non-minimal. + { + buf: [131]byte + buf[0] = 0x04 + buf[1] = 0x82 + buf[2] = 0x00 + buf[3] = 0x80 + r: asn1.Cursor + asn1.cursor_init(&r, buf[:]) + _, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Length) + } + // Indefinite length (80) is BER, never DER. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x30, 0x80, 0x00, 0x00}) + _, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Length) + } + // Length past end of input. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x04, 0x05, 1, 2}) + _, _, err := asn1.read_any(&r) + testing.expect_value(t, err, asn1.Error.Truncated) + } +} + +// ============================================================ +// Scalar types +// ============================================================ + +@(test) +test_boolean :: proc(t: ^testing.T) { + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x01, 0x01, 0xFF}) + v, err := asn1.read_boolean(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, v, true) + } + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x01, 0x01, 0x00}) + v, err := asn1.read_boolean(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, v, false) + } + // DER: any value other than 0x00/0xFF is invalid. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x01, 0x01, 0x01}) + _, err := asn1.read_boolean(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Boolean) + } + // Wrong width. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x01, 0x02, 0xFF, 0xFF}) + _, err := asn1.read_boolean(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Boolean) + } +} + +@(test) +test_integer :: proc(t: ^testing.T) { + Case :: struct { + der: []byte, + value: i64, + err: asn1.Error, + } + cases := []Case{ + {der = {0x02, 0x01, 0x00}, value = 0}, + {der = {0x02, 0x01, 0x7F}, value = 127}, + {der = {0x02, 0x02, 0x00, 0x80}, value = 128}, + {der = {0x02, 0x01, 0x80}, value = -128}, + {der = {0x02, 0x02, 0xFF, 0x7F}, value = -129}, + {der = {0x02, 0x08, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, value = max(i64)}, + {der = {0x02, 0x08, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, value = min(i64)}, + // Non-minimal: redundant leading 0x00 / 0xFF. + {der = {0x02, 0x02, 0x00, 0x05}, err = .Invalid_Integer}, + {der = {0x02, 0x02, 0xFF, 0x85}, err = .Invalid_Integer}, + // Empty content. + {der = {0x02, 0x00}, err = .Invalid_Integer}, + // Too wide for i64. + {der = {0x02, 0x09, 0x01, 0, 0, 0, 0, 0, 0, 0, 0}, err = .Integer_Overflow}, + } + for c in cases { + r: asn1.Cursor + asn1.cursor_init(&r, c.der) + v, err := asn1.read_i64(&r) + testing.expect_value(t, err, c.err) + if c.err == .None { + testing.expect_value(t, v, c.value) + } + } +} + +@(test) +test_unsigned_integer :: proc(t: ^testing.T) { + // 0x00 sign octet stripped: 255 encodes as 02 02 00 FF. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x02, 0x02, 0x00, 0xFF}) + mag, err := asn1.read_unsigned_integer_bytes(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, len(mag), 1) + testing.expect_value(t, mag[0], u8(0xFF)) + } + // Zero stays one octet. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x02, 0x01, 0x00}) + mag, err := asn1.read_unsigned_integer_bytes(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, len(mag), 1) + testing.expect_value(t, mag[0], u8(0x00)) + } + // Negative rejected. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x02, 0x01, 0x80}) + _, err := asn1.read_unsigned_integer_bytes(&r) + testing.expect_value(t, err, asn1.Error.Negative_Integer) + } +} + +@(test) +test_bit_string :: proc(t: ^testing.T) { + // Whole octets: 03 03 00 A0 0F. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x03, 0x00, 0xA0, 0x0F}) + octets, err := asn1.read_bit_string_octets(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, len(octets), 2) + } + // 4 unused bits, correctly zero-padded: A0 = 1010_0000. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x02, 0x04, 0xA0}) + bits, unused, err := asn1.read_bit_string(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, unused, 4) + testing.expect_value(t, len(bits), 1) + } + // Non-zero padding bits violate DER. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x02, 0x04, 0xA1}) + _, _, err := asn1.read_bit_string(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Bit_String) + } + // Unused count > 7. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x02, 0x08, 0xA0}) + _, _, err := asn1.read_bit_string(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Bit_String) + } + // Empty payload must declare zero unused bits. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x01, 0x03}) + _, _, err := asn1.read_bit_string(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Bit_String) + } + // Empty content entirely. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x00}) + _, _, err := asn1.read_bit_string(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Bit_String) + } + // PKIX shape requires unused == 0. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x03, 0x02, 0x04, 0xA0}) + _, err := asn1.read_bit_string_octets(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Bit_String) + } +} + +@(test) +test_null :: proc(t: ^testing.T) { + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x05, 0x00}) + testing.expect_value(t, asn1.read_null(&r), asn1.Error.None) + } + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x05, 0x01, 0x00}) + testing.expect_value(t, asn1.read_null(&r), asn1.Error.Invalid_Null) + } +} + +// ============================================================ +// OBJECT IDENTIFIER +// ============================================================ + +@(test) +test_oid :: proc(t: ^testing.T) { + // rsaEncryption: 1.2.840.113549.1.1.1 + rsa_encryption := []byte{0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} + { + r: asn1.Cursor + asn1.cursor_init(&r, rsa_encryption) + raw, err := asn1.read_oid(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, len(raw), 9) + + str, serr := asn1.oid_to_string(raw) + defer delete(str) + testing.expect_value(t, serr, asn1.Error.None) + testing.expect_value(t, str, "1.2.840.113549.1.1.1") + + arcs, aerr := asn1.oid_components(raw) + defer delete(arcs) + testing.expect_value(t, aerr, asn1.Error.None) + testing.expect_value(t, len(arcs), 7) + testing.expect_value(t, arcs[3], u64(113549)) + } + // ecPublicKey: 1.2.840.10045.2.1 + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01}) + raw, err := asn1.read_oid(&r) + testing.expect_value(t, err, asn1.Error.None) + str, serr := asn1.oid_to_string(raw) + defer delete(str) + testing.expect_value(t, serr, asn1.Error.None) + testing.expect_value(t, str, "1.2.840.10045.2.1") + } + // Arc-2 base offset: 2.5.4.3 (id-at-commonName) → 55 04 03. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x06, 0x03, 0x55, 0x04, 0x03}) + raw, err := asn1.read_oid(&r) + testing.expect_value(t, err, asn1.Error.None) + str, serr := asn1.oid_to_string(raw) + defer delete(str) + testing.expect_value(t, serr, asn1.Error.None) + testing.expect_value(t, str, "2.5.4.3") + } + // Empty content is invalid. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x06, 0x00}) + _, err := asn1.read_oid(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Object_Identifier) + } + // Non-minimal subidentifier (leading 0x80 continuation octet). + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x06, 0x03, 0x2A, 0x80, 0x01}) + _, err := asn1.read_oid(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Object_Identifier) + } + // Truncated subidentifier (continuation bit set on last octet). + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x06, 0x02, 0x2A, 0x86}) + _, err := asn1.read_oid(&r) + testing.expect_value(t, err, asn1.Error.Invalid_Object_Identifier) + } +} + +// ============================================================ +// Time +// ============================================================ + +@(test) +test_time :: proc(t: ^testing.T) { + // Times are returned as time.Time; compare via Unix seconds. + // Reference epochs computed independently (e.g. + // `date -u -d '1999-01-01T00:00:00Z' +%s`). UTCTime century window: + // 990101000000Z → 1999, 490101000000Z → 2049. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x17, 0x0D, '9', '9', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'}) + v, err := asn1.read_utc_time(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, time.to_unix_seconds(v), i64(915148800)) // 1999-01-01T00:00:00Z + } + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x17, 0x0D, '4', '9', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'}) + v, err := asn1.read_utc_time(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, time.to_unix_seconds(v), i64(2493072000)) // 2049-01-01T00:00:00Z + } + // GeneralizedTime: 20260612153000Z. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x18, 0x0F, '2', '0', '2', '6', '0', '6', '1', '2', '1', '5', '3', '0', '0', '0', 'Z'}) + v, err := asn1.read_generalized_time(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, time.to_unix_seconds(v), i64(1781278200)) // 2026-06-12T15:30:00Z + } + // read_time dispatches on tag. + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x18, 0x0F, '2', '0', '5', '0', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'}) + v, err := asn1.read_time(&r) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, time.to_unix_seconds(v), i64(2524608000)) // 2050-01-01T00:00:00Z + } + // Far-future dates (year 9999, the RFC 5280 no-expiration sentinel) + // must still parse, but time.Time tops out near year 2262, so the + // value SATURATES rather than carrying the literal 9999 epoch. This + // is an intentional limitation (see asn1's _time_from_unix): the + // 9999 sentinel just needs to read as "effectively never expires". + { + r: asn1.Cursor + asn1.cursor_init(&r, []byte{0x18, 0x0F, '9', '9', '9', '9', '1', '2', '3', '1', '2', '3', '5', '9', '5', '9', 'Z'}) + v, err := asn1.read_generalized_time(&r) + testing.expect_value(t, err, asn1.Error.None) + // Saturated near year 2262, NOT the true 9999 epoch (253402300799). + testing.expect(t, time.to_unix_seconds(v) >= i64(9_223_372_036), "9999 saturates to time.Time max") + testing.expect(t, time.to_unix_seconds(v) < i64(253402300799), "saturated, not the literal 9999 epoch") + } + // RFC 5280 profile rejections: offset instead of Z, missing + // seconds, fractional seconds, month/day out of range. + bad := [][]byte{ + {0x17, 0x11, '9', '9', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', '+', '0', '1', '0', '0'}, + {0x17, 0x0B, '9', '9', '0', '1', '0', '1', '0', '0', '0', '0', 'Z'}, + {0x18, 0x12, '2', '0', '2', '6', '0', '6', '1', '2', '1', '5', '3', '0', '0', '0', '.', '5', '0', 'Z'}, + {0x17, 0x0D, '9', '9', '1', '3', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'}, + {0x17, 0x0D, '9', '9', '0', '1', '0', '0', '0', '0', '0', '0', '0', '0', 'Z'}, + {0x17, 0x0D, '9', '9', '0', '1', '0', '1', '2', '4', '0', '0', '0', '0', 'Z'}, + } + for der in bad { + r: asn1.Cursor + asn1.cursor_init(&r, der) + _, err := asn1.read_time(&r) + testing.expect(t, err != .None, "malformed time must be rejected") + } +} + +// ============================================================ +// Compound structures +// ============================================================ + +// A real SubjectPublicKeyInfo for an EC P-256 key: +// SEQUENCE { +// SEQUENCE { OID ecPublicKey, OID prime256v1 } +// BIT STRING (65 octets of uncompressed point, 0 unused bits) +// } +@(private) +make_spki :: proc(allocator := context.allocator) -> [dynamic]byte { + out: [dynamic]byte + out.allocator = allocator + append(&out, 0x30, 0x59) // SEQUENCE, len 89 + append(&out, 0x30, 0x13) // SEQUENCE, len 19 + append(&out, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01) // ecPublicKey + append(&out, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07) // prime256v1 + append(&out, 0x03, 0x42, 0x00) // BIT STRING, len 66, 0 unused + append(&out, 0x04) // uncompressed point marker + for i in 0 ..< 64 { + append(&out, u8(i)) + } + return out +} + +@(private) +parse_spki :: proc(data: []byte) -> (point: []byte, err: asn1.Error) { + r: asn1.Cursor + asn1.cursor_init(&r, data) + + spki, serr := asn1.read_sequence(&r) + if serr != .None { + return nil, serr + } + if derr := asn1.done(&r); derr != .None { + return nil, derr + } + + alg, aerr := asn1.read_sequence(&spki) + if aerr != .None { + return nil, aerr + } + alg_oid, oerr := asn1.read_oid(&alg) + if oerr != .None { + return nil, oerr + } + _ = alg_oid + curve_oid, cerr := asn1.read_oid(&alg) + if cerr != .None { + return nil, cerr + } + _ = curve_oid + if derr := asn1.done(&alg); derr != .None { + return nil, derr + } + + key, kerr := asn1.read_bit_string_octets(&spki) + if kerr != .None { + return nil, kerr + } + if derr := asn1.done(&spki); derr != .None { + return nil, derr + } + return key, .None +} + +@(test) +test_spki_walk :: proc(t: ^testing.T) { + spki := make_spki() + defer delete(spki) + + point, err := parse_spki(spki[:]) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, len(point), 65) + testing.expect_value(t, point[0], u8(0x04)) +} + +// Every truncation of a valid structure must error cleanly — never +// panic, never succeed. +@(test) +test_truncation_sweep :: proc(t: ^testing.T) { + spki := make_spki() + defer delete(spki) + + for n in 0 ..< len(spki) { + _, err := parse_spki(spki[:n]) + testing.expect(t, err != .None, "truncated input must be rejected") + } +} + +@(test) +test_explicit_optional :: proc(t: ^testing.T) { + // [0] EXPLICIT INTEGER 2 (the X.509 version field shape), then an + // INTEGER at the outer level. + der := []byte{0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x01, 0x07} + r: asn1.Cursor + asn1.cursor_init(&r, der) + + inner, present, err := asn1.read_explicit(&r, 0) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, present, true) + version, verr := asn1.read_i64(&inner) + testing.expect_value(t, verr, asn1.Error.None) + testing.expect_value(t, version, i64(2)) + testing.expect_value(t, asn1.done(&inner), asn1.Error.None) + + // Absent optional: next element is [1]? No — it's the INTEGER, so + // read_explicit(1) must not consume. + _, present2, err2 := asn1.read_explicit(&r, 1) + testing.expect_value(t, err2, asn1.Error.None) + testing.expect_value(t, present2, false) + + serial, serr := asn1.read_i64(&r) + testing.expect_value(t, serr, asn1.Error.None) + testing.expect_value(t, serial, i64(7)) + testing.expect_value(t, asn1.done(&r), asn1.Error.None) +} diff --git a/tests/core/encoding/asn1/test_writer.odin b/tests/core/encoding/asn1/test_writer.odin new file mode 100644 index 000000000..7800cdb6b --- /dev/null +++ b/tests/core/encoding/asn1/test_writer.odin @@ -0,0 +1,298 @@ +package test_core_asn1 + +import "core:bytes" +import "core:encoding/asn1" +import "core:testing" +import "core:time" + +@(private = "file") +_marshal :: proc(t: ^testing.T, v: asn1.Value) -> []byte { + out, err := asn1.marshal(v) + testing.expect_value(t, err, asn1.Error.None) + return out +} + +@(private = "file") +_expect_der :: proc(t: ^testing.T, v: asn1.Value, want: []byte) { + got := _marshal(t, v) + defer delete(got) + testing.expect_value(t, len(got), asn1.encoded_len(v)) // encoded_len must match what was written + testing.expectf(t, bytes.equal(got, want), "got %x, want %x", got, want) +} + +// INTEGER from an unsigned magnitude: minimal stripping + sign-octet +// insertion, the inverse of read_unsigned_integer_bytes. +@(test) +test_writer_integer_unsigned :: proc(t: ^testing.T) { + _expect_der(t, asn1.integer_unsigned({}), {0x02, 0x01, 0x00}) // empty -> 0 + _expect_der(t, asn1.integer_unsigned({0x00}), {0x02, 0x01, 0x00}) // 0 + _expect_der(t, asn1.integer_unsigned({0x00, 0x00}), {0x02, 0x01, 0x00}) // all-zero -> 0 + _expect_der(t, asn1.integer_unsigned({0x2A}), {0x02, 0x01, 0x2A}) // 42 + _expect_der(t, asn1.integer_unsigned({0x00, 0x2A}), {0x02, 0x01, 0x2A}) // strip leading zero + _expect_der(t, asn1.integer_unsigned({0x80}), {0x02, 0x02, 0x00, 0x80}) // 128: insert sign octet + _expect_der(t, asn1.integer_unsigned({0xFF, 0xFF}), {0x02, 0x03, 0x00, 0xFF, 0xFF}) // 65535 + _expect_der(t, asn1.integer_unsigned({0x01, 0x00, 0x01}), {0x02, 0x03, 0x01, 0x00, 0x01}) // 65537 (RSA e) +} + +// Length octets: short form below 128, otherwise minimal long form. Drive +// the boundary with OCTET STRINGs of crafted sizes and check the header. +@(test) +test_writer_length_forms :: proc(t: ^testing.T) { + cases := []struct { + n: int, + head: []byte, + } { + {0, {0x04, 0x00}}, + {1, {0x04, 0x01}}, + {127, {0x04, 0x7F}}, // last short-form length + {128, {0x04, 0x81, 0x80}}, // first long form + {255, {0x04, 0x81, 0xFF}}, + {256, {0x04, 0x82, 0x01, 0x00}}, + {300, {0x04, 0x82, 0x01, 0x2C}}, + } + for c in cases { + content := make([]byte, c.n) + defer delete(content) + got := _marshal(t, asn1.octet_string(content)) + defer delete(got) + testing.expectf(t, len(got) >= len(c.head), "n=%d: short output", c.n) + testing.expectf(t, bytes.equal(got[:len(c.head)], c.head), "n=%d: header %x, want %x", c.n, got[:len(c.head)], c.head) + testing.expect_value(t, len(got), len(c.head) + c.n) + } +} + +// encode into a buffer one byte too small must write nothing and report it. +@(test) +test_writer_buffer_too_small :: proc(t: ^testing.T) { + v := asn1.integer_unsigned({0x80}) // encodes to 4 bytes + need := asn1.encoded_len(v) + testing.expect_value(t, need, 4) + + short := make([]byte, need - 1) + defer delete(short) + n, err := asn1.encode(v, short) + testing.expect_value(t, err, asn1.Error.Buffer_Too_Small) + testing.expect_value(t, n, 0) + + exact := make([]byte, need) + defer delete(exact) + n2, err2 := asn1.encode(v, exact) + testing.expect_value(t, err2, asn1.Error.None) + testing.expect_value(t, n2, need) +} + +// Round-trip every write back through the cursor reader: SEQUENCE { INTEGER +// r, INTEGER s }, the ECDSA signature shape, with s's top bit set so a sign +// octet is inserted on write and stripped on read. +@(test) +test_writer_roundtrip_ecdsa_sig :: proc(t: ^testing.T) { + r := []byte{0x01, 0x23, 0x45, 0x67} + s := []byte{0x80, 0x00, 0x00, 0x01} // top bit set + + sig := _marshal(t, asn1.sequence({asn1.integer_unsigned(r), asn1.integer_unsigned(s)})) + defer delete(sig) + + // s gained a 0x00 sign octet: 2 + (2+4) + (2+5) = 15 bytes. + testing.expect_value(t, len(sig), 15) + + cur: asn1.Cursor + asn1.cursor_init(&cur, sig) + seq, serr := asn1.read_sequence(&cur) + testing.expect_value(t, serr, asn1.Error.None) + + gr, rerr := asn1.read_unsigned_integer_bytes(&seq) + gs, srerr := asn1.read_unsigned_integer_bytes(&seq) + testing.expect_value(t, rerr, asn1.Error.None) + testing.expect_value(t, srerr, asn1.Error.None) + testing.expect_value(t, asn1.done(&seq), asn1.Error.None) + testing.expect_value(t, asn1.done(&cur), asn1.Error.None) + + testing.expect(t, bytes.equal(gr, r), "r round-trips") + testing.expect(t, bytes.equal(gs, s), "s round-trips") +} + +// Structural Tier 1 encoders: NULL, OID passthrough, BIT STRING (whole +// octets), SET, and the context-specific [n] wrappers, by exact bytes. +@(test) +test_writer_structural :: proc(t: ^testing.T) { + _expect_der(t, asn1.null(), {0x05, 0x00}) + + // BIT STRING, whole octets: 03 00 . + _expect_der(t, asn1.bit_string_octets({0xCA, 0xFE}), {0x03, 0x03, 0x00, 0xCA, 0xFE}) + _expect_der(t, asn1.bit_string_octets({}), {0x03, 0x01, 0x00}) + + // OID passthrough: rsaEncryption (1.2.840.113549.1.1.1) content octets. + rsa_oid := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} + _expect_der(t, asn1.object_identifier(rsa_oid), {0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01}) + + // [0] IMPLICIT primitive, and [0] EXPLICIT wrapping INTEGER 2. + _expect_der(t, asn1.context_primitive(0, {0xAB, 0xCD}), {0x80, 0x02, 0xAB, 0xCD}) + _expect_der(t, asn1.context_explicit(0, {asn1.integer_unsigned({0x02})}), {0xA0, 0x03, 0x02, 0x01, 0x02}) + + // SET emits in the given order (single/pre-sorted element is the contract). + _expect_der(t, asn1.set({asn1.boolean(false)}), {0x31, 0x03, 0x01, 0x01, 0x00}) +} + +// A SubjectPublicKeyInfo-shaped tree round-trips through the reader: +// SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING }. +@(test) +test_writer_spki_shape :: proc(t: ^testing.T) { + oid := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} // rsaEncryption + key := []byte{0x30, 0x06, 0x02, 0x01, 0x2A, 0x02, 0x01, 0x03} // opaque inner key octets + + der := _marshal(t, asn1.sequence({asn1.sequence({asn1.object_identifier(oid), asn1.null()}), asn1.bit_string_octets(key)})) + defer delete(der) + + cur: asn1.Cursor + asn1.cursor_init(&cur, der) + spki, e0 := asn1.read_sequence(&cur) + testing.expect_value(t, e0, asn1.Error.None) + + alg, e1 := asn1.read_sequence(&spki) + testing.expect_value(t, e1, asn1.Error.None) + got_oid, e2 := asn1.read_oid(&alg) + testing.expect_value(t, e2, asn1.Error.None) + testing.expect(t, bytes.equal(got_oid, oid), "oid round-trips") + testing.expect_value(t, asn1.read_null(&alg), asn1.Error.None) + testing.expect_value(t, asn1.done(&alg), asn1.Error.None) + + got_key, e3 := asn1.read_bit_string_octets(&spki) + testing.expect_value(t, e3, asn1.Error.None) + testing.expect(t, bytes.equal(got_key, key), "key bits round-trip") + testing.expect_value(t, asn1.done(&spki), asn1.Error.None) + testing.expect_value(t, asn1.done(&cur), asn1.Error.None) +} + +// time() auto-selects UTCTime (<2050) vs GeneralizedTime (>=2050) per RFC 5280. +@(test) +test_writer_time_auto :: proc(t: ^testing.T) { + // 2049-12-31T23:59:59Z -> UTCTime (tag 0x17). + before := _marshal(t, asn1.time(time.unix(2524607999, 0))) + defer delete(before) + testing.expect_value(t, before[0], u8(0x17)) + // 2050-01-01T00:00:00Z -> GeneralizedTime (tag 0x18). + after := _marshal(t, asn1.time(time.unix(2524608000, 0))) + defer delete(after) + testing.expect_value(t, after[0], u8(0x18)) +} + +// set_of sorts components into DER canonical order (X.690 11.6, by encoding). +@(test) +test_writer_set_of :: proc(t: ^testing.T) { + // Integers given 3,1,2 must emit sorted 1,2,3. + kids := [3]asn1.Value{asn1.integer_unsigned({0x03}), asn1.integer_unsigned({0x01}), asn1.integer_unsigned({0x02})} + v, err := asn1.set_of(kids[:]) + testing.expect_value(t, err, asn1.Error.None) + out := _marshal(t, v) + defer delete(out) + + cur: asn1.Cursor + asn1.cursor_init(&cur, out) + s, e := asn1.read_set(&cur) + testing.expect_value(t, e, asn1.Error.None) + for want in ([]byte{0x01, 0x02, 0x03}) { + got, ge := asn1.read_unsigned_integer_bytes(&s) + testing.expect_value(t, ge, asn1.Error.None) + testing.expect(t, len(got) == 1 && got[0] == want, "sorted ascending") + } + testing.expect_value(t, asn1.done(&s), asn1.Error.None) + + // Ordering is by ENCODING, not value: 2 (02 01 02) sorts before 256 + // (02 02 01 00) because the length octet 0x01 < 0x02. + kids2 := [2]asn1.Value{asn1.integer_unsigned({0x01, 0x00}), asn1.integer_unsigned({0x02})} + v2, err2 := asn1.set_of(kids2[:]) + testing.expect_value(t, err2, asn1.Error.None) + _expect_der(t, v2, {0x31, 0x07, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, 0x00}) +} + +// raw() splices a complete pre-encoded element in verbatim, the composition +// primitive for nesting independently-marshalled structures. +@(test) +test_writer_raw :: proc(t: ^testing.T) { + pre := []byte{0x02, 0x01, 0x2A} // a pre-encoded INTEGER 42 + _expect_der(t, asn1.raw(pre), {0x02, 0x01, 0x2A}) + // embedded beside another value inside a SEQUENCE. + _expect_der(t, asn1.sequence({asn1.raw(pre), asn1.boolean(true)}), {0x30, 0x06, 0x02, 0x01, 0x2A, 0x01, 0x01, 0xFF}) +} + +@(private = "file") +_expect_time :: proc(t: ^testing.T, v: asn1.Value, tag: byte, ascii: string) { + got := _marshal(t, v) + defer delete(got) + want := make([]byte, 2 + len(ascii)) + defer delete(want) + want[0] = tag + want[1] = byte(len(ascii)) + copy(want[2:], transmute([]byte)ascii) + testing.expectf(t, bytes.equal(got, want), "got %x, want %x", got, want) +} + +// UTCTime / GeneralizedTime formatting (option B: time.Time formatted into +// the output at emit) by exact bytes, plus a round-trip through the reader. +// Tags: UTCTime 0x17, GeneralizedTime 0x18. +@(test) +test_writer_time :: proc(t: ^testing.T) { + epoch := time.unix(0, 0) // 1970-01-01 00:00:00Z + _expect_time(t, asn1.generalized_time(epoch), 0x18, "19700101000000Z") + _expect_time(t, asn1.utc_time(epoch), 0x17, "700101000000Z") + + y2027 := time.unix(1798761600, 0) // 2027-01-01 00:00:00Z + _expect_time(t, asn1.generalized_time(y2027), 0x18, "20270101000000Z") + _expect_time(t, asn1.utc_time(y2027), 0x17, "270101000000Z") + + // Nonzero month/day/time-of-day: 2023-11-14 22:13:20Z. + tod := time.unix(1700000000, 0) + _expect_time(t, asn1.generalized_time(tod), 0x18, "20231114221320Z") + + // Round-trip both forms through the reader. + { + der := _marshal(t, asn1.generalized_time(tod)) + defer delete(der) + cur: asn1.Cursor + asn1.cursor_init(&cur, der) + got, err := asn1.read_generalized_time(&cur) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, time.to_unix_seconds(got), i64(1700000000)) + } + { + der := _marshal(t, asn1.utc_time(y2027)) + defer delete(der) + cur: asn1.Cursor + asn1.cursor_init(&cur, der) + got, err := asn1.read_utc_time(&cur) + testing.expect_value(t, err, asn1.Error.None) + testing.expect_value(t, time.to_unix_seconds(got), i64(1798761600)) + } +} + +// Nesting + a second leaf type: SEQUENCE { BOOLEAN, SEQUENCE { INTEGER } }. +// Re-encoding the parsed structure must reproduce the bytes exactly +// (DER is canonical), which is the core correctness property for a writer. +@(test) +test_writer_nested_and_idempotent :: proc(t: ^testing.T) { + inner := asn1.sequence({asn1.integer_unsigned({0x2A})}) + outer := asn1.sequence({asn1.boolean(true), inner}) + + der := _marshal(t, outer) + defer delete(der) + + // 30 08 01 01 FF 30 03 02 01 2A + want := []byte{0x30, 0x08, 0x01, 0x01, 0xFF, 0x30, 0x03, 0x02, 0x01, 0x2A} + testing.expectf(t, bytes.equal(der, want), "got %x, want %x", der, want) + + // Walk it back through the reader to confirm it parses as DER. + cur: asn1.Cursor + asn1.cursor_init(&cur, der) + o, oerr := asn1.read_sequence(&cur) + testing.expect_value(t, oerr, asn1.Error.None) + b, berr := asn1.read_boolean(&o) + testing.expect_value(t, berr, asn1.Error.None) + testing.expect_value(t, b, true) + i, ierr := asn1.read_sequence(&o) + testing.expect_value(t, ierr, asn1.Error.None) + mag, merr := asn1.read_unsigned_integer_bytes(&i) + testing.expect_value(t, merr, asn1.Error.None) + testing.expect(t, bytes.equal(mag, {0x2A}), "inner integer round-trips") + testing.expect_value(t, asn1.done(&o), asn1.Error.None) + testing.expect_value(t, asn1.done(&cur), asn1.Error.None) +} diff --git a/tests/core/net/test_core_net.odin b/tests/core/net/test_core_net.odin index 0b079f5b0..fbca15bb1 100644 --- a/tests/core/net/test_core_net.odin +++ b/tests/core/net/test_core_net.odin @@ -139,6 +139,13 @@ IP_Address_Parsing_Test_Vectors :: []IP_Address_Parsing_Test_Vector{ { .IP4, "[10.0.128.31] :80", "", ""}, { .IP4, "[255.255.255.255]:65536", "", ""}, + // "]:" with no opening '[' is not a bracketed host:port; it parses + // as a plain host (no port) and so is not a valid address here. + { .IP4, "]:80", "", ""}, + { .IP4, "]:", "", ""}, + { .IP6, "]:1", "", ""}, + { .IP4, "foo]:80", "", ""}, + // numbers-and-dots notation, but not dotted-decimal { .IP4_Alt, "1.2.03.4", "01020304", ""}, diff --git a/tests/core/normal.odin b/tests/core/normal.odin index 4708ed700..1d0074918 100644 --- a/tests/core/normal.odin +++ b/tests/core/normal.odin @@ -13,6 +13,8 @@ download_assets :: proc "contextless" () { @(require) import "c/libc" @(require) import "compress" @(require) import "container" +@(require) import "crypto/x509" +@(require) import "encoding/asn1" @(require) import "encoding/base32" @(require) import "encoding/base64" @(require) import "encoding/cbor"