diff --git a/core/crypto/x509/name_constraints.odin b/core/crypto/x509/name_constraints.odin index 009928324..bb1d2dcfa 100644 --- a/core/crypto/x509/name_constraints.odin +++ b/core/crypto/x509/name_constraints.odin @@ -2,6 +2,7 @@ package x509 import "core:bytes" import "core:encoding/asn1" +import "core:strings" // 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 @@ -242,10 +243,10 @@ _nc_dns_match :: proc(nc: []byte, excluded: bool, name: string) -> bool { if excluded { // Fail closed: exclude the wildcard if its subtree overlaps // the constraint at all (constraint covers b, or b covers it). - if _dns_in_constraint(c, b) || _dns_in_constraint(b, _strip_dot(c)) { + if _dns_in_constraint(c, b) || _dns_in_constraint(b, strings.trim_prefix(c, ".")) { return true } - } else if _dns_in_constraint(_strip_dot(c), b) { + } else if _dns_in_constraint(strings.trim_prefix(c, "."), b) { // Permitted only if every child of b is inside the subtree. return true } @@ -257,13 +258,6 @@ _nc_dns_match :: proc(nc: []byte, excluded: bool, name: string) -> bool { return false } -// _strip_dot drops a single leading '.', normalizing a subtree constraint to a -// bare domain for the wildcard-overlap comparisons above. -@(private) -_strip_dot :: proc "contextless" (s: string) -> string { - return s[1:] if len(s) > 0 && s[0] == '.' else s -} - // _nc_ip_match reports whether `ip` (a 4- or 16-octet address) matches any // iPAddress subtree in the given section. In NameConstraints an iPAddress // base is the address followed by a mask of equal length (8 octets for IPv4, @@ -332,9 +326,13 @@ _dns_in_constraint :: proc "contextless" (constraint, name: string) -> bool { return false } +// _ascii_eq_fold / _ascii_suffix_fold are the package's DNS-name comparison +// primitives, shared with verify_hostname. dNSNames are IA5String (ASCII) and +// IDNs are carried as punycode A-labels. Do not replace with strings.equal_fold. @(private) _ascii_lower :: proc "contextless" (b: byte) -> byte { - return b + 0x20 if b >= 'A' && b <= 'Z' else b + if b >= 'A' && b <= 'Z' { return b+0x20 } + else { return b } } @(private) diff --git a/core/crypto/x509/verify.odin b/core/crypto/x509/verify.odin index 5d4594cb4..f7c7abe2d 100644 --- a/core/crypto/x509/verify.odin +++ b/core/crypto/x509/verify.odin @@ -6,6 +6,7 @@ import "core:crypto/ed25519" import "core:crypto/hash" import "core:crypto/rsa" import "core:net" +import "core:slice" import "core:strings" import "core:time" @@ -80,8 +81,10 @@ _match_hostname :: proc(pattern, host: string) -> bool { return false } + // DNS comparisons are ASCII case-insensitive (RFC 4343): dNSNames are + // IA5String and IDNs travel as punycode A-labels, so `_ascii_eq_fold`is used. if !strings.has_prefix(p, "*.") { - return strings.equal_fold(p, host) + return _ascii_eq_fold(p, host) } // Wildcard: "*." + base. The host's first label is consumed by the wildcard; the @@ -100,7 +103,7 @@ _match_hostname :: proc(pattern, host: string) -> bool { if strings.index_byte(base, '*') >= 0 { return false } - return strings.equal_fold(base, rest) + return _ascii_eq_fold(base, rest) } // ============================================================ @@ -406,7 +409,7 @@ _build_to_anchor :: proc( // Otherwise extend through an untrusted intermediate and recurse. for inter in opts.intermediates { - if inter == cert || _in_chain(acc, inter) { + if inter == cert || slice.contains(acc[:], inter) { continue } if !_is_issuer_of(inter, cert) || !_issuer_usable(inter, opts.current_time, below) { @@ -528,13 +531,3 @@ _permits_eku :: proc(cert: ^Certificate, ask: EKU_Bit) -> bool { } return ask in cert.ext_key_usage } - -@(private) -_in_chain :: proc(acc: ^[dynamic]^Certificate, cert: ^Certificate) -> bool { - for c in acc { - if c == cert { - return true - } - } - return false -} diff --git a/tests/core/assets/X509-Limbo/.gitignore b/tests/core/assets/X509-Limbo/.gitignore new file mode 100644 index 000000000..a6c57f5fb --- /dev/null +++ b/tests/core/assets/X509-Limbo/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/tests/core/crypto/x509_limbo/README.md b/tests/core/crypto/x509_limbo/README.md new file mode 100644 index 000000000..9fdcb4796 --- /dev/null +++ b/tests/core/crypto/x509_limbo/README.md @@ -0,0 +1,46 @@ +### x509-limbo — core/crypto/x509 path-validation conformance + +Differential-tests `core:crypto/x509` `verify_chain` against the +[x509-limbo][1] corpus (~9.8k adversarial path-validation testcases, +~30k certificates). Structured like `tests/core/crypto/wycheproof`: a +dedicated package whose corpus lives, gitignored, under +`tests/core/assets/`. + +Fetch the corpus: + +``` +curl -sL https://raw.githubusercontent.com/C2SP/x509-limbo/main/limbo.json \ + -o tests/core/assets/X509-Limbo/limbo.json +``` + +Run: + +``` +odin test tests/core/crypto/x509_limbo -o:speed +``` + +When the corpus is absent the suite logs a notice and passes, so a plain +`odin test` sweep never breaks. + +Each case carries its own `expected_result`, so the harness needs no +external oracle. Reviewed, documented divergences are listed by testcase id +in `allow.odin`: + + - `ACCEPT_ALLOW` — cases limbo marks FAILURE that we accept. All are policy + layers outside RFC 5280 path validation that this package intentionally + does not enforce (revocation, CABF Baseline Requirements issuance + conformance, per-extension criticality/presence policy, lenient + serials/dNSName syntax, name-constraint DoS bailout). + - `REJECT_ALLOW` — cases limbo accepts that we reject (the safe, stricter + direction), all using a name-constraint form we fail closed on + (directoryName / rfc822Name / otherName). + +Any diverging case NOT allow-listed fails the test — the on-change +regression gate, in particular for the security-critical "we accept a chain +limbo rejects" direction. When bumping the corpus, run once, review each new +divergence, and add it to the appropriate list (or fix the finding). + +Covered: RSA (PKCS#1 v1.5 + PSS), ECDSA P-256/P-384, Ed25519, and dNSName / +iPAddress name constraints. Chains using P-521 / Ed448 / DSA are skipped. + +[1]: https://github.com/C2SP/x509-limbo diff --git a/tests/core/crypto/x509_limbo/allow.odin b/tests/core/crypto/x509_limbo/allow.odin new file mode 100644 index 000000000..8abed9623 --- /dev/null +++ b/tests/core/crypto/x509_limbo/allow.odin @@ -0,0 +1,84 @@ +package test_x509_limbo + +// Reviewed & documented divergences from the x509-limbo corpus, keyed by +// testcase id. Any diverging case NOT listed here is treated as a REGRESSION +// and fails the test (see x509_limbo.odin). +// +// ACCEPT_ALLOW: cases limbo marks FAILURE that we ACCEPT. Every one is a policy +// layer OUTSIDE RFC 5280 path validation that this package intentionally does +// not enforce — revocation (crl::*), CABF Baseline Requirements issuance +// conformance (webpki::cn/eku/forbidden-rsa/san/aki, ca-as-leaf), per-extension +// criticality and presence policy (aki/ski/pc/san, nc must-be-critical), lenient +// serials / dNSName syntax, and the name-constraint DoS bailout. None is a +// path-validation integrity bug (no accepted bad signature or broken chain). +ACCEPT_ALLOW := []string { + "crl::crlnumber-critical", + "crl::crlnumber-missing", + "crl::issuer-missing-crlsign", + "crl::revoked-certificate-with-crl", + "pathlen::max-chain-depth-0-exhausted", + "pathlen::max-chain-depth-1-exhausted", + "pathological::nc-dos-1", + "pathological::nc-dos-2", + "rfc5280::aki::critical-aki", + "rfc5280::aki::cross-signed-root-missing-aki", + "rfc5280::aki::intermediate-missing-aki", + "rfc5280::aki::leaf-missing-aki", + "rfc5280::ca-empty-subject", + "rfc5280::leaf-ku-keycertsign", + "rfc5280::nc::invalid-dnsname-leading-period", + "rfc5280::nc::permitted-dns-match-noncritical", + "rfc5280::pc::ica-noncritical-pc", + "rfc5280::root-inconsistent-ca-extensions", + "rfc5280::root-missing-basic-constraints", + "rfc5280::root-non-critical-basic-constraints", + "rfc5280::san::noncritical-with-empty-subject", + "rfc5280::san::underscore-dns", + "rfc5280::serial::too-long", + "rfc5280::serial::zero", + "rfc5280::ski::critical-ski", + "rfc5280::ski::intermediate-missing-ski", + "rfc5280::ski::root-missing-ski", + "webpki::aki::root-with-aki-all-fields", + "webpki::aki::root-with-aki-authoritycertissuer", + "webpki::aki::root-with-aki-authoritycertserialnumber", + "webpki::aki::root-with-aki-missing-keyidentifier", + "webpki::aki::root-with-aki-ski-mismatch", + "webpki::ca-as-leaf", + "webpki::cn::case-mismatch", + "webpki::cn::ipv4-hex-mismatch", + "webpki::cn::ipv4-leading-zeros-mismatch", + "webpki::cn::ipv6-non-rfc5952-mismatch", + "webpki::cn::ipv6-uncompressed-mismatch", + "webpki::cn::ipv6-uppercase-mismatch", + "webpki::cn::not-in-san", + "webpki::cn::punycode-not-in-san", + "webpki::cn::utf8-vs-punycode-mismatch", + "webpki::ee-basicconstraints-ca", + "webpki::eku::ee-anyeku", + "webpki::eku::ee-critical-eku", + "webpki::eku::ee-without-eku", + "webpki::eku::root-has-eku", + "webpki::forbidden-rsa-key-not-divisible-by-8-in-leaf", + "webpki::forbidden-rsa-not-divisible-by-8-in-root", + "webpki::forbidden-weak-rsa-in-leaf", + "webpki::forbidden-weak-rsa-key-in-root", + "webpki::malformed-aia", + "webpki::san::public-suffix-multi-label-wildcard-san", + "webpki::san::public-suffix-private-namespace-wildcard-san", + "webpki::san::public-suffix-wildcard-san", + "webpki::san::san-critical-with-nonempty-subject", +} + +// REJECT_ALLOW: cases limbo ACCEPTS that we REJECT. +// All use a name-constraint GeneralName form we fail closed on: +// directoryName, rfc822Name (email), or otherName (These types are not implemented) +REJECT_ALLOW := []string { + "rfc5280::nc::nc-forbids-othername-noop", + "rfc5280::nc::nc-permits-email-domain", + "rfc5280::nc::nc-permits-email-exact", + "rfc5280::nc::nc-permits-email-literal-asterisk-exact-match", + "rfc5280::nc::nc-permits-email-literal-double-asterisk", + "rfc5280::nc::nc-permits-email-literal-mid-asterisk", + "rfc5280::nc::permitted-dn-match", +} diff --git a/tests/core/crypto/x509_limbo/schemas.odin b/tests/core/crypto/x509_limbo/schemas.odin new file mode 100644 index 000000000..08142c52d --- /dev/null +++ b/tests/core/crypto/x509_limbo/schemas.odin @@ -0,0 +1,39 @@ +package test_x509_limbo + +import "core:encoding/json" +import "core:os" + +// The subset of the C2SP x509-limbo limbo.json schema this harness consumes. +// See https://github.com/C2SP/x509-limbo. + +Limbo :: struct { + testcases: []Testcase `json:"testcases"`, +} + +Testcase :: struct { + id: string `json:"id"`, + expected_result: string `json:"expected_result"`, // "SUCCESS" | "FAILURE" + validation_kind: string `json:"validation_kind"`, // "SERVER" | "CLIENT" | ... + validation_time: string `json:"validation_time"`, // RFC 3339, or "" when null + peer_certificate: string `json:"peer_certificate"`, // the leaf, PEM + untrusted_intermediates: []string `json:"untrusted_intermediates"`, // PEM + trusted_certs: []string `json:"trusted_certs"`, // the roots, PEM + expected_peer_name: Peer_Name `json:"expected_peer_name"`, +} + +Peer_Name :: struct { + kind: string `json:"kind"`, // "DNS" | "IP" | ... + value: string `json:"value"`, +} + +// load reads and parses limbo.json at `path` into `dst`, allocating into the +// current context allocator. Returns false when the file is absent (the caller +// then skips the suite) or does not parse. +load :: proc(dst: ^Limbo, path: string) -> bool { + raw, err := os.read_entire_file_from_path(path, context.allocator) + if err != os.ERROR_NONE { + return false + } + defer delete(raw) + return json.unmarshal(raw, dst) == nil +} diff --git a/tests/core/crypto/x509_limbo/x509_limbo.odin b/tests/core/crypto/x509_limbo/x509_limbo.odin new file mode 100644 index 000000000..12a4d62bd --- /dev/null +++ b/tests/core/crypto/x509_limbo/x509_limbo.odin @@ -0,0 +1,234 @@ +package test_x509_limbo + +// Differential conformance harness for core:crypto/x509 verify_chain against the +// C2SP x509-limbo corpus. +// +// Fetch the corpus (once): +// curl -sL https://raw.githubusercontent.com/C2SP/x509-limbo/main/limbo.json \ +// -o tests/core/assets/X509-Limbo/limbo.json +// Run: +// odin test tests/core/crypto/x509_limbo -o:speed +// +// When the corpus is absent the suite logs a notice and passes, so a plain +// `odin test` does not break. Reviewed divergences are in allow.odin. +// Any test case failure not in the allow.odin should be considered a regression. + +import "core:log" +import "core:mem" +import "core:os" +import "core:slice" +import "core:testing" +import "core:time" + +import "core:crypto/x509" +import "core:encoding/pem" + +BASE_PATH :: ODIN_ROOT + "tests/core/assets/X509-Limbo" + +// 2024-01-01T00:00:00Z, x509-limbo's default validation instant for cases whose +// validation_time is null. +DEFAULT_TIME :: i64(1_704_067_200) + +// Arena for the parsed 41 MB corpus (strings + case structs). +CORPUS_ARENA_SIZE :: 5 * 41 * (1024 * 1024) + +Bucket :: enum { + Agree, // our verdict matches expected_result + Skip, // the chain uses an algorithm we do not verify (P-521 / Ed448 / DSA) + We_Accept_They_Reject, // DANGEROUS: expected FAILURE, we accepted + We_Reject_They_Accept, // safe/stricter: expected SUCCESS, we rejected +} + +@(test) +test_x509_limbo :: proc(t: ^testing.T) { + arena: mem.Arena + backing, berr := make([]byte, CORPUS_ARENA_SIZE) + if berr != nil { + log.errorf("x509-limbo: could not reserve %d bytes", CORPUS_ARENA_SIZE) + return + } + defer delete(backing) + mem.arena_init(&arena, backing) + context.allocator = mem.arena_allocator(&arena) + + path, _ := os.join_path([]string{BASE_PATH, "limbo.json"}, context.allocator) + + corpus: Limbo + if !load(&corpus, path) { + log.infof("x509-limbo corpus not found at %s; skipping (fetch from C2SP/x509-limbo)", path) + return + } + + counts: [Bucket]int + // These outlive the per-case temp resets, so back them with the corpus arena. + unexpected_accept := make([dynamic]string) + unexpected_reject := make([dynamic]string) + + for &tc in corpus.testcases { + bucket := run_case(&tc) + counts[bucket] += 1 + #partial switch bucket { + case .We_Accept_They_Reject: + if !slice.contains(ACCEPT_ALLOW, tc.id) { + append(&unexpected_accept, tc.id) + } + case .We_Reject_They_Accept: + if !slice.contains(REJECT_ALLOW, tc.id) { + append(&unexpected_reject, tc.id) + } + } + free_all(context.temp_allocator) + } + + log.infof( + "x509-limbo: %d cases | agree %d | skip %d | we-accept/they-reject %d | we-reject/they-accept %d", + len(corpus.testcases), + counts[.Agree], + counts[.Skip], + counts[.We_Accept_They_Reject], + counts[.We_Reject_They_Accept], + ) + + // Security-critical gate: no un-reviewed chain we accept that limbo rejects. + for id in unexpected_accept { + testing.expectf(t, false, "x509-limbo REGRESSION (we accept, limbo rejects): %s", id) + } + // Over-rejection gate (safe direction, but still a reviewed-set change). + for id in unexpected_reject { + testing.expectf(t, false, "x509-limbo over-rejection (we reject, limbo accepts): %s", id) + } +} + +// run_case parses a testcase's certificates, runs verify_chain, and buckets the +// verdict against the case's expected_result. All per-case allocation uses the +// temp allocator, reset by the caller after each case. +run_case :: proc(tc: ^Testcase) -> Bucket { + expect_ok := tc.expected_result == "SUCCESS" + + // Fixed backing keeps ^Certificate addresses stable; chains are tiny. + store: [64]x509.Certificate + n := 0 + + leaf: ^x509.Certificate + inters:= make([dynamic]^x509.Certificate,context.temp_allocator) + roots:= make([dynamic]^x509.Certificate,context.temp_allocator) + + // A cert that fails to parse (or overflows the backing) means we reject. + if lp := _add_pems(store[:], &n, tc.peer_certificate, nil); lp == nil { + return _verdict(false, expect_ok) + } else { + leaf = lp + } + for s in tc.untrusted_intermediates { + if _add_pems(store[:], &n, s, &inters) == nil { + return _verdict(false, expect_ok) + } + } + for s in tc.trusted_certs { + if _add_pems(store[:], &n, s, &roots) == nil { + return _verdict(false, expect_ok) + } + } + + // Skip chains that use an algorithm the verifier cannot check; verify_chain + // would report Unsupported_Algorithm, which is neither accept nor reject. + for &c in store[:n] { + if !_supported_key(c.public_key_algorithm) { + return .Skip + } + } + if !_supported_sig(leaf.signature_algorithm) { + return .Skip + } + for c in inters { + if !_supported_sig(c.signature_algorithm) { + return .Skip + } + } + + now := DEFAULT_TIME + if tc.validation_time != "" { + if tm, consumed := time.rfc3339_to_time_utc(tc.validation_time); consumed > 0 { + now = time.to_unix_seconds(tm) + } + } + + opts := x509.Verify_Options { + roots = roots[:], + intermediates = inters[:], + current_time = time.unix(now, 0), + } + if tc.expected_peer_name.kind != "" && tc.expected_peer_name.value != "" { + opts.dns_name = tc.expected_peer_name.value + } + // limbo's SERVER profile implies the serverAuth EKU (webpki / CABF). + if tc.validation_kind == "SERVER" { + opts.required_eku = .Server_Auth + } + + chain, verr := x509.verify_chain(leaf, opts, context.temp_allocator) + return _verdict(verr == .None, expect_ok) +} + +// _add_pems decodes every PEM block in `s` into `store` (bumping `n`), appending +// each parsed cert's pointer to `out` when non-nil, and returns the first cert's +// pointer (nil on any parse failure or backing overflow). A single limbo field +// may hold a bundle of concatenated certificates. +_add_pems :: proc( + store: []x509.Certificate, + n: ^int, + s: string, + out: ^[dynamic]^x509.Certificate, +) -> ^x509.Certificate { + first: ^x509.Certificate + data := transmute([]byte)s + for len(data) > 0 { + blk, rest, derr := pem.decode(data, context.temp_allocator) + if derr != nil || blk == nil { + break // no more blocks + } + data = rest + if n^ >= len(store) { + return nil + } + c, perr := x509.parse(pem.block_bytes(blk), context.temp_allocator) + if perr != .None { + return nil + } + store[n^] = c + p := &store[n^] + n^ += 1 + if first == nil { + first = p + } + if out != nil { + append(out, p) + } + } + return first +} + +_verdict :: proc(our_ok, expect_ok: bool) -> Bucket { + switch { + case our_ok == expect_ok: + return .Agree + case our_ok && !expect_ok: + return .We_Accept_They_Reject + case: + return .We_Reject_They_Accept + } +} + +_supported_key :: proc(a: x509.Public_Key_Algorithm) -> bool { + return a == .RSA || a == .ECDSA_P256 || a == .ECDSA_P384 || a == .Ed25519 +} + +_supported_sig :: proc(a: x509.Signature_Algorithm) -> bool { + #partial switch a { + case .RSA_SHA1, .RSA_SHA256, .RSA_SHA384, .RSA_SHA512, .RSA_PSS: + return true + case .ECDSA_SHA256, .ECDSA_SHA384, .ECDSA_SHA512, .Ed25519: + return true + } + return false +}