add limbo

This commit is contained in:
kalsprite
2026-07-12 15:05:36 -07:00
parent df044411d9
commit 4aa30314c9
7 changed files with 418 additions and 23 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -0,0 +1 @@
*.json

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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
}

View File

@@ -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
}