From 4876cf03bfca96d195ca4074b0b7af52aeab87dd Mon Sep 17 00:00:00 2001 From: kalsprite Date: Sat, 13 Jun 2026 23:40:08 -0700 Subject: [PATCH 1/7] 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" From ef0e991437e225d88963759db44fd79f03380102 Mon Sep 17 00:00:00 2001 From: kalsprite Date: Sun, 14 Jun 2026 00:16:41 -0700 Subject: [PATCH 2/7] relax roots --- core/crypto/x509/verify.odin | 43 +++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/core/crypto/x509/verify.odin b/core/crypto/x509/verify.odin index 80a413902..ca667d790 100644 --- a/core/crypto/x509/verify.odin +++ b/core/crypto/x509/verify.odin @@ -221,12 +221,12 @@ _MAX_SIG_CHECKS :: 100 // 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. +// The trust anchor is treated as trusted input, as in Go and OpenSSL: it +// must be valid at opts.current_time and is name-chained + signature-checked +// as the issuer below it, but its CA authorization (basicConstraints / +// keyCertSign / pathLenConstraint) and its own self-signature are NOT +// re-checked. An expired anchor is still rejected; resilience to that comes +// from the search trying every other available anchor and intermediate. // @(require_results) verify_chain :: proc( @@ -330,11 +330,13 @@ _build_to_anchor :: proc( // 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. + // Prefer terminating at a trust anchor. The anchor must have issued `cert` + // (name chaining + signature) and be valid at `now`, but as TRUSTED INPUT + // its CA authorization (basicConstraints / keyCertSign / pathLenConstraint) + // and its own self-signature are NOT re-checked, matching how Go and + // OpenSSL treat roots. See _anchor_usable. for root in opts.roots { - if !_is_issuer_of(root, cert) || !_issuer_usable(root, opts.current_time, below) { + if !_is_issuer_of(root, cert) || !_anchor_usable(root, opts.current_time) { continue } if budget^ <= 0 { @@ -449,6 +451,27 @@ _issuer_usable :: proc(issuer: ^Certificate, now: time.Time, below: int) -> bool return true } +// The anchor is trusted input, so unlike _issuer_usable its CA authorization +// (basicConstraints / keyCertSign / pathLenConstraint) and self-signature are +// NOT re-checked, matching Go and OpenSSL's treatment of roots. Still required: +// valid at `now` (Go and OpenSSL both enforce this; resilience to an expired +// anchor comes from the search trying other anchors/intermediates), no +// uninterpreted critical extension, and no name constraints (which we cannot +// enforce, so refuse rather than ignore). +@(private) +_anchor_usable :: proc(anchor: ^Certificate, now: time.Time) -> bool { + if anchor.unhandled_critical { + return false + } + if _has_extension(anchor, _OID_EXT_NAME_CONSTRAINTS) { + return false + } + if _check_validity(anchor, 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 From 04b614e1ad315ee52c6eeca804ee419ae2f825c3 Mon Sep 17 00:00:00 2001 From: kalsprite Date: Mon, 22 Jun 2026 22:41:42 -0700 Subject: [PATCH 3/7] update ecdsa to use asn1 --- core/crypto/ecdsa/ecdsa_asn1.odin | 184 +++++------------------------- 1 file changed, 26 insertions(+), 158 deletions(-) diff --git a/core/crypto/ecdsa/ecdsa_asn1.odin b/core/crypto/ecdsa/ecdsa_asn1.odin index 74c9d65e6..0bad6a263 100644 --- a/core/crypto/ecdsa/ecdsa_asn1.odin +++ b/core/crypto/ecdsa/ecdsa_asn1.odin @@ -1,17 +1,13 @@ package ecdsa +import "core:encoding/asn1" import secec "core:crypto/_weierstrass" -// ASN.1 format ECDSA signatures are`SEQUENCE { r INTEGER, s INTEGER }` -// this implements enough to generate/parse signatures. Eventually when -// we have a full ASN.1 DER library, these routines will be removed. +// ASN.1 ECDSA signatures are `SEQUENCE { r INTEGER, s INTEGER }`. These thin +// wrappers over core:encoding/asn1 generate/parse that structure the DER +// minimal-encoding rules. -@(private="file") -TAG_SEQUENCE :: 0x30 -@(private="file") -TAG_INTEGER :: 0x02 - -@(private,require_results) +@(private, require_results) generate_asn1_sig :: proc(r, s: ^$T, allocator := context.allocator) -> []byte { when T == secec.Scalar_p256r1 { SC_SZ :: secec.SC_SIZE_P256R1 @@ -21,163 +17,35 @@ generate_asn1_sig :: proc(r, s: ^$T, allocator := context.allocator) -> []byte { #panic("crypto/ecdsa: invalid curve") } - INT_TLP :: 3 // tag, tength, (optional) leading zero-byte - encode_uint :: proc(b: []byte) -> []byte { - b := b + r_buf, s_buf: [SC_SZ]byte = ---, --- + secec.sc_bytes(r_buf[:], r) + secec.sc_bytes(s_buf[:], s) - // DER requires minimal encoding. - off := INT_TLP - for v in b[off:] { - if v != 0 { - break - } - off += 1 - } - // If the sign big is set, add a leading zero. - if b[off] & 0x80 == 0x80 { - off -= 1 - b[off] = 0 - } - - // Encode the length (up to 127 octets, adequate for ECDSA). - l := len(b[off:]) - off -= 1 - b[off] = byte(l) - - // Encode the tag - off -= 1 - b[off] = TAG_INTEGER - - return b[off:] + sig, err := asn1.marshal( + asn1.sequence({asn1.integer_unsigned(r_buf[:]), asn1.integer_unsigned(s_buf[:])}), + allocator, + ) + if err != .None { + return nil } - - r_buf, s_buf: [INT_TLP+SC_SZ]byte = ---, --- - secec.sc_bytes(r_buf[INT_TLP:], r) - secec.sc_bytes(s_buf[INT_TLP:], s) - - r_bytes, s_bytes := encode_uint(r_buf[:]), encode_uint(s_buf[:]) - seq_len := len(r_bytes) + len(s_bytes) - - // WARNING: If secp521r1 support is added, this needs to support - // long-form length encoding. - ensure(seq_len <= 127, "BUG: crypto/ecdsa: signature length too large") - b := make([]byte, seq_len + 2, allocator) - b[0] = TAG_SEQUENCE - b[1] = byte(seq_len) - copy(b[2:], r_bytes) - copy(b[2+len(r_bytes):], s_bytes) - - return b + return sig } -@(private,require_results) +@(private, require_results) parse_asn1_sig :: proc(sig: []byte) -> (r, s: []byte, ok: bool) { - read_seq :: proc(b: []byte) -> (v: []byte, rest: []byte, ok: bool) { - b_len := len(b) - if b_len < 3 { - return nil, nil, false - } - if b[0] != TAG_SEQUENCE { - return nil, nil, false - } - seq_len, off: int - if b[1] & 0x80 == 0x80 { - if b[1] != 0x81 || b_len < 4 { // 2-length octets is sufficient for ecdsa. - return nil, nil, false - } - if b[2] & 0x80 == 0x80 || b[3] & 0x80 == 80 { - return nil, nil, false - } - seq_len = int(b[2]) * 127 + int(b[3]) - off = 4 - } else { - seq_len = int(b[1]) - off = 2 - } - if b_len - off < seq_len { - return nil, nil, false - } - return b[off:off+seq_len], b[off+seq_len:], true - } - - read_int :: proc(b: []byte) -> (v: []byte, rest: []byte, ok: bool) { - b_len := len(b) - if b_len < 3 { - return nil, nil, false - } - if b[0] != TAG_INTEGER { - return nil, nil, false - } - v_len := int(b[1]) - if v_len > 0x80 || b_len - 2 < v_len { // 127-bytes max. - return nil, nil, false - } - - return b[2:2+v_len], b[2+v_len:], true - } - - // SEQUENCE - seq_bytes, rest: []byte - seq_bytes, rest, ok = read_seq(sig) - if !ok { - return nil, nil, false - } - if len(rest) != 0 { + cur: asn1.Cursor + asn1.cursor_init(&cur, sig) + seq, e0 := asn1.read_sequence(&cur) + if e0 != .None || asn1.done(&cur) != .None { return nil, nil, false } - // INTEGER (r) - r, rest, ok = read_int(seq_bytes) - if !ok { + // r and s are unsigned; read_unsigned_integer_bytes validates the INTEGER + // and strips the DER sign octet, returning the magnitude as a view of sig. + rb, e1 := asn1.read_unsigned_integer_bytes(&seq) + sb, e2 := asn1.read_unsigned_integer_bytes(&seq) + if e1 != .None || e2 != .None || asn1.done(&seq) != .None { return nil, nil, false } - - // INTEGER (s) - s, rest, ok = read_int(rest) - if !ok { - return nil, nil, false - } - if len(rest) != 0 { - return nil, nil, false - } - - // DER requires a leading 0 if and only if (⟺) the sign bit of the leading byte - // is set to distinguish between positive and negative integers, - // and the minimal length representation. `r` and `s` are always - // going to be unsigned, so we validate malformed DER and strip - // the leading 0 as needed. - fixup_der_uint :: proc(b: []byte) -> ([]byte, bool) { - switch len(b) { - case 0: - // 0 length is invalid - return nil, false - case 1: - // Missing leading zero - if b[0] & 0x80 == 0x80 { - return nil, false - } - case: - if b[0] == 0 { - // Sign bit not set - if b[1] & 0x80 != 0x80 { - return nil, false - } - return b[1:], true - } else if b[0] & 0x80 == 0x80 { - // Missing leading zero - return nil, false - } - } - - return b, true - } - - if r, ok = fixup_der_uint(r); !ok { - return nil, nil, false - } - if s, ok = fixup_der_uint(s); !ok { - return nil, nil, false - } - - return r, s, true + return rb, sb, true } From e4fc7a3a9e45b5efe3f8ac92163f3163e424cbd5 Mon Sep 17 00:00:00 2001 From: kalsprite Date: Sun, 12 Jul 2026 12:14:32 -0700 Subject: [PATCH 4/7] rsa, x509 write path --- core/crypto/x509/doc.odin | 39 +- core/crypto/x509/ext.odin | 160 ++++++++ core/crypto/x509/marshal.odin | 431 +++++++++++++++++++++ core/crypto/x509/parse.odin | 118 +++++- core/crypto/x509/pkcs8.odin | 85 ++++ core/crypto/x509/verify.odin | 104 +++-- core/crypto/x509/x509.odin | 32 +- core/encoding/asn1/cursor.odin | 21 +- core/encoding/asn1/writer.odin | 17 +- tests/core/crypto/x509/fuzz_x509.odin | 4 +- tests/core/crypto/x509/test_core_x509.odin | 48 ++- tests/core/crypto/x509/testdata/pss.der | Bin 0 -> 891 bytes 12 files changed, 959 insertions(+), 100 deletions(-) create mode 100644 core/crypto/x509/ext.odin create mode 100644 core/crypto/x509/marshal.odin create mode 100644 core/crypto/x509/pkcs8.odin create mode 100644 tests/core/crypto/x509/testdata/pss.der diff --git a/core/crypto/x509/doc.odin b/core/crypto/x509/doc.odin index ee9104f94..11a9236c5 100644 --- a/core/crypto/x509/doc.odin +++ b/core/crypto/x509/doc.odin @@ -31,10 +31,17 @@ 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. + - Signature verification covers RSA PKCS#1 v1.5 and RSA-PSS + (SHA-1/256/384/512), ECDSA P-256/P-384, and Ed25519. These paths return + .Unsupported_Algorithm: ECDSA P-521 (effectively dead in web PKI), and + RSA-PSS naming a digest or MGF this package does not recognize. + - Name constraints are NOT decoded; verify_chain fails CLOSED on them. + Any CA (intermediate or trust anchor) asserting a nameConstraints + extension, critical or not, is refused as an issuer, so a chain through + a name-constrained CA is rejected rather than accepted unchecked. RFC + 5280 section 6.1.4(g) requires a validator that processes name + constraints to enforce them regardless of criticality; until that is + implemented, refusing is the only safe option. - 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, …). @@ -50,30 +57,6 @@ LIMITATIONS: 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). diff --git a/core/crypto/x509/ext.odin b/core/crypto/x509/ext.odin new file mode 100644 index 000000000..fbff6d88b --- /dev/null +++ b/core/crypto/x509/ext.odin @@ -0,0 +1,160 @@ +package x509 + +import "core:encoding/asn1" + +// Extension encoders. Each returns a complete DER Extension +// Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING } +// with extnValue carrying the encoded value (the inverse of the matching +// _parse_known_extension branch). The returned slice is allocated; +// a certificate builder splices these into the extensions [3] list via +// asn1.raw. critical=false is omitted per DEFAULT FALSE. + +// marshal_ext_basic_constraints encodes id-ce-basicConstraints: +// BasicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL } +// cA is emitted only when `is_ca`; pathLenConstraint only when is_ca and +// `max_path_len` >= 0 (a negative value means "absent"). +@(require_results) +marshal_ext_basic_constraints :: proc(is_ca: bool, max_path_len: int, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) { + vals: [2]asn1.Value + path_buf: [8]byte + n := 0 + if is_ca { + vals[n] = asn1.boolean(true) + n += 1 + if max_path_len >= 0 { + vals[n] = asn1.integer_unsigned(_uint_be(max_path_len, path_buf[:])) + n += 1 + } + } + return _marshal_extension(_OID_EXT_BASIC_CONSTRAINTS, critical, asn1.sequence(vals[:n]), allocator) +} + +// marshal_ext_key_usage encodes id-ce-keyUsage as a KeyUsage BIT STRING, +// minimally (trailing zero bits dropped, per DER named bit strings). +@(require_results) +marshal_ext_key_usage :: proc(usage: Key_Usage, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) { + hi := -1 + for bit in Key_Usage_Bit { + if bit in usage { + hi = int(bit) // bits enumerate ascending, so the last present is the highest + } + } + buf: [3]byte // unused-bits octet + up to 2 payload octets (9 named bits) + content: []byte + if hi < 0 { + content = buf[:1] // no bits set: empty bit string, 0 unused + } else { + nbytes := hi / 8 + 1 + buf[0] = byte(nbytes * 8 - (hi + 1)) // unused-bits count + for bit in Key_Usage_Bit { + i := int(bit) + if i <= hi && bit in usage { + buf[1 + i / 8] |= 0x80 >> uint(i % 8) + } + } + content = buf[:1 + nbytes] + } + return _marshal_extension(_OID_EXT_KEY_USAGE, critical, asn1.primitive(asn1.universal(.Bit_String), content), allocator) +} + +// marshal_ext_ext_key_usage encodes id-ce-extKeyUsage: +// ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId +// in EKU_Bit order. +@(require_results) +marshal_ext_ext_key_usage :: proc(eku: Ext_Key_Usage, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) { + purposes: [7]asn1.Value + n := 0 + for bit in EKU_Bit { + if bit in eku { + purposes[n] = asn1.object_identifier(_eku_oid(bit)) + n += 1 + } + } + return _marshal_extension(_OID_EXT_EXT_KEY_USAGE, critical, asn1.sequence(purposes[:n]), allocator) +} + +// marshal_ext_san encodes id-ce-subjectAltName: +// GeneralNames ::= SEQUENCE OF GeneralName +// emitting dNSName [2] IA5String entries (in order) followed by iPAddress [7] +// OCTET STRING entries. IP values are the raw 4- or 16-octet address. +@(require_results) +marshal_ext_san :: proc(dns_names: []string, ip_addresses: [][]byte, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) { + n := len(dns_names) + len(ip_addresses) + names, merr := make([]asn1.Value, n, allocator) // dynamic count: scaffolding, freed below + if merr != nil { + return nil, .Allocation_Failed + } + defer delete(names, allocator) + + i := 0 + for d in dns_names { + names[i] = asn1.context_primitive(2, transmute([]byte)d) // [2] IMPLICIT IA5String + i += 1 + } + for ip in ip_addresses { + names[i] = asn1.context_primitive(7, ip) // [7] IMPLICIT OCTET STRING + i += 1 + } + return _marshal_extension(_OID_EXT_SAN, critical, asn1.sequence(names[:]), allocator) +} + +// _marshal_extension wraps an extension value tree as a complete Extension. +// `value`'s borrowed backing lives in the caller's frame, which is active for +// the duration of this synchronous call, so the encode below sees it intact. +@(private, require_results) +_marshal_extension :: proc(oid: []byte, critical: bool, value: asn1.Value, allocator := context.allocator) -> (der: []byte, err: Error) { + out: []byte + merr: asn1.Error + if critical { + out, merr = asn1.marshal( + asn1.sequence({asn1.object_identifier(oid), asn1.boolean(true), asn1.octet_string_wrap({value})}), + allocator, + ) + } else { + out, merr = asn1.marshal(asn1.sequence({asn1.object_identifier(oid), asn1.octet_string_wrap({value})}), allocator) + } + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// _uint_be writes the minimal big-endian magnitude of a non-negative int into +// buf and returns the slice (empty for zero, which integer_unsigned encodes as 0). +@(private) +_uint_be :: proc(v: int, buf: []byte) -> []byte { + if v <= 0 { + return buf[:0] + } + n := 0 + x := v + for x > 0 { + n += 1 + x >>= 8 + } + for i in 0 ..< n { + buf[n - 1 - i] = byte(v >> uint(8 * i)) + } + return buf[:n] +} + +@(private) +_eku_oid :: proc(bit: EKU_Bit) -> []byte { + switch bit { + case .Server_Auth: + return _OID_EKU_SERVER_AUTH + case .Client_Auth: + return _OID_EKU_CLIENT_AUTH + case .Code_Signing: + return _OID_EKU_CODE_SIGNING + case .Email_Protection: + return _OID_EKU_EMAIL_PROTECTION + case .Time_Stamping: + return _OID_EKU_TIME_STAMPING + case .OCSP_Signing: + return _OID_EKU_OCSP_SIGNING + case .Any: + return _OID_EKU_ANY + } + return nil +} diff --git a/core/crypto/x509/marshal.odin b/core/crypto/x509/marshal.odin new file mode 100644 index 000000000..e98b4e6e5 --- /dev/null +++ b/core/crypto/x509/marshal.odin @@ -0,0 +1,431 @@ +package x509 + +import "core:encoding/asn1" +import "core:time" + +// Distinguished-name attribute types the DN builder knows by name. +DN_Attribute_Type :: enum { + Common_Name, // CN, 2.5.4.3 + Country, // C, 2.5.4.6 + Locality, // L, 2.5.4.7 + State_Or_Province, // ST, 2.5.4.8 + Organization, // O, 2.5.4.10 + Organizational_Unit, // OU, 2.5.4.11 + Serial_Number, // 2.5.4.5 +} + +// DN_Attribute is one relative distinguished name: a type and its value. +// Values are emitted as UTF8String, except Country and Serial_Number which +// are PrintableString (X.520), the policy RFC 5280 section 4.1.2.4 advises. +DN_Attribute :: struct { + type: DN_Attribute_Type, + value: string, +} + +@(rodata, private) +_OID_AT_CN := []byte{0x55, 0x04, 0x03} +@(rodata, private) +_OID_AT_C := []byte{0x55, 0x04, 0x06} +@(rodata, private) +_OID_AT_L := []byte{0x55, 0x04, 0x07} +@(rodata, private) +_OID_AT_ST := []byte{0x55, 0x04, 0x08} +@(rodata, private) +_OID_AT_O := []byte{0x55, 0x04, 0x0A} +@(rodata, private) +_OID_AT_OU := []byte{0x55, 0x04, 0x0B} +@(rodata, private) +_OID_AT_SERIAL := []byte{0x55, 0x04, 0x05} + +// marshal_dn encodes `attrs` as a DER Name (RDNSequence): one single-valued +// RelativeDistinguishedName per attribute, in the given order. The returned +// slice is the caller's to free; the attribute value bytes are copied into +// it, so `attrs` need not outlive the call. +@(require_results) +marshal_dn :: proc(attrs: []DN_Attribute, allocator := context.allocator) -> (der: []byte, err: Error) { + n := len(attrs) + // Value-tree scaffolding, freed once the bytes are produced. Pre-sized so + // the sub-slices handed to set()/sequence() never move before the encode. + rdns, e1 := make([]asn1.Value, n, allocator) // one SET per attribute + if e1 != nil { + return nil, .Allocation_Failed + } + defer delete(rdns, allocator) + atvs, e2 := make([]asn1.Value, n, allocator) // the AttributeTypeAndValue SEQUENCE + if e2 != nil { + return nil, .Allocation_Failed + } + defer delete(atvs, allocator) + pairs, e3 := make([]asn1.Value, 2 * n, allocator) // {type OID, value} per attribute + if e3 != nil { + return nil, .Allocation_Failed + } + defer delete(pairs, allocator) + + for a, i in attrs { + pairs[2 * i] = asn1.object_identifier(_dn_oid(a.type)) + pairs[2 * i + 1] = asn1.primitive(_dn_string_tag(a.type), transmute([]byte)a.value) + atvs[i] = asn1.sequence(pairs[2 * i:2 * i + 2]) + rdns[i] = asn1.set(atvs[i:i + 1]) + } + + out, merr := asn1.marshal(asn1.sequence(rdns), allocator) + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +@(private) +_dn_oid :: proc(type: DN_Attribute_Type) -> []byte { + switch type { + case .Common_Name: + return _OID_AT_CN + case .Country: + return _OID_AT_C + case .Locality: + return _OID_AT_L + case .State_Or_Province: + return _OID_AT_ST + case .Organization: + return _OID_AT_O + case .Organizational_Unit: + return _OID_AT_OU + case .Serial_Number: + return _OID_AT_SERIAL + } + return nil +} + +@(private) +_dn_string_tag :: proc(type: DN_Attribute_Type) -> asn1.Tag { + #partial switch type { + case .Country, .Serial_Number: + return asn1.universal(.Printable_String) + } + return asn1.universal(.UTF8_String) +} + +@(rodata, private) +_DER_INT_ZERO := []byte{0x00} + +// marshal_csr_info encodes the CertificationRequestInfo, the to-be-signed +// portion of a PKCS#10 CSR (RFC 2986): version v1, the subject DN, the subject +// public key, and the attributes set. Sign the returned bytes and pass them +// with the signature to marshal_csr. The slice is the caller's to free; +// `subject`, `key`, and `extensions` need not outlive the call. +// +// `extensions` is a list of pre-encoded Extension DER (from the marshal_ext_* +// helpers); when non-empty it is requested via a single PKCS#9 +// extensionRequest attribute (the standard way a CSR asks the CA to place +// extensions, SANs, key usage in the issued certificate). Pass nil for the +// empty attributes set. +@(require_results) +marshal_csr_info :: proc(subject: []DN_Attribute, key: Public_Key, extensions: [][]byte = nil, allocator := context.allocator) -> (cri_der: []byte, err: Error) { + dn := marshal_dn(subject, allocator) or_return + defer delete(dn, allocator) + spki := marshal_spki(key, allocator) or_return + defer delete(spki, allocator) + attrs := _marshal_csr_attributes(extensions, allocator) or_return + defer delete(attrs, allocator) + + // CertificationRequestInfo ::= SEQUENCE { version, subject, subjectPKInfo, [0] attributes } + out, merr := asn1.marshal( + asn1.sequence( + { + asn1.integer_raw(_DER_INT_ZERO), // version v1 (0) + asn1.raw(dn), // subject Name + asn1.raw(spki), // subjectPublicKeyInfo + asn1.raw(attrs), // attributes [0] + }, + ), + allocator, + ) + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// _marshal_csr_attributes encodes the CertificationRequestInfo attributes +// field, [0] IMPLICIT SET OF Attribute. With no extensions it is the empty +// set (A0 00); otherwise it carries a single PKCS#9 extensionRequest attribute +// Attribute ::= SEQUENCE { type extensionRequest, values SET { Extensions } } +// whose value is the Extensions SEQUENCE OF Extension built from `extensions`. +@(private, require_results) +_marshal_csr_attributes :: proc(extensions: [][]byte, allocator := context.allocator) -> (der: []byte, err: Error) { + out: []byte + merr: asn1.Error + if len(extensions) == 0 { + out, merr = asn1.marshal(asn1.context_explicit(0, {}), allocator) + } else { + // Pre-sized scaffolding for the Extensions SEQUENCE OF, kept alive + // (never resized) through the marshal below. + ext_raws, e := make([]asn1.Value, len(extensions), allocator) + if e != nil { + return nil, .Allocation_Failed + } + defer delete(ext_raws, allocator) + for ext, i in extensions { + ext_raws[i] = asn1.raw(ext) + } + out, merr = asn1.marshal( + asn1.context_explicit( + 0, + {asn1.sequence({asn1.object_identifier(_OID_EXT_REQUEST), asn1.set({asn1.sequence(ext_raws[:])})})}, + ), + allocator, + ) + } + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// marshal_csr wraps a (separately signed) CertificationRequestInfo into a +// complete PKCS#10 CertificationRequest. `signature` is the raw signature +// value over `cri_der`, a DER ECDSA-Sig-Value for ECDSA, the 64-byte value +// for Ed25519, and `signature_algorithm` selects the matching +// AlgorithmIdentifier. The slice is the caller's to free. +@(require_results) +marshal_csr :: proc(cri_der: []byte, signature_algorithm: Signature_Algorithm, signature: []byte, allocator := context.allocator) -> (csr_der: []byte, err: Error) { + // CertificationRequest ::= SEQUENCE { CRI, signatureAlgorithm, signature BIT STRING } + return _marshal_signed(cri_der, signature_algorithm, signature, allocator) +} + +// _marshal_signed wraps an already-encoded body (a CertificationRequestInfo +// or a TBSCertificate) with its signature algorithm and signature BIT STRING: +// SEQUENCE { body, AlgorithmIdentifier, BIT STRING signature } +// the common shape of PKCS#10 CSRs and X.509 certificates. +@(private, require_results) +_marshal_signed :: proc(body_der: []byte, signature_algorithm: Signature_Algorithm, signature: []byte, allocator := context.allocator) -> (der: []byte, err: Error) { + oid, null_params, ok := _sig_alg_identifier(signature_algorithm) + if !ok { + return nil, .Unsupported_Algorithm + } + out: []byte + merr: asn1.Error + if null_params { + out, merr = asn1.marshal( + asn1.sequence( + { + asn1.raw(body_der), + asn1.sequence({asn1.object_identifier(oid), asn1.null()}), + asn1.bit_string_octets(signature), + }, + ), + allocator, + ) + } else { + out, merr = asn1.marshal( + asn1.sequence( + {asn1.raw(body_der), asn1.sequence({asn1.object_identifier(oid)}), asn1.bit_string_octets(signature)}, + ), + allocator, + ) + } + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// _marshal_alg_id encodes a standalone AlgorithmIdentifier (NULL params for +// RSA PKCS#1, absent for ECDSA/EdDSA) for embedding in a TBSCertificate. +@(private, require_results) +_marshal_alg_id :: proc(signature_algorithm: Signature_Algorithm, allocator := context.allocator) -> (der: []byte, err: Error) { + oid, null_params, ok := _sig_alg_identifier(signature_algorithm) + if !ok { + return nil, .Unsupported_Algorithm + } + out: []byte + merr: asn1.Error + if null_params { + out, merr = asn1.marshal(asn1.sequence({asn1.object_identifier(oid), asn1.null()}), allocator) + } else { + out, merr = asn1.marshal(asn1.sequence({asn1.object_identifier(oid)}), allocator) + } + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// Maps a signature algorithm to its AlgorithmIdentifier OID and whether the +// parameters field is an explicit NULL (RSA PKCS#1) or absent (ECDSA, EdDSA). +@(private) +_sig_alg_identifier :: proc(alg: Signature_Algorithm) -> (oid: []byte, null_params: bool, ok: bool) { + #partial switch alg { + case .RSA_SHA256: + return _OID_SIG_RSA_SHA256, true, true + case .RSA_SHA384: + return _OID_SIG_RSA_SHA384, true, true + case .RSA_SHA512: + return _OID_SIG_RSA_SHA512, true, true + case .ECDSA_SHA256: + return _OID_SIG_ECDSA_SHA256, false, true + case .ECDSA_SHA384: + return _OID_SIG_ECDSA_SHA384, false, true + case .ECDSA_SHA512: + return _OID_SIG_ECDSA_SHA512, false, true + case .Ed25519: + return _OID_ED25519, false, true + } + return nil, false, false +} + +@(rodata, private) +_DER_INT_V3 := []byte{0x02} // Version v3 (value 2) + +// _marshal_extensions_field encodes the TBSCertificate extensions field: +// [3] EXPLICIT Extensions, Extensions ::= SEQUENCE OF Extension +// from a list of pre-encoded Extension DER, or nil when there are none (the +// field is OPTIONAL, so an empty list omits it entirely). +@(private, require_results) +_marshal_extensions_field :: proc(extensions: [][]byte, allocator := context.allocator) -> (der: []byte, err: Error) { + if len(extensions) == 0 { + return nil, .None + } + raws, merr := make([]asn1.Value, len(extensions), allocator) + if merr != nil { + return nil, .Allocation_Failed + } + defer delete(raws, allocator) + for e, i in extensions { + raws[i] = asn1.raw(e) + } + out, ferr := asn1.marshal(asn1.context_explicit(3, {asn1.sequence(raws[:])}), allocator) + if ferr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// TBS_Certificate gathers the fields of a TBSCertificate to encode. `issuer` +// and `subject` are RDNSequences; `serial` is the serialNumber's unsigned +// magnitude; `extensions` is a list of pre-encoded Extension DER (from the +// marshal_ext_* helpers), embedded in order. +TBS_Certificate :: struct { + serial: []byte, + signature_algorithm: Signature_Algorithm, + issuer: []DN_Attribute, + not_before: time.Time, + not_after: time.Time, + subject: []DN_Attribute, + public_key: Public_Key, + extensions: [][]byte, +} + +// marshal_tbs_certificate encodes a v3 TBSCertificate (RFC 5280 section 4.1), +// the to-be-signed portion of a certificate. Sign the returned bytes and pass +// them with the signature to marshal_certificate. The slice is the caller's to +// free; the inputs need not outlive the call. +@(require_results) +marshal_tbs_certificate :: proc(tbs: TBS_Certificate, allocator := context.allocator) -> (der: []byte, err: Error) { + issuer_dn := marshal_dn(tbs.issuer, allocator) or_return + defer delete(issuer_dn, allocator) + subject_dn := marshal_dn(tbs.subject, allocator) or_return + defer delete(subject_dn, allocator) + spki := marshal_spki(tbs.public_key, allocator) or_return + defer delete(spki, allocator) + sig_alg := _marshal_alg_id(tbs.signature_algorithm, allocator) or_return + defer delete(sig_alg, allocator) + ext_field := _marshal_extensions_field(tbs.extensions, allocator) or_return + defer delete(ext_field, allocator) + + // raw() splices the independently-marshalled pieces in place; raw(nil) for + // an absent extensions field contributes nothing. + out, merr := asn1.marshal( + asn1.sequence( + { + asn1.context_explicit(0, {asn1.integer_raw(_DER_INT_V3)}), // version [0] EXPLICIT v3 + asn1.integer_unsigned(tbs.serial), // serialNumber + asn1.raw(sig_alg), // signature AlgorithmIdentifier + asn1.raw(issuer_dn), // issuer + asn1.sequence({asn1.time(tbs.not_before), asn1.time(tbs.not_after)}), // validity + asn1.raw(subject_dn), // subject + asn1.raw(spki), // subjectPublicKeyInfo + asn1.raw(ext_field), // extensions [3] (absent when empty) + }, + ), + allocator, + ) + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} + +// marshal_certificate wraps a (separately signed) TBSCertificate into a +// complete X.509 Certificate. `signature_algorithm` must match the one inside +// the TBS (RFC 5280 section 4.1.1.2). The slice is the caller's to free. +@(require_results) +marshal_certificate :: proc(tbs_der: []byte, signature_algorithm: Signature_Algorithm, signature: []byte, allocator := context.allocator) -> (der: []byte, err: Error) { + return _marshal_signed(tbs_der, signature_algorithm, signature, allocator) +} + +// Public_Key holds the subject public-key material to encode into a +// SubjectPublicKeyInfo, mirroring the fields parse() extracts onto a +// Certificate: rsa_n/rsa_e (unsigned magnitudes) for RSA; ec_point for +// ECDSA (the uncompressed point 0x04||X||Y) and Ed25519 (the 32-byte key). +Public_Key :: struct { + algorithm: Public_Key_Algorithm, + rsa_n: []byte, + rsa_e: []byte, + ec_point: []byte, +} + +// marshal_spki encodes `key` as a DER SubjectPublicKeyInfo, the inverse of +// the SPKI decoding in parse(); the returned slice is the caller's to free. +// Unknown / unsupported key algorithms yield .Unsupported_Algorithm. +@(require_results) +marshal_spki :: proc(key: Public_Key, allocator := context.allocator) -> (der: []byte, err: Error) { + out: []byte + merr: asn1.Error + switch key.algorithm { + case .RSA: + // SEQUENCE { SEQUENCE { OID rsaEncryption, NULL }, BIT STRING { RSAPublicKey } } + out, merr = asn1.marshal( + asn1.sequence( + { + asn1.sequence({asn1.object_identifier(_OID_KEY_RSA), asn1.null()}), + asn1.bit_string_wrap({asn1.sequence({asn1.integer_unsigned(key.rsa_n), asn1.integer_unsigned(key.rsa_e)})}), + }, + ), + allocator, + ) + case .ECDSA_P256, .ECDSA_P384, .ECDSA_P521: + curve_oid: []byte + #partial switch key.algorithm { + case .ECDSA_P256: + curve_oid = _OID_CURVE_P256 + case .ECDSA_P384: + curve_oid = _OID_CURVE_P384 + case .ECDSA_P521: + curve_oid = _OID_CURVE_P521 + } + // SEQUENCE { SEQUENCE { OID ecPublicKey, OID namedCurve }, BIT STRING point } + out, merr = asn1.marshal( + asn1.sequence( + { + asn1.sequence({asn1.object_identifier(_OID_KEY_EC), asn1.object_identifier(curve_oid)}), + asn1.bit_string_octets(key.ec_point), + }, + ), + allocator, + ) + case .Ed25519: + // SEQUENCE { SEQUENCE { OID Ed25519 }, BIT STRING key } (no params, RFC 8410) + out, merr = asn1.marshal( + asn1.sequence({asn1.sequence({asn1.object_identifier(_OID_ED25519)}), asn1.bit_string_octets(key.ec_point)}), + allocator, + ) + case .Unknown: + return nil, .Unsupported_Algorithm + } + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} diff --git a/core/crypto/x509/parse.odin b/core/crypto/x509/parse.odin index c50e6d5d0..26a57e968 100644 --- a/core/crypto/x509/parse.odin +++ b/core/crypto/x509/parse.odin @@ -1,6 +1,7 @@ package x509 import "core:bytes" +import "core:crypto/hash" import "core:encoding/asn1" /* @@ -121,12 +122,17 @@ parse :: proc(der: []byte, allocator := context.allocator) -> (cert: Certificate cert.raw_tbs = outer.data[tbs_start:outer.pos] // signatureAlgorithm + signatureValue. - sig_oid, _, serr := _read_algorithm_identifier(&outer) + sig_oid, sig_params, serr := _read_algorithm_identifier(&outer) if serr != .None { return {}, .Malformed } cert.signature_oid = sig_oid cert.signature_algorithm = _signature_algorithm(sig_oid) + if cert.signature_algorithm == .RSA_PSS { + if perr := _parse_pss_params(&cert, sig_params); perr != .None { + return {}, perr + } + } sig_bits, sberr := asn1.read_bit_string_octets(&outer) if sberr != .None || asn1.done(&outer) != .None { @@ -161,14 +167,18 @@ parse :: proc(der: []byte, allocator := context.allocator) -> (cert: Certificate } 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) + // signature must match the outer signatureAlgorithm per RFC 5280 section 4.1.1.2, + // the OID always, and for RSA-PSS the parameters too (they carry the digest). + tbs_sig_oid, tbs_sig_params, tserr := _read_algorithm_identifier(&tbs) if tserr != .None { return {}, .Malformed } if !bytes.equal(tbs_sig_oid, sig_oid) { return {}, .Malformed } + if cert.signature_algorithm == .RSA_PSS && !bytes.equal(tbs_sig_params, sig_params) { + return {}, .Malformed + } // issuer issuer_start := tbs.pos @@ -301,6 +311,108 @@ _signature_algorithm :: proc(oid: []byte) -> Signature_Algorithm { return .Unknown } +// _hash_from_oid maps a bare hash-algorithm OID (as it appears in an +// RSASSA-PSS AlgorithmIdentifier) to a hash.Algorithm, reporting ok=false for +// digests this package does not verify (leaving the field .Invalid so the +// verifier fails closed rather than the parser rejecting the certificate). +@(private) +_hash_from_oid :: proc(oid: []byte) -> (hash.Algorithm, bool) { + switch { + case bytes.equal(oid, _OID_HASH_SHA256): + return .SHA256, true + case bytes.equal(oid, _OID_HASH_SHA384): + return .SHA384, true + case bytes.equal(oid, _OID_HASH_SHA512): + return .SHA512, true + case bytes.equal(oid, _OID_HASH_SHA1): + return .Insecure_SHA1, true + } + return .Invalid, false +} + +// _parse_pss_params decodes RSASSA-PSS-params (RFC 4055 section 3.1) from the +// signatureAlgorithm parameters into cert.pss_*: +// SEQUENCE { hashAlgorithm [0], maskGenAlgorithm [1], saltLength [2] INTEGER, +// trailerField [3] INTEGER }, all EXPLICIT and all with defaults. +// Omitted fields take their RFC 4055 defaults (SHA-1 / MGF1-SHA-1 / salt 20). +// A structurally broken params element is .Malformed; an unrecognized digest is +// NOT an error here, the hash is left .Invalid for verify_signature to reject. +@(private) +_parse_pss_params :: proc(cert: ^Certificate, params: []byte) -> Error { + // RFC 4055 defaults (applied when a field is absent). + cert.pss_hash = .Insecure_SHA1 + cert.pss_mgf_hash = .Insecure_SHA1 + cert.pss_salt_len = 20 + if len(params) == 0 { + return .None // absent parameters: all defaults + } + + cur: asn1.Cursor + asn1.cursor_init(&cur, params) + seq, e := asn1.read_sequence(&cur) + if e != .None || asn1.done(&cur) != .None { + return .Malformed + } + + // hashAlgorithm [0] EXPLICIT AlgorithmIdentifier + if inner, present, ie := asn1.read_explicit(&seq, 0); ie != .None { + return .Malformed + } else if present { + oid, _, oe := _read_algorithm_identifier(&inner) + if oe != .None || asn1.done(&inner) != .None { + return .Malformed + } + cert.pss_hash, _ = _hash_from_oid(oid) // .Invalid when unrecognized + } + + // maskGenAlgorithm [1] EXPLICIT AlgorithmIdentifier { id-mgf1, hashAlgorithm } + if inner, present, ie := asn1.read_explicit(&seq, 1); ie != .None { + return .Malformed + } else if present { + mgf_oid, mgf_params, me := _read_algorithm_identifier(&inner) + if me != .None || asn1.done(&inner) != .None { + return .Malformed + } + if !bytes.equal(mgf_oid, _OID_MGF1) { + cert.pss_mgf_hash = .Invalid // an MGF other than MGF1: unverifiable here + } else { + mp: asn1.Cursor + asn1.cursor_init(&mp, mgf_params) + hoid, _, he := _read_algorithm_identifier(&mp) + if he != .None || asn1.done(&mp) != .None { + return .Malformed + } + cert.pss_mgf_hash, _ = _hash_from_oid(hoid) + } + } + + // saltLength [2] EXPLICIT INTEGER + if inner, present, ie := asn1.read_explicit(&seq, 2); ie != .None { + return .Malformed + } else if present { + sl, se := asn1.read_i64(&inner) + if se != .None || asn1.done(&inner) != .None || sl < 0 { + return .Malformed + } + cert.pss_salt_len = int(sl) + } + + // trailerField [3] EXPLICIT INTEGER, only trailerFieldBC (1) is defined. + if inner, present, ie := asn1.read_explicit(&seq, 3); ie != .None { + return .Malformed + } else if present { + tf, te := asn1.read_i64(&inner) + if te != .None || asn1.done(&inner) != .None || tf != 1 { + return .Malformed + } + } + + if asn1.done(&seq) != .None { + return .Malformed + } + return .None +} + @(private) _parse_spki :: proc(cert: ^Certificate, spki: ^asn1.Cursor) -> Error { key_oid, key_params, aerr := _read_algorithm_identifier(spki) diff --git a/core/crypto/x509/pkcs8.odin b/core/crypto/x509/pkcs8.odin new file mode 100644 index 000000000..a097fe40b --- /dev/null +++ b/core/crypto/x509/pkcs8.odin @@ -0,0 +1,85 @@ +package x509 + +import "core:encoding/asn1" + +// Private_Key holds the RAW key material to serialize as a PKCS#8 +// PrivateKeyInfo (RFC 5208 / 5958). It is the bytes the crypto packages expose +// (ecdsa.private_key_bytes / public_key_bytes, ed25519.private_key_bytes), +// which marshal_pkcs8 only assembles into DER. For ECDSA, `private` is the +// secret scalar and `public` (optional) the uncompressed point; for Ed25519, +// `private` is the 32-byte seed and `public` is ignored. +Private_Key :: struct { + algorithm: Public_Key_Algorithm, + private: []byte, + public: []byte, +} + +@(rodata, private) +_DER_INT_ONE := []byte{0x01} + +// Serializes `key` as a DER PKCS#8 PrivateKeyInfo. The crypto has already +// happened; this only nests the raw bytes. RSA and unknown algorithms +// yield .Unsupported_Algorithm (RSA is out of scope here). The returned +// slice is the caller's to free. +@(require_results) +marshal_pkcs8 :: proc(key: Private_Key, allocator := context.allocator) -> (der: []byte, err: Error) { + out: []byte + merr: asn1.Error + switch key.algorithm { + case .ECDSA_P256, .ECDSA_P384, .ECDSA_P521: + curve_oid: []byte + #partial switch key.algorithm { + case .ECDSA_P256: + curve_oid = _OID_CURVE_P256 + case .ECDSA_P384: + curve_oid = _OID_CURVE_P384 + case .ECDSA_P521: + curve_oid = _OID_CURVE_P521 + } + // ECPrivateKey ::= SEQUENCE { version(1), privateKey OCTET STRING, [1] publicKey BIT STRING OPTIONAL } + // (parameters [0] is omitted: the curve is in privateKeyAlgorithm.) + ec_fields: [3]asn1.Value + pub_wrap: [1]asn1.Value // stable backing for the [1] EXPLICIT child + n := 0 + ec_fields[n] = asn1.integer_raw(_DER_INT_ONE) + n += 1 + ec_fields[n] = asn1.octet_string(key.private) + n += 1 + if len(key.public) > 0 { + pub_wrap[0] = asn1.bit_string_octets(key.public) + ec_fields[n] = asn1.context_explicit(1, pub_wrap[:]) + n += 1 + } + // PrivateKeyInfo ::= SEQUENCE { version(0), AlgId{ecPublicKey, curve}, + // privateKey OCTET STRING { ECPrivateKey } } + out, merr = asn1.marshal( + asn1.sequence( + { + asn1.integer_raw(_DER_INT_ZERO), + asn1.sequence({asn1.object_identifier(_OID_KEY_EC), asn1.object_identifier(curve_oid)}), + asn1.octet_string_wrap({asn1.sequence(ec_fields[:n])}), + }, + ), + allocator, + ) + case .Ed25519: + // PrivateKeyInfo ::= SEQUENCE { version(0), AlgId{Ed25519}, + // privateKey OCTET STRING { CurvePrivateKey ::= OCTET STRING seed } } + out, merr = asn1.marshal( + asn1.sequence( + { + asn1.integer_raw(_DER_INT_ZERO), + asn1.sequence({asn1.object_identifier(_OID_ED25519)}), + asn1.octet_string_wrap({asn1.octet_string(key.private)}), + }, + ), + allocator, + ) + case .RSA, .Unknown: + return nil, .Unsupported_Algorithm + } + if merr != .None { + return nil, .Allocation_Failed + } + return out, .None +} diff --git a/core/crypto/x509/verify.odin b/core/crypto/x509/verify.odin index ca667d790..ead1fbe1a 100644 --- a/core/crypto/x509/verify.odin +++ b/core/crypto/x509/verify.odin @@ -4,6 +4,7 @@ import "core:bytes" import "core:crypto/ecdsa" import "core:crypto/ed25519" import "core:crypto/hash" +import "core:crypto/rsa" import "core:net" import "core:strings" import "core:time" @@ -107,18 +108,51 @@ _match_hostname :: proc(pattern, host: string) -> bool { // // 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 — +// 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. +// and .Unsupported_Algorithm when the signature algorithm (ECDSA P-521, or +// RSA-PSS with an unrecognized digest) 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 .RSA_SHA1, .RSA_SHA256, .RSA_SHA384, .RSA_SHA512: + if issuer.public_key_algorithm != .RSA { + // An RSA signature paired with a non-RSA issuer key can never verify. + return .Unsupported_Algorithm + } + pub: rsa.Public_Key + defer rsa.public_key_clear(&pub) + if !rsa.public_key_set_bytes(&pub, issuer.rsa_n, issuer.rsa_e) { + return .Signature_Invalid + } + h := _hash_for_rsa(cert.signature_algorithm) + if !rsa.verify_pkcs1(&pub, h, cert.raw_tbs, cert.signature) { + return .Signature_Invalid + } + return .None + + case .RSA_PSS: + if issuer.public_key_algorithm != .RSA { + return .Unsupported_Algorithm + } + // The parser decodes the RSASSA-PSS-params into cert.pss_*, leaving a + // digest it cannot verify as .Invalid; refuse rather than guess. + if cert.pss_hash == .Invalid || cert.pss_mgf_hash == .Invalid { + return .Unsupported_Algorithm + } + pub: rsa.Public_Key + defer rsa.public_key_clear(&pub) + if !rsa.public_key_set_bytes(&pub, issuer.rsa_n, issuer.rsa_e) { + return .Signature_Invalid + } + if !rsa.verify_pss(&pub, cert.pss_hash, cert.pss_salt_len, cert.raw_tbs, cert.signature, mgf1_algo = cert.pss_mgf_hash) { + return .Signature_Invalid + } + return .None case .ECDSA_SHA256, .ECDSA_SHA384, .ECDSA_SHA512: curve: ecdsa.Curve @@ -170,6 +204,21 @@ _hash_for_ecdsa :: proc "contextless" (s: Signature_Algorithm) -> hash.Algorithm return .SHA256 } +// _hash_for_rsa maps an RSA PKCS#1 v1.5 signature algorithm to its message +// digest. SHA-1 (obsolete, legacy verification only) maps to .Insecure_SHA1. +@(private) +_hash_for_rsa :: proc "contextless" (s: Signature_Algorithm) -> hash.Algorithm { + #partial switch s { + case .RSA_SHA1: + return .Insecure_SHA1 + case .RSA_SHA384: + return .SHA384 + case .RSA_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 @@ -221,11 +270,11 @@ _MAX_SIG_CHECKS :: 100 // 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 treated as trusted input, as in Go and OpenSSL: it -// must be valid at opts.current_time and is name-chained + signature-checked -// as the issuer below it, but its CA authorization (basicConstraints / -// keyCertSign / pathLenConstraint) and its own self-signature are NOT -// re-checked. An expired anchor is still rejected; resilience to that comes +// The trust anchor is treated as trusted input: it must be valid at +// opts.current_time and is name-chained + signature-checked as the issuer +// below it, but its CA authorization (basicConstraints / keyCertSign / +// pathLenConstraint) and its own self-signature are NOT re-checked. +// An expired anchor is still rejected; resilience to that comes // from the search trying every other available anchor and intermediate. // @(require_results) @@ -244,21 +293,18 @@ verify_chain :: proc( 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 - } + // A leaf signed with an algorithm the verifier cannot check (RSA-PSS with + // an unrecognized digest, ECDSA P-521) surfaces via `saw_unsupported` from + // the per-edge verify_signature call, so an unbuildable path still returns + // .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 + // 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 @@ -333,8 +379,7 @@ _build_to_anchor :: proc( // Prefer terminating at a trust anchor. The anchor must have issued `cert` // (name chaining + signature) and be valid at `now`, but as TRUSTED INPUT // its CA authorization (basicConstraints / keyCertSign / pathLenConstraint) - // and its own self-signature are NOT re-checked, matching how Go and - // OpenSSL treat roots. See _anchor_usable. + // and its own self-signature are NOT re-checked. See _anchor_usable. for root in opts.roots { if !_is_issuer_of(root, cert) || !_anchor_usable(root, opts.current_time) { continue @@ -430,9 +475,9 @@ _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 + // Name constraints are NOT decoded. RFC 5280 section 6.1.4(g) requires a + // validator that processes them to enforce them regardless of criticality; + // until that is implemented, refuse any issuer that asserts them. if _has_extension(issuer, _OID_EXT_NAME_CONSTRAINTS) { return false } @@ -451,12 +496,11 @@ _issuer_usable :: proc(issuer: ^Certificate, now: time.Time, below: int) -> bool return true } -// The anchor is trusted input, so unlike _issuer_usable its CA authorization -// (basicConstraints / keyCertSign / pathLenConstraint) and self-signature are -// NOT re-checked, matching Go and OpenSSL's treatment of roots. Still required: -// valid at `now` (Go and OpenSSL both enforce this; resilience to an expired -// anchor comes from the search trying other anchors/intermediates), no -// uninterpreted critical extension, and no name constraints (which we cannot +// The anchor is trusted input, so unlike _issuer_usable its CA authorization +// (basicConstraints / keyCertSign / pathLenConstraint) and self-signature are +// NOT re-checked. Still required: valid at `now` (resilience to an expired +// anchor comes from the search trying other anchors/intermediates), no +// uninterpreted critical extension, and no name constraints (which we cannot // enforce, so refuse rather than ignore). @(private) _anchor_usable :: proc(anchor: ^Certificate, now: time.Time) -> bool { diff --git a/core/crypto/x509/x509.odin b/core/crypto/x509/x509.odin index 242c2bb38..31911cc2f 100644 --- a/core/crypto/x509/x509.odin +++ b/core/crypto/x509/x509.odin @@ -1,5 +1,6 @@ package x509 +import "core:crypto/hash" import "core:time" Error :: enum { @@ -24,8 +25,8 @@ Error :: enum { } // 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. +// encounters in practice. RSASSA-PSS parameters are decoded into the +// Certificate's pss_* fields. Signature_Algorithm :: enum { Unknown, RSA_SHA1, // obsolete; parsed for identification only @@ -103,7 +104,7 @@ Certificate :: struct { 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 + // bit is set carries a leading 0x00 sign octet; 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, @@ -122,6 +123,16 @@ Certificate :: struct { // ECDSA: the uncompressed point (0x04 || X || Y); Ed25519: the 32-byte key. ec_point: []byte, + // RSASSA-PSS parameters, decoded from the signatureAlgorithm's + // RSASSA-PSS-params (meaningful only when signature_algorithm == + // .RSA_PSS; RFC 4055 defaults applied for omitted fields). pss_hash and + // pss_mgf_hash are hash.Algorithm.Invalid when the certificate names a + // digest this package cannot verify, which verify_signature reports as + // .Unsupported_Algorithm rather than a failure. + pss_hash: hash.Algorithm, + pss_mgf_hash: hash.Algorithm, + pss_salt_len: int, + // BasicConstraints (basic_constraints_valid reports presence). basic_constraints_valid: bool, is_ca: bool, @@ -178,6 +189,21 @@ _OID_SIG_ECDSA_SHA512 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x04} @(rodata, private) _OID_ED25519 := []byte{0x2B, 0x65, 0x70} // id-Ed25519 (1.3.101.112), RFC 8410 +@(rodata, private) +_OID_EXT_REQUEST := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x0E} // id-extensionRequest (1.2.840.113549.1.9.14), PKCS#9 + +// Bare hash OIDs (content octets), for the RSASSA-PSS AlgorithmIdentifiers. +@(rodata, private) +_OID_HASH_SHA1 := []byte{0x2B, 0x0E, 0x03, 0x02, 0x1A} // id-sha1 (1.3.14.3.2.26) +@(rodata, private) +_OID_HASH_SHA256 := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01} // id-sha256 (2.16.840.1.101.3.4.2.1) +@(rodata, private) +_OID_HASH_SHA384 := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02} // id-sha384 (2.16.840.1.101.3.4.2.2) +@(rodata, private) +_OID_HASH_SHA512 := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03} // id-sha512 (2.16.840.1.101.3.4.2.3) +@(rodata, private) +_OID_MGF1 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x08} // id-mgf1 (1.2.840.113549.1.1.8), RFC 4055 + @(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) diff --git a/core/encoding/asn1/cursor.odin b/core/encoding/asn1/cursor.odin index 508d3ff3d..bfc950454 100644 --- a/core/encoding/asn1/cursor.odin +++ b/core/encoding/asn1/cursor.odin @@ -165,9 +165,8 @@ read_i64 :: proc "contextless" (r: ^Cursor) -> (value: i64, err: Error) { 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. +// Reads a non-negative INTEGER and returns its magnitude octets with any leading 0x00 sign +// octet stripped. read_unsigned_integer_bytes :: proc "contextless" (r: ^Cursor) -> (magnitude: []byte, err: Error) { content, ierr := read_integer_bytes(r) if ierr != .None { @@ -210,8 +209,7 @@ read_bit_string :: proc "contextless" (r: ^Cursor) -> (bits: []byte, unused: int 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. +// Reads a BIT STRING that must be a whole number of octets (unused == 0). read_bit_string_octets :: proc "contextless" (r: ^Cursor) -> (octets: []byte, err: Error) { bits, unused, berr := read_bit_string(r) if berr != .None { @@ -402,9 +400,8 @@ oid_to_string :: proc(raw: []byte, allocator := context.allocator) -> (str: stri 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). + // Builder writes swallow allocator failures, so tally the written vs expected lengths + // and treat any shortfall as an allocation failure. written, expected := 0, 0 acc: u64 = 0 first := true @@ -569,11 +566,9 @@ _two_digits :: proc "contextless" (b: []byte) -> (value: int, ok: bool) { 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". +// Converts Unix seconds to a time.Time, saturating at time.Time's i64-nanosecond +// bounds (near year 2262) rather than overflowing. See the note above +// read_utc_time for why far-future dates are saturated instead of rejected. @(private) _time_from_unix :: proc "contextless" (secs: i64) -> dt.Time { NS_PER_SEC :: i64(1_000_000_000) diff --git a/core/encoding/asn1/writer.odin b/core/encoding/asn1/writer.odin index f34a37234..1c0f2f3f4 100644 --- a/core/encoding/asn1/writer.odin +++ b/core/encoding/asn1/writer.odin @@ -9,8 +9,7 @@ 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. +children (DER SET OF ordering). 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 @@ -23,7 +22,7 @@ its children with a slice/array that outlives the encode call: 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. +covers all of PKIX; high-tag-number form is not supported. See: - [[ https://www.itu.int/rec/T-REC-X.690 ]] @@ -81,10 +80,8 @@ boolean :: proc "contextless" (v: bool) -> Value { // 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. +// inserted when the top bit would otherwise read as negative. An empty +// or all-zero magnitude encodes as 0. The inverse of read_unsigned_integer_bytes. integer_unsigned :: proc "contextless" (magnitude: []byte) -> Value { return Value{tag = universal(.Integer), form = .Integer_Magnitude, content = magnitude} } @@ -321,9 +318,7 @@ _content_len :: proc(v: Value) -> int { // // 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. +// single O(n) sizing pass, this does the single O(n) write pass. @(private) _emit :: proc(v: Value, dst: []byte) -> int { if v.form == .Raw { @@ -372,7 +367,7 @@ _emit :: proc(v: Value, dst: []byte) -> int { end -= _emit(v.children[i], dst[:end]) } } - clen := len(dst) - end // content length, read off the cursor — never recomputed + 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) diff --git a/tests/core/crypto/x509/fuzz_x509.odin b/tests/core/crypto/x509/fuzz_x509.odin index 340791869..1865b7658 100644 --- a/tests/core/crypto/x509/fuzz_x509.odin +++ b/tests/core/crypto/x509/fuzz_x509.odin @@ -72,7 +72,7 @@ test_fuzz_bitflips :: proc(t: ^testing.T) { // Random multi-byte mutations across all three fixtures. @(test) test_fuzz_mutations :: proc(t: ^testing.T) { - fixtures := [?][]byte{RSA_DER, EC_DER, ED_DER} + fixtures := [?][]byte{RSA_DER, PSS_DER, EC_DER, ED_DER} max_len := 0 for f in fixtures { @@ -86,7 +86,7 @@ test_fuzz_mutations :: proc(t: ^testing.T) { input := buf[:len(fixture)] copy(input, fixture) - // Mutate 1-16 positions, occasionally truncating instead — + // 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)] diff --git a/tests/core/crypto/x509/test_core_x509.odin b/tests/core/crypto/x509/test_core_x509.odin index 413e34ae9..98458b712 100644 --- a/tests/core/crypto/x509/test_core_x509.odin +++ b/tests/core/crypto/x509/test_core_x509.odin @@ -19,6 +19,7 @@ package test_core_x509 // Field expectations below were cross-checked against // `openssl x509 -noout -text`. +import "core:crypto/hash" import "core:crypto/x509" import "core:testing" import "core:time" @@ -27,6 +28,12 @@ RSA_DER := #load("testdata/rsa.der") EC_DER := #load("testdata/ec.der") ED_DER := #load("testdata/ed.der") +// Self-signed RSA-PSS CA (SHA-256, MGF1-SHA-256, salt 32), generated with: +// openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 \ +// -subj "/CN=pss.rsa.test" -sigopt rsa_padding_mode:pss \ +// -sigopt rsa_pss_saltlen:32 -addext "basicConstraints=critical,CA:TRUE" +PSS_DER := #load("testdata/pss.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 @@ -335,14 +342,33 @@ test_verify_signature_ed :: proc(t: ^testing.T) { 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. +// RSA PKCS#1 v1.5 verification is wired through to core:crypto/rsa. rsa.der is +// a self-signed RSA-SHA256 CA, so it is its own issuer and its signature must +// verify; pairing an RSA-signed cert with a non-RSA issuer key is unsupported. @(test) -test_verify_signature_rsa_unsupported :: proc(t: ^testing.T) { - rsa, err := x509.parse(RSA_DER); defer x509.destroy(&rsa) +test_verify_signature_rsa :: proc(t: ^testing.T) { + rsa, e1 := x509.parse(RSA_DER); defer x509.destroy(&rsa) + ec, e2 := x509.parse(EC_DER); defer x509.destroy(&ec) + testing.expect_value(t, e1, x509.Error.None) + testing.expect_value(t, e2, x509.Error.None) + testing.expect_value(t, x509.verify_signature(&rsa, &rsa), x509.Error.None) + testing.expect_value(t, x509.verify_signature(&rsa, &ec), x509.Error.Unsupported_Algorithm) +} + +// RSA-PSS end-to-end: the parser decodes RSASSA-PSS-params (hash, MGF, salt) +// off the signatureAlgorithm, and verify_signature feeds them to +// rsa.verify_pss. pss.der is a self-signed RSA-PSS CA, so it is its own issuer. +@(test) +test_verify_signature_rsa_pss :: proc(t: ^testing.T) { + pss, err := x509.parse(PSS_DER); defer x509.destroy(&pss) testing.expect_value(t, err, x509.Error.None) - testing.expect_value(t, x509.verify_signature(&rsa, &rsa), x509.Error.Unsupported_Algorithm) + testing.expect_value(t, pss.signature_algorithm, x509.Signature_Algorithm.RSA_PSS) + // Decoded parameters (SHA-256, MGF1-SHA-256, salt length 32). + testing.expect_value(t, pss.pss_hash, hash.Algorithm.SHA256) + testing.expect_value(t, pss.pss_mgf_hash, hash.Algorithm.SHA256) + testing.expect_value(t, pss.pss_salt_len, 32) + // The PSS signature verifies against the embedded key. + testing.expect_value(t, x509.verify_signature(&pss, &pss), x509.Error.None) } @(test) @@ -573,14 +599,16 @@ test_verify_chain_cross_signed_cycle :: proc(t: ^testing.T) { testing.expect_value(t, err, x509.Error.Unknown_Authority) } -// An RSA-signed leaf surfaces the RSA gap directly. +// End-to-end: the self-signed RSA-SHA256 CA validates as a chain with itself +// as the trust anchor — the RSA PKCS#1 v1.5 signature check now runs. @(test) -test_verify_chain_rsa_unsupported :: proc(t: ^testing.T) { +test_verify_chain_rsa_selfsigned :: 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) + c, verr := x509.verify_chain(&rsa, opts); defer delete(c) + testing.expect_value(t, verr, x509.Error.None) + testing.expect(t, len(c) >= 1, "RSA self-signed chain built") } // Each negative chain must be rejected for the right structural reason diff --git a/tests/core/crypto/x509/testdata/pss.der b/tests/core/crypto/x509/testdata/pss.der new file mode 100644 index 0000000000000000000000000000000000000000..0c3cb5e305ada4ca3c8573875df16dac38f2afab GIT binary patch literal 891 zcmXqLVlFpmV$xo~%*4pVB*OA6_=BN1huX7+U;eqgRSPMXH4ZRvWaHFo^Jx3d%gD%O zV6uSUfR~Lkq0NIam6?T!k(FVgjDZwF1qYh!B4#E=1p{$IQ3GK%=1>-99-e~YV!fi` zM7@&K;t~Tnab6=c19L+oLkmL-Q^P26USl%@a|25#7q`YHMkSC*0AF)}hdb28s|YIf}AnY`~}qNWDz^#8f_v#VDBmXxcClRaiO$mIv`ZdekauWHY+ z<5=dOCh1Lk*K^GN?YjHd{2$tvIlgAf1@Nd;ES#lzyWiP=qP^~;3&D%xJpZmrUYRl} zeYFp3`rfmJ!r7-?VxC4HJ{B%*QS2XHxvcME3e#^T{sU}R-hDnSzF*_XzJTA;?JrHv zt?!ckx5}lA?M7kF$yG00W<}a2ELHg#w(egEw^qDi$FG@_W@h=d|K9x0=wkS}rzQMi zs?F;KW9Q#_VDA4+cJ?XfiBH$2J$l~K_RMMa%V(bzj!sIP8U4hdbwYxJZ{^e#ZyC#3 z9k!3Kc_vTV$uvV1IJEFznpHNFY(WsJ*TIgeSc zHo_!#+v|r0@*rtt76}8f2J8y>K?;N!8UM4e8Za|5{-;8EUAxpMx8!V8e)%~j_LX_U z>qb+d#+g^c_f@1E4*C;);O1w))bBf9Op|DQayCxzbIPM9bMDAFGXAWbrRz|-Fn#`p zJAc~6)=WwHG(lK*^V$Ewkpefg3++X9truH7T50)1V9L1&+ndkJjFnBo`A!yUa{~Y} C?^$I4 literal 0 HcmV?d00001 From df044411d9d3a60fa28117ce5fbe7c122d3cf62f Mon Sep 17 00:00:00 2001 From: kalsprite Date: Sun, 12 Jul 2026 12:58:45 -0700 Subject: [PATCH 5/7] attach nc --- core/crypto/x509/doc.odin | 22 +- core/crypto/x509/name_constraints.odin | 365 ++++++++++++++++++ core/crypto/x509/parse.odin | 5 + core/crypto/x509/verify.odin | 34 +- tests/core/crypto/x509/test_core_x509.odin | 79 +++- .../crypto/x509/testdata/nc_dir_inter.der | Bin 0 -> 458 bytes .../crypto/x509/testdata/nc_excl_inter.der | Bin 0 -> 471 bytes tests/core/crypto/x509/testdata/nc_inter.der | Bin 0 -> 461 bytes .../core/crypto/x509/testdata/nc_leaf_bad.der | Bin 0 -> 433 bytes .../core/crypto/x509/testdata/nc_leaf_dir.der | Bin 0 -> 436 bytes .../core/crypto/x509/testdata/nc_leaf_ok.der | Bin 0 -> 443 bytes .../crypto/x509/testdata/nc_leaf_wild.der | Bin 0 -> 431 bytes tests/core/crypto/x509/testdata/nc_root.der | Bin 0 -> 407 bytes 13 files changed, 461 insertions(+), 44 deletions(-) create mode 100644 core/crypto/x509/name_constraints.odin create mode 100644 tests/core/crypto/x509/testdata/nc_dir_inter.der create mode 100644 tests/core/crypto/x509/testdata/nc_excl_inter.der create mode 100644 tests/core/crypto/x509/testdata/nc_inter.der create mode 100644 tests/core/crypto/x509/testdata/nc_leaf_bad.der create mode 100644 tests/core/crypto/x509/testdata/nc_leaf_dir.der create mode 100644 tests/core/crypto/x509/testdata/nc_leaf_ok.der create mode 100644 tests/core/crypto/x509/testdata/nc_leaf_wild.der create mode 100644 tests/core/crypto/x509/testdata/nc_root.der diff --git a/core/crypto/x509/doc.odin b/core/crypto/x509/doc.odin index 11a9236c5..718eb57d4 100644 --- a/core/crypto/x509/doc.odin +++ b/core/crypto/x509/doc.odin @@ -35,13 +35,15 @@ LIMITATIONS: (SHA-1/256/384/512), ECDSA P-256/P-384, and Ed25519. These paths return .Unsupported_Algorithm: ECDSA P-521 (effectively dead in web PKI), and RSA-PSS naming a digest or MGF this package does not recognize. - - Name constraints are NOT decoded; verify_chain fails CLOSED on them. - Any CA (intermediate or trust anchor) asserting a nameConstraints - extension, critical or not, is refused as an issuer, so a chain through - a name-constrained CA is rejected rather than accepted unchecked. RFC - 5280 section 6.1.4(g) requires a validator that processes name - constraints to enforce them regardless of criticality; until that is - implemented, refusing is the only safe option. + - Name constraints (RFC 5280 4.2.1.10) are enforced for the dNSName and + iPAddress forms: a CA's permitted/excluded subtrees are checked against + every subordinate certificate's SANs, regardless of the extension's + criticality. A NameConstraints that uses any other base form + (directoryName, rfc822Name, URI, otherName), a minimum/maximum, or that + is malformed cannot be fully evaluated, so the whole chain is rejected + (fail closed) rather than accepted unchecked. NOT enforced: the RFC 5280 + rule that the extension be critical, and dNSName syntax validation (a + leading-period constraint is accepted, as OpenSSL does). - 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, …). @@ -67,9 +69,9 @@ duplicate extension OIDs (Duplicate_Extension, RFC 5280 section 4.2). 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/Authority Key Identifier; NameConstraints is decoded at + verification time). All others (AIA, CRL distribution points, + certificate policies, …) 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. diff --git a/core/crypto/x509/name_constraints.odin b/core/crypto/x509/name_constraints.odin new file mode 100644 index 000000000..009928324 --- /dev/null +++ b/core/crypto/x509/name_constraints.odin @@ -0,0 +1,365 @@ +package x509 + +import "core:bytes" +import "core:encoding/asn1" + +// Name-constraint processing (RFC 5280 section 4.2.1.10 + the section 6.1.4 +// path-validation checks), scoped to the dNSName and iPAddress GeneralName +// forms. A NameConstraints extension that uses any other base form +// (directoryName, rfc822Name, uniformResourceIdentifier, otherName, …), a +// non-zero minimum, a maximum, or that fails to decode causes the whole chain +// to be rejected (fail closed): we never accept a constraint we cannot fully +// evaluate. + +// GeneralName context tags used in NameConstraints (X.509 GeneralName CHOICE). +@(private) +_GN_OTHER_NAME :: 0 // otherName [0] (constructed) +@(private) +_GN_RFC822 :: 1 // rfc822Name [1] +@(private) +_GN_DNS :: 2 // dNSName [2] +@(private) +_GN_DIR :: 4 // directoryName [4] (constructed) +@(private) +_GN_URI :: 6 // uniformResourceIdentifier [6] +@(private) +_GN_IP :: 7 // iPAddress [7] + +// _check_name_constraints enforces every NameConstraints extension in `chain` +// (leaf at index 0, trust anchor last). Each CA's constraints apply to every +// certificate below it; checking each CA independently against each +// subordinate yields the RFC 5280 permitted=intersection / excluded=union +// semantics without accumulator state. Returns true when the chain is +// acceptable, false when a name is forbidden (or a constraint cannot be +// evaluated, in which case we reject rather than guess). +@(private) +_check_name_constraints :: proc(chain: []^Certificate) -> bool { + // RFC 5280 4.2.1.10: NameConstraints MUST appear only in a CA certificate. + // A non-CA end entity that carries it is malformed — reject the chain. + if _, leaf_has := _find_name_constraints(chain[0]); leaf_has && !chain[0].is_ca { + return false + } + // ci walks the CAs (anchor down to the first intermediate above the leaf); + // the leaf (index 0) never constrains. + for ci := len(chain) - 1; ci >= 1; ci -= 1 { + nc, has := _find_name_constraints(chain[ci]) + if !has { + continue + } + if !_nc_decidable(nc) { + return false // a form/feature we cannot evaluate: fail closed + } + for sub := ci - 1; sub >= 0; sub -= 1 { + c := chain[sub] + // RFC 5280 section 6.1.4(b): constraints are not applied to a + // self-issued intermediate, only to the final (leaf) certificate. + if sub != 0 && bytes.equal(c.raw_subject, c.raw_issuer) { + continue + } + if !_names_permitted(nc, c) { + return false + } + } + } + return true +} + +// _find_name_constraints returns the raw extnValue DER of the cert's +// NameConstraints extension, if present. +@(private) +_find_name_constraints :: proc(cert: ^Certificate) -> (der: []byte, ok: bool) { + for ext in cert.extensions { + if bytes.equal(ext.oid, _OID_EXT_NAME_CONSTRAINTS) { + return ext.value, true + } + } + return nil, false +} + +// _names_permitted checks a subordinate certificate's dNSName and iPAddress +// SANs against one CA's NameConstraints: each name form, when the CA lists +// permitted subtrees for it, must match at least one; and no name may match an +// excluded subtree. +@(private) +_names_permitted :: proc(nc: []byte, sub: ^Certificate) -> bool { + for dns in sub.dns_names { + if _nc_section_has(nc, false, _GN_DNS) && !_nc_dns_match(nc, false, dns) { + return false + } + if _nc_dns_match(nc, true, dns) { + return false + } + } + for ip in sub.ip_addresses { + if _nc_section_has(nc, false, _GN_IP) && !_nc_ip_match(nc, false, ip) { + return false + } + if _nc_ip_match(nc, true, ip) { + return false + } + } + return true +} + +// _nc_decidable structurally validates a NameConstraints and reports whether we +// can fully evaluate it. It returns false, so the caller fails closed, when the +// extension is malformed (an element other than permittedSubtrees [0] / +// excludedSubtrees [1], neither section present, or an empty GeneralSubtrees, +// which SIZE (1..MAX) forbids), or when a subtree uses a base form other than +// dNSName / iPAddress or carries a minimum/maximum (barred by the RFC 5280 +// profile). Only a NameConstraints whose every subtree is a bare +// dNSName/iPAddress is decidable. +@(private) +_nc_decidable :: proc(nc: []byte) -> bool { + cur: asn1.Cursor + asn1.cursor_init(&cur, nc) + seq, e := asn1.read_sequence(&cur) + if e != .None || asn1.done(&cur) != .None { + return false + } + sections := 0 + for !asn1.is_empty(&seq) { + tag, content, re := asn1.read_any(&seq) + if re != .None { + return false + } + // The only permitted members are permittedSubtrees [0] / excludedSubtrees + // [1]; a NULL or any other element (CABF 7.1.2.5.2) is malformed. + if tag.class != .Context_Specific || (tag.number != 0 && tag.number != 1) { + return false + } + sections += 1 + if !_nc_section_wellformed(content) { + return false + } + } + return sections > 0 +} + +// _nc_section_wellformed checks one GeneralSubtrees body: at least one subtree +// (SIZE (1..MAX)), every base a bare dNSName/iPAddress, no minimum/maximum. +@(private) +_nc_section_wellformed :: proc(content: []byte) -> bool { + cur: asn1.Cursor + asn1.cursor_init(&cur, content) + count := 0 + for !asn1.is_empty(&cur) { + gs, e := asn1.read_sequence(&cur) + if e != .None { + return false + } + count += 1 + tag, _, be := asn1.read_any(&gs) + if be != .None || tag.class != .Context_Specific { + return false + } + if tag.number != _GN_DNS && tag.number != _GN_IP { + return false // directoryName / rfc822 / URI / otherName / … + } + // minimum [0] / maximum [1] must both be absent (RFC 5280 profile). + if asn1.done(&gs) != .None { + return false + } + } + return count > 0 +} + +// _nc_section returns the raw content octets of the permitted [0] (or excluded +// [1]) GeneralSubtrees, i.e. the concatenation of GeneralSubtree elements. +@(private) +_nc_section :: proc(nc: []byte, excluded: bool) -> (content: []byte, present: bool) { + want := u32(excluded ? 1 : 0) + cur: asn1.Cursor + asn1.cursor_init(&cur, nc) + seq, e := asn1.read_sequence(&cur) + if e != .None { + return nil, false + } + for !asn1.is_empty(&seq) { + tag, c, re := asn1.read_any(&seq) + if re != .None { + return nil, false + } + if tag.class == .Context_Specific && tag.number == want { + return c, true + } + } + return nil, false +} + +// _nc_section_has reports whether the given section contains at least one +// subtree whose base is `form`. +@(private) +_nc_section_has :: proc(nc: []byte, excluded: bool, form: u32) -> bool { + content, present := _nc_section(nc, excluded) + if !present { + return false + } + cur: asn1.Cursor + asn1.cursor_init(&cur, content) + for !asn1.is_empty(&cur) { + gs, e := asn1.read_sequence(&cur) + if e != .None { + return false + } + tag, _, be := asn1.read_any(&gs) + if be != .None { + return false + } + if tag.class == .Context_Specific && tag.number == form { + return true + } + } + return false +} + +// _nc_dns_match reports whether `name` matches any dNSName subtree in the +// given section. +@(private) +_nc_dns_match :: proc(nc: []byte, excluded: bool, name: string) -> bool { + content, present := _nc_section(nc, excluded) + if !present { + return false + } + cur: asn1.Cursor + asn1.cursor_init(&cur, content) + for !asn1.is_empty(&cur) { + gs, e := asn1.read_sequence(&cur) + if e != .None { + return false + } + tag, base, be := asn1.read_any(&gs) + if be != .None { + return false + } + if tag.class == .Context_Specific && tag.number == _GN_DNS { + c := string(base) + // A wildcard SAN "*.B" stands for every "