diff --git a/.gitignore b/.gitignore index bb29201..43930c6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ *.dylib CLAUDE.md .codex +.claude +data/ +tests/certs/generated # Test binary, built with `go test -c` *.test diff --git a/README.md b/README.md index f5050dd..5a9f1f2 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ rules: operator: eq operands: [true] severity: error - appliesTo: [root, intermediate] + certType: [root, intermediate] # ------------------------------------------------- # Key Usage (Section 4.2.1.3) @@ -169,7 +169,7 @@ rules: target: certificate.keyUsage operator: keyUsageCA severity: error - appliesTo: [root, intermediate] + certType: [root, intermediate] # ------------------------------------------------- # AIA Extension - Best Practice (INFO severity) @@ -180,7 +180,7 @@ rules: target: certificate.caIssuersURL operator: present severity: info - appliesTo: [leaf] + certType: [leaf] # ------------------------------------------------- # Subject Alternative Name (Section 4.2.1.6) @@ -417,7 +417,7 @@ Rules can include a `when` clause to apply only when certain conditions are met: target: certificate.signedCertificateTimestamps operator: present severity: warning - appliesTo: [leaf] + certType: [leaf] ``` When the `when` condition is not met, the rule status is **SKIP** (not displayed by default, use `-vv` to see). @@ -444,17 +444,16 @@ PCL automatically builds and validates certificate chains, applying rules based **Enhanced Detection:** At position 0, PCL checks BasicConstraints to correctly identify CA certificates even when linted directly (without a subscriber certificate chain). This allows linting intermediate CA certificates standalone. -Use `appliesTo` in rules to target specific certificate types. +Use `certType` on individual rules to target certificate roles (`leaf`, `intermediate`, `root`, `ocspSigning`). Rules without `certType` run on every role (others are **SKIP** when the role does not match). -## 📥 Input Type Filtering +## 📥 Policy and input filtering -Policies are automatically filtered by input type based on rule targets: +**Policy-level** (top of the YAML file, next to `id` / `version`): -- Rules with `certificate.*` targets → applied to X.509 certificates -- Rules with `crl.*` targets → applied to CRLs -- Rules with `ocsp.*` targets → applied to OCSP responses +- `appliesTo`: which **input object** the policy is for — `cert`, `crl`, or `ocsp` (e.g. `appliesTo: [ocsp]` for OCSP-only policies). +- `certType`: optional filter so the **whole policy** runs only on matching certificate roles (used with certificate linting). -This allows mixed policies to validate different PKI components independently. Use `appliesTo` to explicitly specify input types: `cert`, `crl`, `ocsp`. +If `appliesTo` is omitted, the input type is inferred from the first rule’s `target` prefix (`certificate.*` → cert, `crl.*` → crl, `ocsp.*` → ocsp). That inference does **not** propagate `root` / `leaf` to other rules — role filtering is per-rule `certType` only. ## 🌳 Node Tree Structure @@ -542,7 +541,7 @@ crl └── ... ``` -**CRL Type Detection:** The `isCACRL` field enables differentiation between Subscriber CRLs and CA CRLs (BR 7.2). This requires providing issuer certificates via `--issuer`. +**CRL type detection (`isCACRL`):** Set from the CRL signing certificate when its Subject or Authority Key Identifier matches a certificate in the validation chain. With `--auto-validate`, PCL also walks CA Issuers URLs from the chain (same timeout/depth as chain climbing) to fetch the signer when it is not already in the chain—so you usually do not need `--issuer` for CDP-fetched CRLs. Use `--issuer` only when the signer is not reachable via AIA from the built chain. ### OCSP Node Tree diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index 985034a..e0a1ccd 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -104,4 +104,4 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/docs/POLICY_WRITING_GUIDE.md b/docs/POLICY_WRITING_GUIDE.md index 0d99986..1bcf23c 100644 --- a/docs/POLICY_WRITING_GUIDE.md +++ b/docs/POLICY_WRITING_GUIDE.md @@ -77,7 +77,7 @@ Each rule has the following structure: operator: eq # Required: Operator to apply operands: [3] # Required for some operators: Values to compare severity: error # Required: error, warning, or info - appliesTo: [leaf, intermediate] # Optional: Certificate types this rule applies to + certType: [leaf, intermediate] # Optional: Certificate roles this rule applies to when: # Optional: Precondition that must be met target: certificate.extensions operator: present @@ -93,7 +93,7 @@ Each rule has the following structure: | `operator` | Yes | Comparison/validation operator (see Operators) | | `operands` | Some required | Values for operators that need them | | `severity` | Yes | `error`, `warning`, or `info` | -| `appliesTo` | No | Types this rule applies to (see Certificate Type Filtering) | +| `certType` | No | Certificate roles for this rule (see Certificate Type Filtering) | | `when` | No | Precondition that must be true before evaluating the rule | --- @@ -386,7 +386,7 @@ PCL provides 107 operators organized by category. All operators are defined in ` operator: eq operands: [false] severity: error - appliesTo: [leaf] + certType: [leaf] # Check signature algorithm matches tbsSignatureAlgorithm - id: sig-alg-matches-tbs @@ -490,7 +490,7 @@ PCL provides 107 operators organized by category. All operators are defined in ` operator: validityDays operands: [398] severity: error - appliesTo: [leaf] + certType: [leaf] # CRL nextUpdate within 10 days - id: crl-nextupdate-10-days @@ -524,7 +524,7 @@ PCL provides 107 operators organized by category. All operators are defined in ` target: certificate.extensions.2.5.29.19 operator: isCritical severity: error - appliesTo: [root, intermediate] + certType: [root, intermediate] ``` ### 7. EKU Operators @@ -548,7 +548,7 @@ PCL provides 107 operators organized by category. All operators are defined in ` operator: ekuContains operands: [serverAuth] severity: error - appliesTo: [leaf] + certType: [leaf] ``` ### 8. Key Usage Operators @@ -566,7 +566,7 @@ PCL provides 107 operators organized by category. All operators are defined in ` target: certificate.keyUsage operator: keyUsageCA severity: error - appliesTo: [root, intermediate] + certType: [root, intermediate] ``` ### 9. Certificate Chain Operators @@ -809,27 +809,43 @@ The `every` operator checks that ALL elements in an array satisfy a condition. --- -## Certificate Type Filtering +## Certificate and input filtering -The `appliesTo` field filters rules by certificate type or input type. +PCL uses two different fields. Do not put policy-level `appliesTo` on individual rules (it is ignored). -### Certificate Types (Roles) +### Policy-level (top of file, next to `id`) -| Type | Description | -|------|-------------| +| Field | Values | Purpose | +|-------|--------|---------| +| `appliesTo` | `cert`, `crl`, `ocsp` | Which **input object** this policy is evaluated against | +| `certType` | `leaf`, `root`, … | Optional: run the **entire policy** only on matching certificate roles | +| `crlType` | `completeCRL`, … | Optional: CRL subtype filter (see CRL policies) | + +```yaml +id: RFC6960 +version: "1.0" +appliesTo: + - ocsp +rules: + - id: RFC6960_STATUS_VALID + target: ocsp.status + # ... +``` + +If `appliesTo` is omitted, the input type is inferred from the **first rule’s** `target` prefix (`certificate.*`, `crl.*`, `ocsp.*`). That only selects cert vs CRL vs OCSP processing — not `root` vs `leaf`. + +### Rule-level (`certType` on each rule) + +| Value | Description | +|-------|-------------| | `leaf` | End-entity (subscriber) certificate | | `intermediate` | Subordinate CA certificate | | `root` | Self-signed root CA certificate | | `ocspSigning` | OCSP responder certificate | +| `crl` | When linting a CRL (matches evaluation context type) | +| `ocsp` | When linting an OCSP response object | -### Input Types - -| Type | Description | -|------|-------------| -| `crl` | Certificate Revocation List | -| `ocsp` | OCSP response | - -**Important:** Certificate types are roles (leaf, intermediate, root, ocspSigning). Do NOT use `cert` as a value - it is not valid. +Omit `certType` on a rule to evaluate it for every role (others with `certType` are **SKIP** when the role does not match). Each rule is filtered independently. **Examples:** ```yaml @@ -839,21 +855,21 @@ The `appliesTo` field filters rules by certificate type or input type. operator: ekuContains operands: [serverAuth] severity: error - appliesTo: [leaf] + certType: [leaf] # Apply to CA certificates - id: ca-key-cert-sign target: certificate.keyUsage.keyCertSign operator: present severity: error - appliesTo: [root, intermediate] + certType: [root, intermediate] -# Apply only to CRL inputs +# Apply only when evaluating a CRL - id: crl-validity-check target: crl operator: crlValid severity: error - appliesTo: [crl] + certType: [crl] ``` --- @@ -914,17 +930,17 @@ Always include specification references: - `warning`: SHOULD requirements - `info`: MAY requirements or informational -### Use appliesTo Appropriately +### Use certType appropriately -Use `appliesTo` to avoid confusing SKIP messages: +Use per-rule `certType` to avoid confusing SKIP messages: ```yaml -# Without appliesTo: will SKIP on CA certs +# Without certType: will SKIP on CA certs when the check does not apply - id: subscriber-not-ca target: certificate.basicConstraints.cA operator: eq operands: [false] severity: error - appliesTo: [leaf] # Clear: only evaluated on leaf + certType: [leaf] # Clear: only evaluated on leaf ``` --- @@ -952,7 +968,7 @@ rules: target: certificate.subjectKeyIdentifier operator: present severity: warning - appliesTo: [root, intermediate] + certType: [root, intermediate] - id: ski-not-critical reference: RFC5280 4.2.1.2 @@ -968,7 +984,7 @@ rules: operator: eq operands: [false] severity: error - appliesTo: [leaf] + certType: [leaf] # Key Usage - id: ca-key-cert-sign @@ -976,7 +992,7 @@ rules: target: certificate.keyUsage.keyCertSign operator: present severity: error - appliesTo: [root, intermediate] + certType: [root, intermediate] # Algorithm-Specific - id: rsa-key-size-min @@ -996,7 +1012,7 @@ rules: target: crl operator: crlValid severity: error - appliesTo: [crl] + certType: [crl] ``` --- diff --git a/internal/aia/aia_test.go b/internal/aia/aia_test.go index 785a60a..e376735 100644 --- a/internal/aia/aia_test.go +++ b/internal/aia/aia_test.go @@ -5,7 +5,6 @@ import ( "crypto/rsa" stdx509 "crypto/x509" "crypto/x509/pkix" - "encoding/asn1" "encoding/pem" "math/big" "net/http" @@ -55,11 +54,21 @@ func TestParseIssuerResponsePEM(t *testing.T) { } } +func TestBuildCertsOnlyPKCS7_rejectsEmptyDER(t *testing.T) { + if _, err := BuildCertsOnlyPKCS7([]byte{}); err == nil { + t.Fatal("expected error for empty certificate DER") + } +} + func TestParseIssuerResponsePKCS7(t *testing.T) { firstDER := testCertificateDER(t, "PKCS7 CA 1") secondDER := testCertificateDER(t, "PKCS7 CA 2") - certs, format, err := ParseIssuerResponse(testPKCS7CertsOnly(t, firstDER, secondDER)) + pkcs7DER, err := BuildCertsOnlyPKCS7(firstDER, secondDER) + if err != nil { + t.Fatalf("BuildCertsOnlyPKCS7: %v", err) + } + certs, format, err := ParseIssuerResponse(pkcs7DER) if err != nil { t.Fatalf("ParseIssuerResponse returned error: %v", err) } @@ -213,12 +222,11 @@ func TestSelectIssuer(t *testing.T) { matched: true, }, { - name: "fallback to first candidate", + name: "no match returns nil", child: &zx509.Certificate{ Issuer: zpkix.Name{CommonName: "Unknown CA"}, }, candidates: []*zx509.Certificate{fallback, subjectMatch}, - want: fallback, }, } @@ -289,54 +297,3 @@ func testCertificateDER(t *testing.T, commonName string) []byte { return der } -func testPKCS7CertsOnly(t *testing.T, certDERs ...[]byte) []byte { - t.Helper() - - certificateSet := make([]byte, 0) - for _, certDER := range certDERs { - certificateSet = append(certificateSet, certDER...) - } - - dataOID, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}) - if err != nil { - t.Fatalf("failed to marshal data OID: %v", err) - } - signedDataOID, err := asn1.Marshal(oidSignedData) - if err != nil { - t.Fatalf("failed to marshal signedData OID: %v", err) - } - - version := []byte{0x02, 0x01, 0x01} - emptySet := []byte{0x31, 0x00} - encapContentInfo := encodeASN1(0x30, dataOID) - certificates := encodeASN1(0xa0, certificateSet) - signedData := encodeASN1(0x30, appendAll(version, emptySet, encapContentInfo, certificates, emptySet)) - - return encodeASN1(0x30, appendAll(signedDataOID, encodeASN1(0xa0, signedData))) -} - -func appendAll(parts ...[]byte) []byte { - var out []byte - for _, part := range parts { - out = append(out, part...) - } - return out -} - -func encodeASN1(tag byte, content []byte) []byte { - out := []byte{tag} - out = append(out, encodeASN1Length(len(content))...) - out = append(out, content...) - return out -} - -func encodeASN1Length(length int) []byte { - if length < 0x80 { - return []byte{byte(length)} - } - var bytes []byte - for n := length; n > 0; n >>= 8 { - bytes = append([]byte{byte(n)}, bytes...) - } - return append([]byte{0x80 | byte(len(bytes))}, bytes...) -} diff --git a/internal/aia/issuer.go b/internal/aia/issuer.go index 3e0724a..bec6519 100644 --- a/internal/aia/issuer.go +++ b/internal/aia/issuer.go @@ -1,15 +1,33 @@ package aia -import "github.com/zmap/zcrypto/x509" +import ( + "bytes" + + "github.com/zmap/zcrypto/x509" +) // SelectIssuer chooses the issuer certificate for child from a CA Issuers -// response. It returns matched=false when the result is only a fallback. +// response. It returns matched=false when no candidate is suitable. func SelectIssuer(child *x509.Certificate, candidates []*x509.Certificate) (*x509.Certificate, bool) { if child == nil || len(candidates) == 0 { return nil, false } + if len(child.Raw) > 0 { + for _, candidate := range candidates { + if candidate == nil { + continue + } + if child.CheckSignatureFrom(candidate) == nil { + return candidate, true + } + } + } + for _, candidate := range candidates { + if candidate == nil { + continue + } if candidate.Subject.String() == child.Issuer.String() { return candidate, true } @@ -17,11 +35,15 @@ func SelectIssuer(child *x509.Certificate, candidates []*x509.Certificate) (*x50 if len(child.AuthorityKeyId) > 0 { for _, candidate := range candidates { - if len(candidate.SubjectKeyId) > 0 && string(candidate.SubjectKeyId) == string(child.AuthorityKeyId) { + if candidate == nil { + continue + } + if len(candidate.SubjectKeyId) > 0 && + bytes.Equal(candidate.SubjectKeyId, child.AuthorityKeyId) { return candidate, true } } } - return candidates[0], false + return nil, false } diff --git a/internal/aia/issuer_logic_test.go b/internal/aia/issuer_logic_test.go new file mode 100644 index 0000000..177fe49 --- /dev/null +++ b/internal/aia/issuer_logic_test.go @@ -0,0 +1,31 @@ +package aia + +import ( + "testing" + + zx509 "github.com/zmap/zcrypto/x509" + zpkix "github.com/zmap/zcrypto/x509/pkix" +) + +// TestSelectIssuer_LEAIA_noMatchReturnsNil documents that unrelated LE-style +// backup intermediates in a CA Issuers bundle must not be used as parent. +func TestSelectIssuer_LEAIA_noMatchReturnsNil(t *testing.T) { + child := &zx509.Certificate{ + Issuer: zpkix.Name{ + Organization: []string{"Internet Security Research Group"}, + CommonName: "ISRG Root X1", + Country: []string{"US"}, + }, + } + backupIntermediate := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "Let's Encrypt E9"}, + } + otherIntermediate := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "Let's Encrypt R14"}, + } + + got, matched := SelectIssuer(child, []*zx509.Certificate{backupIntermediate, otherIntermediate}) + if got != nil || matched { + t.Fatalf("SelectIssuer() = (%v, %v), want (nil, false)", got, matched) + } +} diff --git a/internal/aia/issuer_select_test.go b/internal/aia/issuer_select_test.go new file mode 100644 index 0000000..964eb1e --- /dev/null +++ b/internal/aia/issuer_select_test.go @@ -0,0 +1,109 @@ +package aia + +import ( + "crypto/rand" + "crypto/rsa" + stdx509 "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + zx509 "github.com/zmap/zcrypto/x509" + zpkix "github.com/zmap/zcrypto/x509/pkix" +) + +// testSignedLeafPair returns a signed leaf (zcrypto), its real issuer, and a +// decoy with the same subject DN as the issuer but a different key. +func testSignedLeafPair(t *testing.T) (leaf, parent, decoy *zx509.Certificate) { + t.Helper() + + parentKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate parent key: %v", err) + } + notBefore := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + notAfter := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + parentTemplate := &stdx509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Issuing CA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: stdx509.KeyUsageCertSign | stdx509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x0a, 0x0b}, + } + parentDER, err := stdx509.CreateCertificate(rand.Reader, parentTemplate, parentTemplate, &parentKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create parent: %v", err) + } + parent, err = zx509.ParseCertificate(parentDER) + if err != nil { + t.Fatalf("parse parent: %v", err) + } + + decoyKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate decoy key: %v", err) + } + decoyDER, err := stdx509.CreateCertificate(rand.Reader, parentTemplate, parentTemplate, &decoyKey.PublicKey, decoyKey) + if err != nil { + t.Fatalf("create decoy: %v", err) + } + decoy, err = zx509.ParseCertificate(decoyDER) + if err != nil { + t.Fatalf("parse decoy: %v", err) + } + + leafTemplate := &stdx509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "leaf.example"}, + Issuer: parentTemplate.Subject, + NotBefore: notBefore, + NotAfter: notAfter, + } + leafDER, err := stdx509.CreateCertificate(rand.Reader, leafTemplate, parentTemplate, &parentKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create leaf: %v", err) + } + leaf, err = zx509.ParseCertificate(leafDER) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + return leaf, parent, decoy +} + +func TestSelectIssuer_signatureBeforeBundleOrder(t *testing.T) { + leaf, parent, decoy := testSignedLeafPair(t) + + got, matched := SelectIssuer(leaf, []*zx509.Certificate{decoy, parent}) + if !matched || got != parent { + t.Fatalf("SelectIssuer() = (%v, %v), want (%v, true)", got, matched, parent) + } +} + +func TestSelectIssuer_subjectDNWhenNoRawSignature(t *testing.T) { + leaf, parent, _ := testSignedLeafPair(t) + leafNoRaw := &zx509.Certificate{ + Issuer: leaf.Issuer, + } + got, matched := SelectIssuer(leafNoRaw, []*zx509.Certificate{parent}) + if !matched || got != parent { + t.Fatalf("SelectIssuer() = (%v, %v), want (%v, true) via DN hint", got, matched, parent) + } +} + +func TestSelectIssuer_noFallbackWhenOnlyUnrelated(t *testing.T) { + leaf, _, _ := testSignedLeafPair(t) + unrelated := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "Other CA"}, + SerialNumber: big.NewInt(3), + IsCA: true, + } + + got, matched := SelectIssuer(leaf, []*zx509.Certificate{unrelated}) + if got != nil || matched { + t.Fatalf("SelectIssuer() = (%v, %v), want (nil, false)", got, matched) + } +} diff --git a/internal/aia/pkcs7.go b/internal/aia/pkcs7.go index e2a1ed3..66d2b8e 100644 --- a/internal/aia/pkcs7.go +++ b/internal/aia/pkcs7.go @@ -9,6 +9,63 @@ import ( "github.com/zmap/zcrypto/x509" ) +// BuildCertsOnlyPKCS7 wraps DER-encoded certificates in a PKCS#7 SignedData +// certs-only structure (RFC 5652), as served by some CA Issuers endpoints. +func BuildCertsOnlyPKCS7(certDERs ...[]byte) ([]byte, error) { + certificateSet := make([]byte, 0) + for _, certDER := range certDERs { + if len(certDER) == 0 { + return nil, fmt.Errorf("empty certificate DER") + } + certificateSet = append(certificateSet, certDER...) + } + + dataOID, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}) + if err != nil { + return nil, fmt.Errorf("marshal data OID: %w", err) + } + signedDataOID, err := asn1.Marshal(oidSignedData) + if err != nil { + return nil, fmt.Errorf("marshal signedData OID: %w", err) + } + + version := []byte{0x02, 0x01, 0x01} + emptySet := []byte{0x31, 0x00} + encapContentInfo := pkcs7EncodeASN1(0x30, dataOID) + certificates := pkcs7EncodeASN1(0xa0, certificateSet) + signedData := pkcs7EncodeASN1(0x30, pkcs7AppendAll(version, emptySet, encapContentInfo, certificates, emptySet)) + + return pkcs7EncodeASN1(0x30, pkcs7AppendAll(signedDataOID, pkcs7EncodeASN1(0xa0, signedData))), nil +} + +func pkcs7AppendAll(parts ...[]byte) []byte { + var out []byte + for _, part := range parts { + out = append(out, part...) + } + return out +} + +func pkcs7EncodeASN1(tag byte, content []byte) []byte { + lengthBytes := pkcs7EncodeASN1Length(len(content)) + out := make([]byte, 0, 1+len(lengthBytes)+len(content)) + out = append(out, tag) + out = append(out, lengthBytes...) + out = append(out, content...) + return out +} + +func pkcs7EncodeASN1Length(length int) []byte { + if length < 0x80 { + return []byte{byte(length)} + } + var bytes []byte + for n := length; n > 0; n >>= 8 { + bytes = append([]byte{byte(n)}, bytes...) + } + return append([]byte{0x80 | byte(len(bytes))}, bytes...) +} + var oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} // id-signedData (1.2.840.113549.1.7.2) // parsePKCS7CertsOnly parses a PKCS#7 SignedData structure and extracts certificates. diff --git a/internal/cert/aia_collect.go b/internal/cert/aia_collect.go new file mode 100644 index 0000000..a2454cc --- /dev/null +++ b/internal/cert/aia_collect.go @@ -0,0 +1,186 @@ +package cert + +import ( + "io" + "time" + + "github.com/cavoq/PCL/internal/aia" + "github.com/cavoq/PCL/internal/source" + "github.com/zmap/zcrypto/x509" +) + +// AIACollectConfig configures breadth-first CA Issuers collection. +type AIACollectConfig struct { + Timeout time.Duration + MaxDepth int + Warn io.Writer + // StopWhen, if set, stops after a discovered certificate satisfies the predicate. + StopWhen func(*x509.Certificate) bool +} + +// CollectViaCAIssuers walks CA Issuers URLs from seed certificates and returns +// the seed plus any newly discovered certificates (breadth-first, deduplicated by serial). +func CollectViaCAIssuers(seed []*x509.Certificate, cfg AIACollectConfig) []*x509.Certificate { + pool := append([]*x509.Certificate(nil), seed...) + if cfg.MaxDepth <= 0 || cfg.Timeout <= 0 { + return pool + } + + seen := serialSeenSet(pool) + frontier := append([]*x509.Certificate(nil), seed...) + + for depth := 0; depth < cfg.MaxDepth; depth++ { + var added []*x509.Certificate + for _, c := range frontier { + if c == nil { + continue + } + newCerts, stop := discoverViaCAIssuers(c, seen, cfg) + added = append(added, newCerts...) + if stop { + return append(pool, added...) + } + } + if len(added) == 0 { + break + } + pool = append(pool, added...) + frontier = added + } + + return pool +} + +// FetchParentViaCAIssuers fetches the issuing certificate for child using the first +// CA Issuers URL, matching ClimbChain behavior. +func FetchParentViaCAIssuers(child *x509.Certificate, timeout time.Duration, w io.Writer) (*x509.Certificate, source.Info, string, error) { + if child == nil || len(child.IssuingCertificateURL) == 0 { + return nil, source.Info{}, "", nil + } + + url := child.IssuingCertificateURL[0] + results, errs := aia.FetchCAIssuers([]string{url}, timeout) + if len(errs) > 0 { + return nil, source.Info{}, url, errs[0] + } + if len(results) == 0 { + return nil, source.Info{}, url, nil + } + + issuerResult := results[0] + warnPKCS7Bundle(w, child, issuerResult.Certs) + warnPEMDownload(w, url, issuerResult.Source.Format) + + issuerCert, matched := aia.SelectIssuer(child, issuerResult.Certs) + if issuerCert == nil { + if len(issuerResult.Certs) > 0 { + warnf(w, "Warning: CA Issuers URL %s returned %d certs but none match %s\n", + url, len(issuerResult.Certs), child.Subject.CommonName) + } + return nil, source.Info{}, url, nil + } + if !matched && len(issuerResult.Certs) > 1 { + warnf(w, "Warning: CA Issuers URL %s returned %d certs; selected via identity hint only\n", + url, len(issuerResult.Certs)) + } + + return issuerCert, normalizeIssuerSourceInfo(issuerResult.Source), url, nil +} + +func discoverViaCAIssuers(c *x509.Certificate, seen map[string]bool, cfg AIACollectConfig) ([]*x509.Certificate, bool) { + if c == nil || len(c.IssuingCertificateURL) == 0 { + return nil, false + } + + results, errs := aia.FetchCAIssuers(c.IssuingCertificateURL, cfg.Timeout) + for _, err := range errs { + warnf(cfg.Warn, "Warning: %v\n", err) + } + + var added []*x509.Certificate + for _, result := range results { + if result == nil { + continue + } + warnPEMDownload(cfg.Warn, result.Source.URL, result.Source.Format) + + var stop bool + added, stop = appendUniqueCandidates(seen, added, result.Certs, cfg.StopWhen) + if stop { + return added, true + } + } + + return added, false +} + +func appendUniqueCandidates( + seen map[string]bool, + into []*x509.Certificate, + candidates []*x509.Certificate, + stopWhen func(*x509.Certificate) bool, +) ([]*x509.Certificate, bool) { + for _, candidate := range candidates { + if candidate == nil || candidate.SerialNumber == nil { + continue + } + serial := candidate.SerialNumber.String() + if seen[serial] { + continue + } + seen[serial] = true + into = append(into, candidate) + if stopWhen != nil && stopWhen(candidate) { + return into, true + } + } + return into, false +} + +func normalizeIssuerSourceInfo(src source.Info) source.Info { + info := src + switch src.Format { + case source.FormatPKCS7: + info.Type = source.Extracted + info.Description = "extracted from PKCS#7" + case source.FormatPEM: + info.Description = "downloaded PEM" + } + return info +} + +// markSerialSeen records cert in seen and reports whether the serial was already present. +func markSerialSeen(seen map[string]bool, cert *x509.Certificate) bool { + if cert == nil || cert.SerialNumber == nil { + return false + } + serial := cert.SerialNumber.String() + if seen[serial] { + return true + } + seen[serial] = true + return false +} + +func serialSeenSet(certs []*x509.Certificate) map[string]bool { + seen := make(map[string]bool) + for _, c := range certs { + if c != nil && c.SerialNumber != nil { + seen[c.SerialNumber.String()] = true + } + } + return seen +} + +func warnPKCS7Bundle(w io.Writer, child *x509.Certificate, candidates []*x509.Certificate) { + if _, matched := aia.SelectIssuer(child, candidates); !matched && len(candidates) > 0 { + warnf(w, "Warning: PKCS#7 bundle contains %d certs, no issuer match for %s\n", + len(candidates), child.Subject.CommonName) + } +} + +func warnPEMDownload(w io.Writer, url string, format source.Format) { + if format == source.FormatPEM { + warnf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) + } +} diff --git a/internal/cert/aia_collect_test.go b/internal/cert/aia_collect_test.go new file mode 100644 index 0000000..03e59ea --- /dev/null +++ b/internal/cert/aia_collect_test.go @@ -0,0 +1,512 @@ +package cert + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cavoq/PCL/internal/source" + zx509 "github.com/zmap/zcrypto/x509" + zpkix "github.com/zmap/zcrypto/x509/pkix" +) + +func TestCollectViaCAIssuers_skipsNilFrontierCert(t *testing.T) { + got := CollectViaCAIssuers([]*zx509.Certificate{nil}, AIACollectConfig{ + Timeout: time.Second, + MaxDepth: 1, + }) + if len(got) != 1 || got[0] != nil { + t.Fatalf("got %v, want nil seed preserved", got) + } +} + +func TestCollectViaCAIssuers_twoHopBFS(t *testing.T) { + rootDER, root, rootKey := testParentChildPair(t, "Root CA", "unused-leaf") + rootServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(rootDER) + })) + defer rootServer.Close() + + interDER, inter := testSignedChildCA(t, root, rootKey, "Intermediate CA", []byte{0x03, 0x04}, []string{rootServer.URL}) + interServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(interDER) + })) + defer interServer.Close() + + leaf := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "subscriber"}, + Issuer: inter.Subject, + SerialNumber: big.NewInt(3), + IssuingCertificateURL: []string{interServer.URL}, + } + + got := CollectViaCAIssuers([]*zx509.Certificate{leaf}, AIACollectConfig{ + Timeout: time.Second, + MaxDepth: 3, + }) + if len(got) != 3 { + t.Fatalf("got %d certs, want leaf + intermediate + root", len(got)) + } +} + +func TestCollectViaCAIssuers_noFetchWhenDisabled(t *testing.T) { + seed := []*zx509.Certificate{{ + Subject: zpkix.Name{CommonName: "Leaf"}, + SerialNumber: big.NewInt(1), + }} + got := CollectViaCAIssuers(seed, AIACollectConfig{}) + if len(got) != 1 || got[0] != seed[0] { + t.Fatalf("CollectViaCAIssuers() = %v, want seed only", got) + } +} + +func TestCollectViaCAIssuers_fetchesAndStops(t *testing.T) { + parentDER, parent, _ := testParentChildPair(t, "Parent CA", "leaf.example") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(parentDER) + })) + defer server.Close() + + child := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "leaf.example"}, + Issuer: parent.Subject, + SerialNumber: big.NewInt(2), + IssuingCertificateURL: []string{server.URL}, + } + + var buf bytes.Buffer + got := CollectViaCAIssuers([]*zx509.Certificate{child}, AIACollectConfig{ + Timeout: time.Second, + MaxDepth: 1, + Warn: &buf, + StopWhen: func(c *zx509.Certificate) bool { + return c != nil && c.Subject.CommonName == "Parent CA" + }, + }) + + if len(got) != 2 { + t.Fatalf("got %d certs, want seed + parent", len(got)) + } + if got[1].Subject.CommonName != parent.Subject.CommonName { + t.Fatalf("second cert CN = %q, want %q", got[1].Subject.CommonName, parent.Subject.CommonName) + } +} + +func TestCollectViaCAIssuers_warnsOnFetchError(t *testing.T) { + child := &zx509.Certificate{ + SerialNumber: big.NewInt(1), + IssuingCertificateURL: []string{"http://127.0.0.1:1/"}, + } + + var buf bytes.Buffer + got := CollectViaCAIssuers([]*zx509.Certificate{child}, AIACollectConfig{ + Timeout: 50 * time.Millisecond, + MaxDepth: 1, + Warn: &buf, + }) + if len(got) != 1 { + t.Fatalf("got %d certs, want seed only", len(got)) + } + if buf.Len() == 0 { + t.Fatal("expected fetch warning") + } +} + +func TestFetchParentViaCAIssuers_noAIA(t *testing.T) { + child := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "Leaf"}, + SerialNumber: big.NewInt(1), + } + cert, info, url, err := FetchParentViaCAIssuers(child, time.Second, nil) + if err != nil || cert != nil || url != "" || info.Type != "" { + t.Fatalf("FetchParentViaCAIssuers() = (%v, %v, %q, %v), want nil,nil,\"\",nil", cert, info, url, err) + } +} + +func TestFetchParentViaCAIssuers_successDER(t *testing.T) { + parentDER, parent, _ := testParentChildPair(t, "Issuer CA", "subscriber.example") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(parentDER) + })) + defer server.Close() + + child := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "subscriber.example"}, + Issuer: parent.Subject, + SerialNumber: big.NewInt(2), + IssuingCertificateURL: []string{server.URL}, + } + + got, info, url, err := FetchParentViaCAIssuers(child, time.Second, nil) + if err != nil { + t.Fatalf("FetchParentViaCAIssuers() error: %v", err) + } + if got == nil || got.Subject.CommonName != "Issuer CA" { + t.Fatalf("issuer = %v, want Issuer CA", got) + } + if url != server.URL { + t.Fatalf("url = %q, want %q", url, server.URL) + } + if info.Format != source.FormatDER { + t.Fatalf("format = %q, want DER", info.Format) + } +} + +func TestFetchParentViaCAIssuers_successPEM(t *testing.T) { + parentDER, parent, _ := testParentChildPair(t, "PEM CA", "leaf.pem.test") + pemBody := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: parentDER}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(pemBody) + })) + defer server.Close() + + child := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "leaf.pem.test"}, + Issuer: parent.Subject, + SerialNumber: big.NewInt(2), + IssuingCertificateURL: []string{server.URL}, + } + + var buf bytes.Buffer + got, info, _, err := FetchParentViaCAIssuers(child, time.Second, &buf) + if err != nil { + t.Fatalf("FetchParentViaCAIssuers() error: %v", err) + } + if got == nil { + t.Fatal("expected issuer certificate") + } + if info.Format != source.FormatPEM { + t.Fatalf("format = %q, want PEM", info.Format) + } + if info.Description != "downloaded PEM" { + t.Fatalf("description = %q", info.Description) + } + if buf.Len() == 0 { + t.Fatal("expected PEM format warning") + } +} + +func TestFetchParentViaCAIssuers_emptyResults(t *testing.T) { + child := &zx509.Certificate{ + IssuingCertificateURL: []string{"http://127.0.0.1:1/"}, + } + _, _, url, err := FetchParentViaCAIssuers(child, 50*time.Millisecond, nil) + if err == nil { + t.Fatal("expected fetch error when all URLs fail") + } + if url != "http://127.0.0.1:1/" { + t.Fatalf("url = %q", url) + } +} + +func TestFetchParentViaCAIssuers_returnsNilWhenBundleUnrelated(t *testing.T) { + leaf := testSignedLeafZX509(t) + unrelatedDER := testParentDEROnly(t, "Unrelated CA", []byte{0xff, 0xfe}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(unrelatedDER) + })) + defer server.Close() + + leaf.IssuingCertificateURL = []string{server.URL} + var buf bytes.Buffer + got, _, _, err := FetchParentViaCAIssuers(leaf, time.Second, &buf) + if err != nil { + t.Fatalf("FetchParentViaCAIssuers() error: %v", err) + } + if got != nil { + t.Fatalf("issuer = %v, want nil when bundle does not match leaf", got) + } + if buf.Len() == 0 { + t.Fatal("expected warning when CA Issuers response has no matching issuer") + } +} + +func TestFetchParentViaCAIssuers_fetchError(t *testing.T) { + child := &zx509.Certificate{ + IssuingCertificateURL: []string{"http://127.0.0.1:1/"}, + } + _, _, url, err := FetchParentViaCAIssuers(child, 50*time.Millisecond, nil) + if err == nil { + t.Fatal("expected fetch error") + } + if url != "http://127.0.0.1:1/" { + t.Fatalf("url = %q", url) + } +} + +func TestWarnPKCS7Bundle(t *testing.T) { + child := &zx509.Certificate{ + Issuer: zpkix.Name{CommonName: "Expected Issuer"}, + } + candidates := []*zx509.Certificate{ + {Subject: zpkix.Name{CommonName: "Other 1"}, SerialNumber: big.NewInt(1)}, + {Subject: zpkix.Name{CommonName: "Other 2"}, SerialNumber: big.NewInt(2)}, + } + var buf bytes.Buffer + warnPKCS7Bundle(&buf, child, candidates) + if buf.Len() == 0 { + t.Fatal("expected PKCS#7 bundle warning") + } +} + +func TestNormalizeIssuerSourceInfo(t *testing.T) { + pkcs7 := normalizeIssuerSourceInfo(source.Info{Format: source.FormatPKCS7, URL: "https://example/aia"}) + if pkcs7.Type != source.Extracted || pkcs7.Description != "extracted from PKCS#7" { + t.Fatalf("PKCS#7 info = %+v", pkcs7) + } + + pemInfo := normalizeIssuerSourceInfo(source.Info{Format: source.FormatPEM, URL: "https://example/aia"}) + if pemInfo.Description != "downloaded PEM" { + t.Fatalf("PEM info = %+v", pemInfo) + } +} + +func TestAppendUniqueCandidates_stopWhen(t *testing.T) { + target := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "Target"}, + SerialNumber: big.NewInt(42), + } + other := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "Other"}, + SerialNumber: big.NewInt(43), + } + seen := map[string]bool{"1": true} + + added, stop := appendUniqueCandidates(seen, nil, []*zx509.Certificate{ + {SerialNumber: big.NewInt(1)}, + target, + other, + }, func(c *zx509.Certificate) bool { + return c == target + }) + if !stop || len(added) != 1 || added[0] != target { + t.Fatalf("appendUniqueCandidates() = (%v, %v), want ([target], true)", added, stop) + } + if !seen["42"] || seen["43"] { + t.Fatalf("seen after stop = %v, want only target serial recorded", seen) + } +} + +func TestMarkSerialSeen(t *testing.T) { + seen := map[string]bool{} + cert := &zx509.Certificate{SerialNumber: big.NewInt(7)} + if markSerialSeen(seen, cert) { + t.Fatal("first mark should not be duplicate") + } + if !markSerialSeen(seen, cert) { + t.Fatal("second mark should be duplicate") + } +} + +func TestMarkSerialSeen_nilCert(t *testing.T) { + if markSerialSeen(map[string]bool{}, nil) { + t.Fatal("nil cert should not count as duplicate") + } +} + +func TestAppendUniqueCandidates_skipsInvalidCandidates(t *testing.T) { + seen := map[string]bool{} + added, stop := appendUniqueCandidates(seen, nil, []*zx509.Certificate{ + nil, + {Subject: zpkix.Name{CommonName: "no-serial"}}, + }, nil) + if stop || len(added) != 0 { + t.Fatalf("appendUniqueCandidates() = (%v, %v), want ([], false)", added, stop) + } +} + +func TestWarnPEMDownload(t *testing.T) { + var buf bytes.Buffer + warnPEMDownload(&buf, "https://example.com/ca.cer", source.FormatPEM) + if buf.Len() == 0 { + t.Fatal("expected PEM warning") + } +} + +func testSignedLeafZX509(t *testing.T) *zx509.Certificate { + t.Helper() + + parentDER, _, key := testParentChildPair(t, "Issuing CA", "leaf.example") + parentStd, err := x509.ParseCertificate(parentDER) + if err != nil { + t.Fatalf("parse parent std: %v", err) + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "leaf.example"}, + Issuer: pkix.Name{CommonName: "Issuing CA"}, + NotBefore: parentStd.NotBefore, + NotAfter: parentStd.NotAfter, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, parentStd, &key.PublicKey, key) + if err != nil { + t.Fatalf("create leaf: %v", err) + } + leaf, err := zx509.ParseCertificate(leafDER) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + return leaf +} + +func testParentDEROnly(t *testing.T, cn string, ski []byte) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(99), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: ski, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create unrelated parent: %v", err) + } + return der +} + +func testParentChildPair(t *testing.T, parentCN, childCN string) ([]byte, *zx509.Certificate, *rsa.PrivateKey) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + parentTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: parentCN}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x01, 0x02}, + } + parentDER, err := x509.CreateCertificate(rand.Reader, parentTemplate, parentTemplate, &key.PublicKey, key) + if err != nil { + t.Fatalf("create parent: %v", err) + } + parent, err := zx509.ParseCertificate(parentDER) + if err != nil { + t.Fatalf("parse parent: %v", err) + } + + childTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: childCN}, + Issuer: pkix.Name{CommonName: parentCN}, + NotBefore: parentTemplate.NotBefore, + NotAfter: parentTemplate.NotAfter, + } + _, err = x509.CreateCertificate(rand.Reader, childTemplate, parentTemplate, &key.PublicKey, key) + if err != nil { + t.Fatalf("create child: %v", err) + } + + return parentDER, parent, key +} + +func testSignedChildCA(t *testing.T, parent *zx509.Certificate, parentKey *rsa.PrivateKey, cn string, ski []byte, aiaURLs []string) ([]byte, *zx509.Certificate) { + t.Helper() + + parentStd, err := x509.ParseCertificate(parent.Raw) + if err != nil { + t.Fatalf("parse parent raw: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(10), + Subject: pkix.Name{CommonName: cn}, + Issuer: parentStd.Subject, + NotBefore: parentStd.NotBefore, + NotAfter: parentStd.NotAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: ski, + IssuingCertificateURL: aiaURLs, + } + der, err := x509.CreateCertificate(rand.Reader, template, parentStd, &parentKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create intermediate: %v", err) + } + parsed, err := zx509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse intermediate: %v", err) + } + return der, parsed +} + +func TestClimbChain_fetchesParent(t *testing.T) { + parentDER, parent, _ := testParentChildPair(t, "Climb Parent", "climb-leaf.example") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(parentDER) + })) + defer server.Close() + + leaf := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "climb-leaf.example"}, + Issuer: parent.Subject, + SerialNumber: big.NewInt(2), + IssuingCertificateURL: []string{server.URL}, + } + + chain := []*Info{{Cert: leaf, FilePath: "leaf.pem"}} + got := ClimbChain(chain, time.Second, 1, nil) + if len(got) != 2 { + t.Fatalf("got %d certs, want 2", len(got)) + } + if got[1].Cert.Subject.CommonName != "Climb Parent" { + t.Fatalf("parent CN = %q", got[1].Cert.Subject.CommonName) + } + if got[1].FilePath != server.URL { + t.Fatalf("parent source path = %q", got[1].FilePath) + } +} + +func TestClimbChain_detectsCircularReference(t *testing.T) { + parentDER, parent, _ := testParentChildPair(t, "Circle CA", "circle-leaf") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(parentDER) + })) + defer server.Close() + + leaf := &zx509.Certificate{ + Subject: zpkix.Name{CommonName: "circle-leaf"}, + Issuer: parent.Subject, + SerialNumber: big.NewInt(2), + IssuingCertificateURL: []string{server.URL}, + } + + // Parent is already in chain; climbing from leaf re-fetches the same CA serial. + chain := []*Info{ + {Cert: parent}, + {Cert: leaf}, + } + var buf bytes.Buffer + got := ClimbChain(chain, time.Second, 2, &buf) + if len(got) != 2 { + t.Fatalf("got %d certs, want 2 (no duplicate append)", len(got)) + } + if buf.Len() == 0 { + t.Fatal("expected circular reference warning") + } +} diff --git a/internal/cert/certs.go b/internal/cert/certs.go new file mode 100644 index 0000000..f350f90 --- /dev/null +++ b/internal/cert/certs.go @@ -0,0 +1,14 @@ +package cert + +import "github.com/zmap/zcrypto/x509" + +// CertsFromInfos extracts certificates from Info values, skipping nil entries. +func CertsFromInfos(infos []*Info) []*x509.Certificate { + var certs []*x509.Certificate + for _, info := range infos { + if info != nil && info.Cert != nil { + certs = append(certs, info.Cert) + } + } + return certs +} diff --git a/internal/cert/certs_test.go b/internal/cert/certs_test.go new file mode 100644 index 0000000..7c9b9a1 --- /dev/null +++ b/internal/cert/certs_test.go @@ -0,0 +1,40 @@ +package cert + +import ( + "math/big" + "testing" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +func TestCertsFromInfos(t *testing.T) { + c := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Test"}, + SerialNumber: big.NewInt(1), + } + + tests := []struct { + name string + infos []*Info + want int + }{ + {name: "nil", want: 0}, + {name: "empty", infos: []*Info{}, want: 0}, + {name: "nil info", infos: []*Info{nil}, want: 0}, + {name: "nil cert", infos: []*Info{{}}, want: 0}, + {name: "one cert", infos: []*Info{{Cert: c}}, want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CertsFromInfos(tt.infos) + if len(got) != tt.want { + t.Fatalf("CertsFromInfos() len = %d, want %d", len(got), tt.want) + } + if tt.want == 1 && got[0] != c { + t.Fatal("unexpected certificate pointer") + } + }) + } +} diff --git a/internal/cert/chain.go b/internal/cert/chain.go index c1c8597..1387334 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -9,8 +9,8 @@ import ( "slices" "time" - "github.com/cavoq/PCL/internal/aia" "github.com/cavoq/PCL/internal/source" + "github.com/zmap/zcrypto/x509" ) func LoadCertificates(path string) ([]*Info, error) { @@ -113,62 +113,53 @@ func BuildChain(certs []*Info) ([]*Info, error) { // ClimbChain recursively fetches issuer certificates via CA Issuers URLs. func ClimbChain(chain []*Info, timeout time.Duration, maxDepth int, w io.Writer) []*Info { + return ClimbChainWithPool(chain, nil, timeout, maxDepth, w) +} + +// ClimbChainWithPool is like ClimbChain but when the top certificate has no +// IssuingCertificateURL, it looks in pool for a parent that verifies the +// signature (e.g. --issuer). DN-only matches are never used. +func ClimbChainWithPool(chain, pool []*Info, timeout time.Duration, maxDepth int, w io.Writer) []*Info { if len(chain) == 0 || maxDepth <= 0 { return chain } - seen := make(map[string]bool) - for _, c := range chain { - if c.Cert != nil && c.Cert.SerialNumber != nil { - seen[c.Cert.SerialNumber.String()] = true - } - } - - result := chain - depth := 0 + seen := serialSeenSet(CertsFromInfos(chain)) + result := append([]*Info(nil), chain...) - for depth < maxDepth { + for depth := 0; depth < maxDepth; depth++ { top := result[len(result)-1] if top.Cert == nil || IsSelfSigned(top.Cert) { break } if len(top.Cert.IssuingCertificateURL) == 0 { - break + parent := findSigningParentInPool(top.Cert, pool, seen) + if parent == nil { + break + } + if markSerialSeen(seen, parent.Cert) { + warnf(w, "Warning: circular certificate detected at %s\n", parent.FilePath) + break + } + parent.Position = len(result) + parent.Type = GetCertType(parent.Cert, parent.Position, len(result)+1) + result = append(result, parent) + continue } - url := top.Cert.IssuingCertificateURL[0] - issuerResult, err := aia.FetchCAIssuer(url, timeout) + issuerCert, sourceInfo, url, err := FetchParentViaCAIssuers(top.Cert, timeout, w) if err != nil { warnf(w, "Warning: failed to climb chain from %s: %v\n", url, err) break } - - issuerCert, matched := aia.SelectIssuer(top.Cert, issuerResult.Certs) if issuerCert == nil { break } - if !matched && len(issuerResult.Certs) > 1 { - warnf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(issuerResult.Certs)) - } - - if issuerCert.SerialNumber != nil { - serial := issuerCert.SerialNumber.String() - if seen[serial] { - warnf(w, "Warning: circular certificate detected at %s\n", url) - break - } - seen[serial] = true - } - sourceInfo := issuerResult.Source - switch issuerResult.Source.Format { - case source.FormatPKCS7: - sourceInfo.Type = source.Extracted - sourceInfo.Description = "extracted from PKCS#7" - case source.FormatPEM: - sourceInfo.Description = "downloaded PEM" - warnf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) + if markSerialSeen(seen, issuerCert) { + warnf(w, "Warning: circular certificate detected at %s\n", url) + break } result = append(result, &Info{ @@ -177,16 +168,38 @@ func ClimbChain(chain []*Info, timeout time.Duration, maxDepth int, w io.Writer) Type: GetCertType(issuerCert, len(result), len(result)+1), Position: len(result), Source: sourceInfo, - Format: issuerResult.Source.Format, + Format: sourceInfo.Format, }) - - depth++ } RebuildChainMetadata(result) return result } +func findSigningParentInPool(child *x509.Certificate, pool []*Info, seen map[string]bool) *Info { + if child == nil || len(child.Raw) == 0 { + return nil + } + for _, info := range pool { + if info == nil || info.Cert == nil { + continue + } + if child.CheckSignatureFrom(info.Cert) != nil { + continue + } + if info.Cert.SerialNumber != nil && seen[info.Cert.SerialNumber.String()] { + continue + } + return &Info{ + Cert: info.Cert, + FilePath: info.FilePath, + Source: info.Source, + Format: info.Format, + } + } + return nil +} + func RebuildChainMetadata(chain []*Info) { for i, c := range chain { c.Position = i diff --git a/internal/cert/chain_pool_test.go b/internal/cert/chain_pool_test.go new file mode 100644 index 0000000..735ecec --- /dev/null +++ b/internal/cert/chain_pool_test.go @@ -0,0 +1,231 @@ +package cert + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + zx509 "github.com/zmap/zcrypto/x509" +) + +func TestClimbChainWithPool_usesPoolWhenNoCaIssuers(t *testing.T) { + parentKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate parent key: %v", err) + } + interKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate intermediate key: %v", err) + } + + notBefore := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + notAfter := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + + parentStd := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Pool Parent CA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x01, 0x02}, + } + parentDER, err := x509.CreateCertificate(rand.Reader, parentStd, parentStd, &parentKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create parent: %v", err) + } + parent, err := zx509.ParseCertificate(parentDER) + if err != nil { + t.Fatalf("parse parent: %v", err) + } + parentInfo := &Info{Cert: parent, FilePath: "/trusted/parent.pem"} + + interStd := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "No AIA Intermediate"}, + Issuer: parentStd.Subject, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x03, 0x04}, + // No IssuingCertificateURL — climb must use pool + } + interDER, err := x509.CreateCertificate(rand.Reader, interStd, parentStd, &interKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create intermediate: %v", err) + } + intermediate, err := zx509.ParseCertificate(interDER) + if err != nil { + t.Fatalf("parse intermediate: %v", err) + } + interInfo := &Info{Cert: intermediate, FilePath: "intermediate.cer"} + + leafStd := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "sub.example"}, + Issuer: interStd.Subject, + NotBefore: notBefore, + NotAfter: notAfter, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafStd, interStd, &interKey.PublicKey, interKey) + if err != nil { + t.Fatalf("create leaf: %v", err) + } + leaf, err := zx509.ParseCertificate(leafDER) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + leafInfo := &Info{Cert: leaf, FilePath: "leaf.pem"} + + pool := []*Info{parentInfo} + got := ClimbChainWithPool([]*Info{leafInfo, interInfo}, pool, time.Second, 2, nil) + if len(got) != 3 { + t.Fatalf("got %d certs, want leaf + intermediate + pool parent", len(got)) + } + if got[2].Cert.Subject.CommonName != "Pool Parent CA" { + t.Fatalf("parent CN = %q", got[2].Cert.Subject.CommonName) + } + if got[2].FilePath != "/trusted/parent.pem" { + t.Fatalf("parent path = %q", got[2].FilePath) + } + if leaf.CheckSignatureFrom(intermediate) != nil || intermediate.CheckSignatureFrom(parent) != nil { + t.Fatal("setup signatures invalid") + } +} + +func TestClimbChainWithPool_doesNotUseDNOnlyPoolMatch(t *testing.T) { + parentKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + notBefore := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + notAfter := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + + impostorStd := &x509.Certificate{ + SerialNumber: big.NewInt(99), + Subject: pkix.Name{CommonName: "Wrong Parent"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + impostorDER, err := x509.CreateCertificate(rand.Reader, impostorStd, impostorStd, &parentKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create impostor: %v", err) + } + impostorZ, err := zx509.ParseCertificate(impostorDER) + if err != nil { + t.Fatalf("parse impostor: %v", err) + } + + childStd := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "child"}, + Issuer: pkix.Name{CommonName: "Wrong Parent"}, + NotBefore: notBefore, + NotAfter: notAfter, + } + childDER, err := x509.CreateCertificate(rand.Reader, childStd, impostorStd, &parentKey.PublicKey, parentKey) + _ = impostorZ + if err != nil { + t.Fatalf("create child: %v", err) + } + child, err := zx509.ParseCertificate(childDER) + if err != nil { + t.Fatalf("parse child: %v", err) + } + + // Pool has unrelated CA that does not sign child + realParentStd := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Real Parent"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + realKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate real key: %v", err) + } + realDER, err := x509.CreateCertificate(rand.Reader, realParentStd, realParentStd, &realKey.PublicKey, realKey) + if err != nil { + t.Fatalf("create real parent: %v", err) + } + realParent, err := zx509.ParseCertificate(realDER) + if err != nil { + t.Fatalf("parse real parent: %v", err) + } + + child.Issuer = realParent.Subject // DN matches real parent but signed by impostor + pool := []*Info{{Cert: realParent, FilePath: "real.pem"}} + + got := ClimbChainWithPool([]*Info{{Cert: child, FilePath: "child.pem"}}, pool, time.Second, 1, nil) + if len(got) != 1 { + t.Fatalf("got %d certs, want 1 (no DN-only pool link)", len(got)) + } +} + +func TestClimbChainWithPool_maxDepthZero(t *testing.T) { + leaf := &Info{Cert: &zx509.Certificate{SerialNumber: big.NewInt(1)}} + got := ClimbChainWithPool([]*Info{leaf}, nil, time.Second, 0, nil) + if len(got) != 1 { + t.Fatalf("len = %d, want unchanged chain", len(got)) + } +} + +func TestClimbChainWithPool_emitsWarningOnFetchFailure(t *testing.T) { + parentKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate parent key: %v", err) + } + childKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate child key: %v", err) + } + notBefore := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + notAfter := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + parentStd := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Parent CA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + childStd := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "child"}, + Issuer: parentStd.Subject, + IssuingCertificateURL: []string{"http://127.0.0.1:1/unreachable"}, + NotBefore: notBefore, + NotAfter: notAfter, + } + der, err := x509.CreateCertificate(rand.Reader, childStd, parentStd, &childKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create cert: %v", err) + } + childZ, err := zx509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse: %v", err) + } + var buf bytes.Buffer + got := ClimbChainWithPool([]*Info{{Cert: childZ, FilePath: "child.pem"}}, nil, 50*time.Millisecond, 2, &buf) + if len(got) != 1 { + t.Fatalf("len = %d", len(got)) + } + if buf.Len() == 0 { + t.Fatal("expected warning when CA Issuers fetch fails") + } +} diff --git a/internal/cert/climbchain_le_test.go b/internal/cert/climbchain_le_test.go new file mode 100644 index 0000000..809fbc9 --- /dev/null +++ b/internal/cert/climbchain_le_test.go @@ -0,0 +1,187 @@ +package cert + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cavoq/PCL/internal/aia" + "github.com/cavoq/PCL/internal/source" + zx509 "github.com/zmap/zcrypto/x509" +) + +// LE fixture layout (pinned from letsencrypt.org / x2.i.lencr.org): +// +// e9.der — Let's Encrypt E9 intermediate (issuer ISRG Root X2) +// isrg-root-x2.der — ISRG Root X2 (real parent; signs E9) +// +// Live CA Issuers at http://x2.i.lencr.org/ currently returns a single DER cert. +// This test serves a PKCS#7 bundle like historical multi-cert AIA responses: +// [DN+AKI impostor first, real signing parent second] so ClimbChain must verify +// signatures, not identity hints alone. +func TestClimbChain_lePKCS7Bundle_selectsSigningParent(t *testing.T) { + e9 := readLEFixtureCert(t, "e9.der") + realRootDER := readLEFixtureCertDER(t, "isrg-root-x2.der") + realRoot, err := zx509.ParseCertificate(realRootDER) + if err != nil { + t.Fatalf("parse ISRG Root X2: %v", err) + } + if e9.CheckSignatureFrom(realRoot) != nil { + t.Fatal("fixture: E9 must be signed by pinned ISRG Root X2") + } + + impostorDER := leISRGRootX2ImpostorDER(t, e9.AuthorityKeyId) + impostor, err := zx509.ParseCertificate(impostorDER) + if err != nil { + t.Fatalf("parse impostor: %v", err) + } + if impostor.Subject.String() != e9.Issuer.String() { + t.Fatalf("impostor subject %q != E9 issuer %q", impostor.Subject, e9.Issuer) + } + if e9.CheckSignatureFrom(impostor) == nil { + t.Fatal("impostor must not cryptographically sign E9") + } + + pkcs7DER, err := aia.BuildCertsOnlyPKCS7(impostorDER, realRootDER) + if err != nil { + t.Fatalf("BuildCertsOnlyPKCS7: %v", err) + } + if _, format, err := aia.ParseIssuerResponse(pkcs7DER); err != nil { + t.Fatalf("ParseIssuerResponse: %v", err) + } else if format != source.FormatPKCS7 { + t.Fatalf("format = %q, want PKCS#7", format) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(pkcs7DER) + })) + defer server.Close() + + e9.IssuingCertificateURL = []string{server.URL} + chain := []*Info{{Cert: e9, FilePath: "e9.der"}} + + got := ClimbChain(chain, time.Second, 1, nil) + if len(got) != 2 { + t.Fatalf("got %d certs, want E9 + ISRG Root X2", len(got)) + } + parent := got[1].Cert + if parent == nil { + t.Fatal("missing parent cert") + } + if parent.Subject.CommonName != "ISRG Root X2" { + t.Fatalf("parent CN = %q", parent.Subject.CommonName) + } + if e9.CheckSignatureFrom(parent) != nil { + t.Fatalf("climbed parent does not sign E9: %v", e9.CheckSignatureFrom(parent)) + } + if parent.SerialNumber.Cmp(realRoot.SerialNumber) != 0 { + t.Fatalf("selected serial %s, want pinned root %s", parent.SerialNumber, realRoot.SerialNumber) + } + if got[1].Format != source.FormatPKCS7 { + t.Fatalf("parent format = %q, want PKCS#7", got[1].Format) + } + if got[1].Source.Type != source.Extracted { + t.Fatalf("parent source type = %q, want extracted", got[1].Source.Type) + } +} + +func TestFetchParentViaCAIssuers_lePKCS7Bundle_selectsSigningParent(t *testing.T) { + e9 := readLEFixtureCert(t, "e9.der") + realRootDER := readLEFixtureCertDER(t, "isrg-root-x2.der") + impostorDER := leISRGRootX2ImpostorDER(t, e9.AuthorityKeyId) + + pkcs7DER, err := aia.BuildCertsOnlyPKCS7(impostorDER, realRootDER) + if err != nil { + t.Fatalf("BuildCertsOnlyPKCS7: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(pkcs7DER) + })) + defer server.Close() + + e9.IssuingCertificateURL = []string{server.URL} + got, info, url, err := FetchParentViaCAIssuers(e9, time.Second, nil) + if err != nil { + t.Fatalf("FetchParentViaCAIssuers: %v", err) + } + if got == nil { + t.Fatal("expected ISRG Root X2 from PKCS#7 bundle") + } + if got.Subject.CommonName != "ISRG Root X2" { + t.Fatalf("issuer CN = %q", got.Subject.CommonName) + } + if e9.CheckSignatureFrom(got) != nil { + t.Fatalf("issuer does not sign E9: %v", e9.CheckSignatureFrom(got)) + } + if url != server.URL { + t.Fatalf("url = %q", url) + } + if info.Format != source.FormatPKCS7 { + t.Fatalf("format = %q, want PKCS#7", info.Format) + } +} + +func readLEFixtureCert(t *testing.T, name string) *zx509.Certificate { + t.Helper() + der := readLEFixtureCertDER(t, name) + cert, err := zx509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + return cert +} + +func readLEFixtureCertDER(t *testing.T, name string) []byte { + t.Helper() + for _, base := range []string{ + filepath.Join("testdata", "letsencrypt"), + filepath.Join("..", "crl", "testdata", "letsencrypt"), + } { + path := filepath.Join(base, name) + der, err := os.ReadFile(path) + if err == nil { + return der + } + } + t.Fatalf("read LE fixture %s from cert or crl testdata", name) + return nil +} + +// leISRGRootX2ImpostorDER is a self-signed cert with the same subject and SKI as +// E9's issuer hint but a different key — it must not be chosen when the real root +// is present in the PKCS#7 bundle. +func leISRGRootX2ImpostorDER(t *testing.T, authorityKeyID []byte) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(0xbadc0de), + Subject: pkix.Name{ + Country: []string{"US"}, + Organization: []string{"Internet Security Research Group"}, + CommonName: "ISRG Root X2", + }, + NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2035, 1, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: append([]byte(nil), authorityKeyID...), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create impostor: %v", err) + } + return der +} diff --git a/internal/cert/testdata/letsencrypt/README.md b/internal/cert/testdata/letsencrypt/README.md new file mode 100644 index 0000000..acf7515 --- /dev/null +++ b/internal/cert/testdata/letsencrypt/README.md @@ -0,0 +1,10 @@ +# Let's Encrypt AIA fixtures (cert package) + +Pinned for `ClimbChain` / `FetchParentViaCAIssuers` integration tests. Refresh: + +```bash +curl -sSL http://letsencrypt.org/certs/2024/e9.der -o e9.der +curl -sS http://x2.i.lencr.org/ -o isrg-root-x2.der +``` + +`e9.der` is also kept under `internal/crl/testdata/letsencrypt/`; tests read either path. diff --git a/internal/cert/testdata/letsencrypt/isrg-root-x2.der b/internal/cert/testdata/letsencrypt/isrg-root-x2.der new file mode 100644 index 0000000..6e06880 Binary files /dev/null and b/internal/cert/testdata/letsencrypt/isrg-root-x2.der differ diff --git a/internal/crl/issuer.go b/internal/crl/issuer.go new file mode 100644 index 0000000..50de9fb --- /dev/null +++ b/internal/crl/issuer.go @@ -0,0 +1,86 @@ +package crl + +import ( + "bytes" + "io" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/zmap/zcrypto/x509" +) + +const subscriberCRLMaxInterval = 10 * 24 * time.Hour + +// CertMatchesCRLIssuer reports whether cert is the CRL signing certificate. +func CertMatchesCRLIssuer(cert *x509.Certificate, revocationList *x509.RevocationList) bool { + if cert == nil || revocationList == nil { + return false + } + if cert.Subject.String() == revocationList.Issuer.String() { + return true + } + if len(revocationList.AuthorityKeyId) > 0 && len(cert.SubjectKeyId) > 0 { + return bytes.Equal(cert.SubjectKeyId, revocationList.AuthorityKeyId) + } + return false +} + +// CertSignsCRL reports whether cert signed the CRL (DN/AKI match or signature verify). +func CertSignsCRL(cert *x509.Certificate, revocationList *x509.RevocationList) bool { + if CertMatchesCRLIssuer(cert, revocationList) { + return true + } + if cert == nil || revocationList == nil { + return false + } + return revocationList.CheckSignatureFrom(cert) == nil +} + +// SigningCertFromPool returns the CRL signing certificate from pool. +// Signature verification is preferred; DN/AKI match alone is used only when +// no pool member verifies (e.g. unit tests without a real signature). +func SigningCertFromPool(revocationList *x509.RevocationList, pool []*x509.Certificate) *x509.Certificate { + if revocationList == nil { + return nil + } + var hint *x509.Certificate + for _, c := range pool { + if c == nil { + continue + } + if revocationList.CheckSignatureFrom(c) == nil { + return c + } + if hint == nil && CertMatchesCRLIssuer(c, revocationList) { + hint = c + } + } + return hint +} + +// ResolveIssuerCerts returns chain certificates plus any CRL signing certificates +// discovered via Authority Key Identifier match, signature verify, or CA Issuers fetch. +func ResolveIssuerCerts( + chain []*cert.Info, + revocationList *x509.RevocationList, + timeout time.Duration, + maxDepth int, + w io.Writer, +) []*x509.Certificate { + pool := cert.CertsFromInfos(chain) + if revocationList == nil { + return pool + } + if SigningCertFromPool(revocationList, pool) != nil { + return pool + } + + return cert.CollectViaCAIssuers(pool, cert.AIACollectConfig{ + Timeout: timeout, + MaxDepth: maxDepth, + Warn: w, + StopWhen: func(c *x509.Certificate) bool { + return CertSignsCRL(c, revocationList) + }, + }) +} diff --git a/internal/crl/issuer_test.go b/internal/crl/issuer_test.go new file mode 100644 index 0000000..a817746 --- /dev/null +++ b/internal/crl/issuer_test.go @@ -0,0 +1,375 @@ +package crl + +import ( + "crypto/rand" + "crypto/rsa" + cryptox509 "crypto/x509" + cryptopkix "crypto/x509/pkix" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +func TestCertMatchesCRLIssuer(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Signer"}, + SubjectKeyId: []byte{0x01, 0x02, 0x03}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + other := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Other"}, + SubjectKeyId: []byte{0x04, 0x05, 0x06}, + SerialNumber: big.NewInt(2), + } + + tests := []struct { + name string + cert *x509.Certificate + crl *x509.RevocationList + want bool + }{ + { + name: "nil cert", + crl: &x509.RevocationList{Issuer: pkix.Name{CommonName: "Signer"}}, + }, + { + name: "nil crl", + cert: signer, + }, + { + name: "subject match", + cert: signer, + crl: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Signer"}, + AuthorityKeyId: []byte{0x99}, + }, + want: true, + }, + { + name: "authority key identifier match", + cert: other, + crl: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Different Issuer DN"}, + AuthorityKeyId: []byte{0x04, 0x05, 0x06}, + }, + want: true, + }, + { + name: "no match", + cert: signer, + crl: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Unknown"}, + AuthorityKeyId: []byte{0x07}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CertMatchesCRLIssuer(tt.cert, tt.crl) + if got != tt.want { + t.Fatalf("CertMatchesCRLIssuer() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSigningCertFromPool(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Signer"}, + SubjectKeyId: []byte{0x07}, + SerialNumber: big.NewInt(3), + } + other := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Other"}, + SerialNumber: big.NewInt(4), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Signer"}, + AuthorityKeyId: []byte{0x07}, + } + + got := SigningCertFromPool(revocationList, []*x509.Certificate{other, signer}) + if got != signer { + t.Fatalf("SigningCertFromPool() = %v, want signer", got) + } +} + +func TestResolveIssuerCerts_nilCRL(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CA"}, + SerialNumber: big.NewInt(1), + } + pool := ResolveIssuerCerts([]*cert.Info{{Cert: signer}}, nil, time.Second, 1, nil) + if len(pool) != 1 || pool[0] != signer { + t.Fatalf("ResolveIssuerCerts(nil CRL) = %v", pool) + } +} + +func TestCertSignsCRL_matchesViaSignature(t *testing.T) { + revocationList, err := ParseCRL(mustReadCRLFixture(t)) + if err != nil { + t.Fatalf("parse CRL: %v", err) + } + signer := &x509.Certificate{ + Subject: revocationList.Issuer, + SubjectKeyId: revocationList.AuthorityKeyId, + IsCA: true, + SerialNumber: big.NewInt(1), + } + if !CertSignsCRL(signer, revocationList) { + t.Fatal("expected CertSignsCRL true for issuer DN match") + } +} + +func TestSigningCertFromPool_nilWhenNoMatch(t *testing.T) { + if got := SigningCertFromPool(&x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Nobody"}, + }, []*x509.Certificate{{Subject: pkix.Name{CommonName: "Other"}}}); got != nil { + t.Fatalf("SigningCertFromPool() = %v, want nil", got) + } +} + +func TestResolveIssuerCerts_skipsFetchWhenSignerInChain(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Test CA"}, + SubjectKeyId: []byte{0x0a}, + IsCA: true, + SerialNumber: big.NewInt(5), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Test CA"}, + AuthorityKeyId: []byte{0x0a}, + } + chain := []*cert.Info{{Cert: signer}} + + pool := ResolveIssuerCerts(chain, revocationList, 0, 0, nil) + if len(pool) != 1 || pool[0] != signer { + t.Fatalf("ResolveIssuerCerts() = %v, want chain signer only", pool) + } +} + +func TestInferCACRLFromValidity(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + + tests := []struct { + name string + crl *x509.RevocationList + want bool + }{ + { + name: "nil", + }, + { + name: "seven days subscriber window", + crl: &x509.RevocationList{ + ThisUpdate: now, + NextUpdate: now.Add(7 * 24 * time.Hour), + }, + }, + { + name: "eleven days implies other CRL profile", + crl: &x509.RevocationList{ + ThisUpdate: now, + NextUpdate: now.Add(11 * 24 * time.Hour), + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferCACRLFromValidity(tt.crl) + if got != tt.want { + t.Fatalf("inferCACRLFromValidity() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsCACRL_usesValidityWhenSignerUnknown(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + crl := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Unknown Root"}, + ThisUpdate: now, + NextUpdate: now.Add(100 * 24 * time.Hour), + } + + if !isCACRL(crl, nil) { + t.Fatal("expected isCACRL true for long validity window without signer in pool") + } +} + +func TestIsCACRL_usesValidityWhenSignerNotCA(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + nonCA := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Wrong Match"}, + SubjectKeyId: []byte{0x01}, + IsCA: false, + SerialNumber: big.NewInt(9), + } + crl := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Wrong Match"}, + AuthorityKeyId: []byte{0x01}, + ThisUpdate: now, + NextUpdate: now.Add(100 * 24 * time.Hour), + } + + if !isCACRL(crl, []*x509.Certificate{nonCA}) { + t.Fatal("expected isCACRL true via validity when matched signer is not a CA") + } +} + +func TestResolveIssuerCerts_fetchesViaAIA(t *testing.T) { + parentDER, parent := testCRLIssuerCA(t, "CRL CA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(parentDER) + })) + defer server.Close() + + leaf := &x509.Certificate{ + Subject: pkix.Name{CommonName: "subscriber"}, + Issuer: parent.Subject, + SerialNumber: big.NewInt(2), + IssuingCertificateURL: []string{server.URL}, + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Different Issuer DN"}, + AuthorityKeyId: []byte{0x01, 0x02}, + } + + pool := ResolveIssuerCerts([]*cert.Info{{Cert: leaf}}, revocationList, time.Second, 2, nil) + if len(pool) != 2 { + t.Fatalf("pool len = %d, want leaf + fetched parent", len(pool)) + } + signer := SigningCertFromPool(revocationList, pool) + if signer == nil || signer.Subject.CommonName != "CRL CA" { + t.Fatalf("SigningCertFromPool() = %v, want CRL CA signer", signer) + } +} + +func TestBuildTreeWithChain_nilCRL(t *testing.T) { + if got := BuildTreeWithChain(nil, nil); got != nil { + t.Fatalf("BuildTreeWithChain(nil) = %v, want nil", got) + } +} + +func TestBuildTreeWithChain_isCACRLFromCASigner(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CA Signer"}, + SubjectKeyId: []byte{0x0b}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CA Signer"}, + AuthorityKeyId: []byte{0x0b}, + ThisUpdate: now, + NextUpdate: now.Add(7 * 24 * time.Hour), + } + + tree := BuildTreeWithChain(revocationList, []*x509.Certificate{signer}) + if tree == nil { + t.Fatal("expected CRL tree") + } + isCA, ok := tree.Children["isCACRL"] + if !ok { + t.Fatal("isCACRL node missing") + } + if isCA.Value != true { + t.Fatalf("isCACRL = %v, want true", isCA.Value) + } +} + +func TestBuildTreeWithChain_isCACRLFalseForShortValidity(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Unknown"}, + ThisUpdate: now, + NextUpdate: now.Add(5 * 24 * time.Hour), + } + + tree := BuildTreeWithChain(revocationList, nil) + isCA := tree.Children["isCACRL"] + if isCA.Value != false { + t.Fatalf("isCACRL = %v, want false", isCA.Value) + } +} + +func TestBuildTree_setsNodes(t *testing.T) { + revocationList, err := ParseCRL(mustReadCRLFixture(t)) + if err != nil { + t.Fatalf("parse CRL: %v", err) + } + tree := BuildTree(revocationList) + if tree == nil || tree.Children["issuer"] == nil { + t.Fatal("expected CRL tree with issuer node") + } +} + +func TestBuildTreeWithChain_zeroNextUpdate(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Unknown"}, + ThisUpdate: now, + } + tree := BuildTreeWithChain(revocationList, nil) + if tree.Children["isCACRL"].Value != false { + t.Fatalf("isCACRL = %v, want false when nextUpdate is zero", tree.Children["isCACRL"].Value) + } +} + +func TestInferCACRLFromValidity_zeroNextUpdate(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + if inferCACRLFromValidity(&x509.RevocationList{ThisUpdate: now}) { + t.Fatal("expected false when nextUpdate is zero") + } +} + +func mustReadCRLFixture(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "test.crl")) + if err != nil { + t.Fatalf("read test CRL: %v", err) + } + return data +} + +func testCRLIssuerCA(t *testing.T, cn string) ([]byte, *x509.Certificate) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + template := &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: cn}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: cryptox509.KeyUsageCertSign | cryptox509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x01, 0x02}, + } + der, err := cryptox509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return der, parsed +} diff --git a/internal/crl/letsencrypt_test.go b/internal/crl/letsencrypt_test.go new file mode 100644 index 0000000..5969c4f --- /dev/null +++ b/internal/crl/letsencrypt_test.go @@ -0,0 +1,161 @@ +package crl + +import ( + "io" + "math/big" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +func readLetsEncryptFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "letsencrypt", name)) + if err != nil { + t.Fatalf("read letsencrypt fixture %s: %v", name, err) + } + return data +} + +func parseLetsEncryptRootX1CRL(t *testing.T) *x509.RevocationList { + t.Helper() + revocationList, err := ParseCRL(readLetsEncryptFixture(t, "isrg-root-x1.crl")) + if err != nil { + t.Fatalf("parse ISRG Root X1 CRL: %v", err) + } + return revocationList +} + +func parseLetsEncryptRootX1Cert(t *testing.T) *x509.Certificate { + t.Helper() + c, err := x509.ParseCertificate(readLetsEncryptFixture(t, "isrgrootx1.der")) + if err != nil { + t.Fatalf("parse ISRG Root X1 cert: %v", err) + } + return c +} + +// TestLetsEncrypt_ISRGRootX1_isOtherCRLProfile documents LE root CRLs: long +// nextUpdate window must classify as other CRL (isCACRL true), not subscriber. +func TestLetsEncrypt_ISRGRootX1_isOtherCRLProfile(t *testing.T) { + revocationList := parseLetsEncryptRootX1CRL(t) + + window := revocationList.NextUpdate.Sub(revocationList.ThisUpdate) + if window <= subscriberCRLMaxInterval { + t.Fatalf("fixture window = %v, want > %v (refresh CRL if LE changed policy)", window, subscriberCRLMaxInterval) + } + if !isCACRL(revocationList, nil) { + t.Fatal("ISRG Root X1 CRL should be isCACRL true via validity inference") + } + + tree := BuildTreeWithChain(revocationList, nil) + if tree.Children["isCACRL"].Value != true { + t.Fatalf("tree isCACRL = %v, want true", tree.Children["isCACRL"].Value) + } +} + +// TestLetsEncrypt_ISRGRootX1_signingCertFromPool verifies the official root when +// it is present in the pool (signature path, not DN-only spoofing). +func TestLetsEncrypt_ISRGRootX1_signingCertFromPool(t *testing.T) { + revocationList := parseLetsEncryptRootX1CRL(t) + root := parseLetsEncryptRootX1Cert(t) + + if root.Subject.String() != revocationList.Issuer.String() { + t.Fatalf("fixture mismatch: cert subject %q vs CRL issuer %q", root.Subject, revocationList.Issuer) + } + + got := SigningCertFromPool(revocationList, []*x509.Certificate{root}) + if got == nil { + t.Fatal("expected ISRG Root X1 from pool") + } + if got != root { + t.Fatalf("SigningCertFromPool() = %q, want ISRG Root X1", got.Subject.CommonName) + } + if err := revocationList.CheckSignatureFrom(got); err != nil { + t.Fatalf("CRL signature must verify with official root: %v", err) + } +} + +// TestLetsEncrypt_ISRGRootX1_chainWithoutRootStillOtherCRL models validating a +// subscriber chain that does not include the root: BR other-CRL inference must +// still apply (this was the misclassification risk for long-validity CDP CRLs). +func TestLetsEncrypt_ISRGRootX1_chainWithoutRootStillOtherCRL(t *testing.T) { + revocationList := parseLetsEncryptRootX1CRL(t) + leaf := &x509.Certificate{ + Subject: pkix.Name{CommonName: "leaf.example.com"}, + Issuer: pkix.Name{CommonName: "R13"}, + SerialNumber: big.NewInt(1), + } + intermediate := &x509.Certificate{ + Subject: pkix.Name{CommonName: "R13"}, + IsCA: true, + SerialNumber: big.NewInt(2), + } + + pool := []*x509.Certificate{leaf, intermediate} + if !isCACRL(revocationList, pool) { + t.Fatal("expected isCACRL true without root in pool when validity > 10 days") + } +} + +// TestLetsEncrypt_ISRGRootX1_resolveIssuerCerts_skipsAIAWhenRootPresent ensures +// we do not network-fetch when the chain already contains the CRL signer. +func TestLetsEncrypt_ISRGRootX1_resolveIssuerCerts_skipsAIAWhenRootPresent(t *testing.T) { + revocationList := parseLetsEncryptRootX1CRL(t) + root := parseLetsEncryptRootX1Cert(t) + chain := []*cert.Info{ + {Cert: &x509.Certificate{Subject: pkix.Name{CommonName: "leaf"}}}, + {Cert: root}, + } + + pool := ResolveIssuerCerts(chain, revocationList, time.Second, 2, nil) + if len(pool) != 2 { + t.Fatalf("pool len = %d, want 2 (no AIA expansion)", len(pool)) + } + if SigningCertFromPool(revocationList, pool) != root { + t.Fatal("expected pool to identify ISRG Root X1 as signer") + } +} + +// TestLetsEncrypt_subscriberCRL_whenURLAvailable pins a live LE intermediate CRL +// (≤10 day nextUpdate) once published. As of 2026-05, e9.c.lencr.org / r14.c.lencr.org +// return 404 outside rotation; set PCL_LE_SUBSCRIBER_CRL_URL to enable this test. +func TestLetsEncrypt_subscriberCRL_whenURLAvailable(t *testing.T) { + url := os.Getenv("PCL_LE_SUBSCRIBER_CRL_URL") + if url == "" { + t.Skip("set PCL_LE_SUBSCRIBER_CRL_URL to a downloadable LE intermediate subscriber CRL (e.g. when e9.c.lencr.org serves 200)") + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(url) + if err != nil { + t.Fatalf("fetch subscriber CRL: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Skipf("subscriber CRL URL returned %s: %s", resp.Status, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read CRL body: %v", err) + } + revocationList, err := ParseCRL(body) + if err != nil { + t.Fatalf("parse subscriber CRL: %v", err) + } + + window := revocationList.NextUpdate.Sub(revocationList.ThisUpdate) + if window > subscriberCRLMaxInterval { + t.Fatalf("subscriber CRL window = %v, want ≤ %v", window, subscriberCRLMaxInterval) + } + if isCACRL(revocationList, nil) { + t.Fatal("subscriber CRL should not classify as other CRL (isCACRL false)") + } +} diff --git a/internal/crl/logic_test.go b/internal/crl/logic_test.go new file mode 100644 index 0000000..9adf672 --- /dev/null +++ b/internal/crl/logic_test.go @@ -0,0 +1,179 @@ +package crl + +import ( + "crypto/rand" + "crypto/rsa" + cryptox509 "crypto/x509" + cryptopkix "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +// TestCertSignsCRL_issuerMatchDoesNotVerifySignature documents that CertSignsCRL +// is a cheap identity hint only. Operators that need cryptographic proof must +// call CheckSignatureFrom (see CRLSignedBy). +func TestCertSignsCRL_nilInputs(t *testing.T) { + if CertSignsCRL(nil, &x509.RevocationList{}) { + t.Fatal("nil cert must not sign CRL") + } + if CertSignsCRL(&x509.Certificate{}, nil) { + t.Fatal("nil CRL must not match") + } +} + +func TestSigningCertFromPool_nilCRL(t *testing.T) { + ca := &x509.Certificate{SerialNumber: big.NewInt(1)} + if SigningCertFromPool(nil, []*x509.Certificate{ca}) != nil { + t.Fatal("nil CRL must return nil signer") + } +} + +func TestCertSignsCRL_issuerMatchDoesNotVerifySignature(t *testing.T) { + revocationList, err := ParseCRL(mustReadCRLFixture(t)) + if err != nil { + t.Fatalf("parse CRL: %v", err) + } + wrongKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + fakeSigner, err := cryptox509.CreateCertificate(rand.Reader, &cryptox509.Certificate{ + SerialNumber: big.NewInt(99), + Subject: cryptopkix.Name{CommonName: revocationList.Issuer.CommonName}, + NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + SubjectKeyId: revocationList.AuthorityKeyId, + }, &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: "self"}, + NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + }, &wrongKey.PublicKey, wrongKey) + if err != nil { + t.Fatalf("create fake signer: %v", err) + } + parsed, err := x509.ParseCertificate(fakeSigner) + if err != nil { + t.Fatalf("parse fake signer: %v", err) + } + + if !CertSignsCRL(parsed, revocationList) { + t.Fatal("CertSignsCRL reports true on DN/AKI match without checking signature") + } + if revocationList.CheckSignatureFrom(parsed) == nil { + t.Fatal("bogus cert must not verify CRL signature") + } +} + +// TestSigningCertFromPool_prefersSignatureOverSubjectSpoof ensures pool order +// cannot pick a non-signing cert that only matches the CRL issuer DN. +func TestSigningCertFromPool_prefersSignatureOverSubjectSpoof(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + caTemplate := &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: "Real CRL CA"}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: cryptox509.KeyUsageCertSign | cryptox509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x01, 0x02}, + } + caDER, err := cryptox509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &key.PublicKey, key) + if err != nil { + t.Fatalf("create CA: %v", err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("parse CA: %v", err) + } + cryptoCA, err := cryptox509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("parse crypto CA: %v", err) + } + + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + crlDER, err := cryptox509.CreateRevocationList(rand.Reader, &cryptox509.RevocationList{ + ThisUpdate: now, + NextUpdate: now.Add(30 * 24 * time.Hour), + Number: big.NewInt(1), + }, cryptoCA, key) + if err != nil { + t.Fatalf("create CRL: %v", err) + } + revocationList, err := ParseCRL(crlDER) + if err != nil { + t.Fatalf("parse CRL: %v", err) + } + + spoof := &x509.Certificate{ + Subject: revocationList.Issuer, + SubjectKeyId: revocationList.AuthorityKeyId, + IsCA: false, + SerialNumber: big.NewInt(2), + } + + got := SigningCertFromPool(revocationList, []*x509.Certificate{spoof, ca}) + if got == nil { + t.Fatal("expected a signer from pool") + } + if got == spoof { + t.Fatal("SigningCertFromPool chose DN-only spoof instead of signature-verified CA") + } + if got.Subject.CommonName != "Real CRL CA" { + t.Fatalf("SigningCertFromPool() CN = %q, want Real CRL CA", got.Subject.CommonName) + } +} + +// TestIsCACRL_BRBoundaryExactlyTenDays is the BR 7.2 subscriber CRL window edge: +// nextUpdate - thisUpdate must be *greater than* 10 days to classify as other CRL. +func TestIsCACRL_BRBoundaryExactlyTenDays(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + exactlyTen := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Unknown"}, + ThisUpdate: now, + NextUpdate: now.Add(subscriberCRLMaxInterval), + } + if isCACRL(exactlyTen, nil) { + t.Fatal("exactly 10-day CRL should be treated as subscriber CRL (isCACRL false)") + } + + overTen := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Unknown"}, + ThisUpdate: now, + NextUpdate: now.Add(subscriberCRLMaxInterval + time.Second), + } + if !isCACRL(overTen, nil) { + t.Fatal("CRL validity just over 10 days should infer other CRL profile (isCACRL true)") + } +} + +// TestIsCACRL_longValidityWithNonCASigner uses the same shape as mis-issued CDP +// CRLs: AKI/DN match on a non-CA in the pool must not force subscriber profile +// when validity exceeds 10 days (see TestLetsEncrypt_ISRGRootX1_* for real CRLs). +func TestIsCACRL_longValidityWithNonCASigner(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + ee := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Subscriber"}, + SubjectKeyId: []byte{0xaa}, + IsCA: false, + SerialNumber: big.NewInt(1), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Subscriber"}, + AuthorityKeyId: []byte{0xaa}, + ThisUpdate: now, + NextUpdate: now.Add(100 * 24 * time.Hour), + } + + if !isCACRL(revocationList, []*x509.Certificate{ee}) { + t.Fatal("long-validity CRL should be isCACRL true even when only matched signer is non-CA") + } +} diff --git a/internal/crl/testdata/letsencrypt/README.md b/internal/crl/testdata/letsencrypt/README.md new file mode 100644 index 0000000..614dbbb --- /dev/null +++ b/internal/crl/testdata/letsencrypt/README.md @@ -0,0 +1,22 @@ +# Let's Encrypt official fixtures + +Pinned from [Let's Encrypt certificates](https://letsencrypt.org/certificates/) CDNs (HTTP). Refresh: + +```bash +# Or: tests/scripts/fetch-letsencrypt-crl-fixtures.sh +curl -sS http://crl.root-x1.letsencrypt.org/ -o isrg-root-x1.crl +curl -sS http://x2.c.lencr.org/ -o isrg-root-x2.crl +curl -sSL http://letsencrypt.org/certs/isrgrootx1.der -o isrgrootx1.der +curl -sSL http://letsencrypt.org/certs/2024/e9.der -o e9.der +curl -sS http://x2.i.lencr.org/ -o isrg-root-x2.der +``` + +| File | Source | Role | +|------|--------|------| +| `isrg-root-x1.crl` | `http://crl.root-x1.letsencrypt.org/` | ISRG Root X1 CRL (~1 year `nextUpdate`) | +| `isrg-root-x2.crl` | `http://x2.c.lencr.org/` | ISRG Root X2 CRL | +| `isrgrootx1.der` | `http://letsencrypt.org/certs/isrgrootx1.der` | Root CA for signature checks | +| `e9.der` | `http://letsencrypt.org/certs/2024/e9.der` | E9 intermediate for AIA / ClimbChain tests | +| `isrg-root-x2.der` | `http://x2.i.lencr.org/` | Parent cert in PKCS#7 ClimbChain fixture | + +Intermediate subscriber CRLs (`e9.c.lencr.org`, `r14.c.lencr.org`) often 404 outside rotation; set `PCL_LE_SUBSCRIBER_CRL_URL` when a live URL is available, or use `testdata/test.crl` for ~30-day subscriber windows. diff --git a/internal/crl/testdata/letsencrypt/chain-x1-test.pem b/internal/crl/testdata/letsencrypt/chain-x1-test.pem new file mode 100644 index 0000000..e69de29 diff --git a/internal/crl/testdata/letsencrypt/e9-cross.pem b/internal/crl/testdata/letsencrypt/e9-cross.pem new file mode 100644 index 0000000..054a21c --- /dev/null +++ b/internal/crl/testdata/letsencrypt/e9-cross.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEVjCCAj6gAwIBAgIQPxMQ4JDWqro5xbc4i97cEzANBgkqhkiG9w0BAQsFADBP +MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy +Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa +Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF +bmNyeXB0MQswCQYDVQQDEwJFOTB2MBAGByqGSM49AgEGBSuBBAAiA2IABKBc3EWf +O6zmAqlYSV0MFToiAnzppo1ZSJfbXGjprjBkydFbYBekcgrlJEmt5787R4P1grbP +tgd3oUBlfoMzWHihpjXWkojvlceMmUYqvVbQc39pCD3RiYjDCr7WpuIqc6OB+DCB +9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB +MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFF130U2sTSJ4WbKGWY5DHLdk +WRNBMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB +BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE +DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j +ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBXnLSlc0Gensx9lwlwHOuj6gafwSHI +Ex4AhPjD6aMOCfuMP5iEn3Mb1QadTZafQ075SWQo1npjZyaH5M1N8cewGo2/2fjc +z+2joCAeXBxTAbIiOIbaJwjgeUXQAFtpb7OffZdj7fmN5dAplWuau3ssY7vhmMfI +zxMW7ILj0Uwj99AGtg6YQmo5UVFJbLu41sTpLXFP4eeI5L+cbsHxgMnXaHB7G1Wl +Htlyp2iDlfzHdR28GBf05Ztex/S/kwFP3TUD5udUpaGZ/wAM8Vz0PnES0YN9Pwkg +fz1kxBO37E1nmGtF8mBVzBvGAiVL2AfgDn6eaKuerWrbtXEBCp0JawPcJRYRRu3e +lp1fqkvihMKBwXwA4xjM4BOvbGDPr9zkFN8NTwvL2sOuLDRlX6ZV6ILJ3uEMuJ17 +8b+fUymtArsbbSsbcCW8yb95gy9corS6RLCND8GU5+ampNzgNx0+PP7QFP4/eqLC +68CJNOctb5eDwbGsmF1vrfltenYU375le6FZILjP8O69hD3BPrNIie8VlrqBSiDg +M+ZF/E4uwcNxU62tYmKs9/WZyMJAR084fkEwXOw322x6OCwzyemZVq3kYKFg0y/m +E9NYU8ijBkUBB3uW1AC4btGCOSo9cnEz9TlRO1HOo7DMZkuuz5K0Rcal0LGLL11d +KsApwwSJLRbh7A== +-----END CERTIFICATE----- diff --git a/internal/crl/testdata/letsencrypt/e9.der b/internal/crl/testdata/letsencrypt/e9.der new file mode 100644 index 0000000..e0a8752 Binary files /dev/null and b/internal/crl/testdata/letsencrypt/e9.der differ diff --git a/internal/crl/testdata/letsencrypt/e9.pem b/internal/crl/testdata/letsencrypt/e9.pem new file mode 100644 index 0000000..a61fa06 --- /dev/null +++ b/internal/crl/testdata/letsencrypt/e9.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICtDCCAjugAwIBAgIQL0w31L50/EIziMaoQErAIjAKBggqhkjOPQQDAzBPMQsw +CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg +R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yNDAzMTMwMDAwMDBaFw0y +NzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNy +eXB0MQswCQYDVQQDEwJFOTB2MBAGByqGSM49AgEGBSuBBAAiA2IABKBc3EWfO6zm +AqlYSV0MFToiAnzppo1ZSJfbXGjprjBkydFbYBekcgrlJEmt5787R4P1grbPtgd3 +oUBlfoMzWHihpjXWkojvlceMmUYqvVbQc39pCD3RiYjDCr7WpuIqc6OB+DCB9TAO +BgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFF130U2sTSJ4WbKGWY5DHLdkWRNB +MB8GA1UdIwQYMBaAFHxClq7eS0g7+pL4nozPbYupcjeVMDIGCCsGAQUFBwEBBCYw +JDAiBggrBgEFBQcwAoYWaHR0cDovL3gyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAK +MAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDIuYy5sZW5jci5v +cmcvMAoGCCqGSM49BAMDA2cAMGQCMBWsD3obNCbyyK+wmA5pegl6G4VEIEAzL7wM +bza6kx4K7CvBzaHQxKl+roFwORB8fgIwS34uE6gGijIy0C2Xn+vjeCqn8xZikhq2 +Xf1xSYDuYqpW9YVJ1Ou2vqm5YZFCIX+n +-----END CERTIFICATE----- diff --git a/internal/crl/testdata/letsencrypt/isrg-root-x1.crl b/internal/crl/testdata/letsencrypt/isrg-root-x1.crl new file mode 100644 index 0000000..cd628c2 Binary files /dev/null and b/internal/crl/testdata/letsencrypt/isrg-root-x1.crl differ diff --git a/internal/crl/testdata/letsencrypt/isrg-root-x2.crl b/internal/crl/testdata/letsencrypt/isrg-root-x2.crl new file mode 100644 index 0000000..335f43d Binary files /dev/null and b/internal/crl/testdata/letsencrypt/isrg-root-x2.crl differ diff --git a/internal/crl/testdata/letsencrypt/isrgrootx1.der b/internal/crl/testdata/letsencrypt/isrgrootx1.der new file mode 100644 index 0000000..9d2132e Binary files /dev/null and b/internal/crl/testdata/letsencrypt/isrgrootx1.der differ diff --git a/internal/crl/testdata/letsencrypt/leaf-x1-test.pem b/internal/crl/testdata/letsencrypt/leaf-x1-test.pem new file mode 100644 index 0000000..e32c661 --- /dev/null +++ b/internal/crl/testdata/letsencrypt/leaf-x1-test.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE1zCCA7+gAwIBAgISBaAGpdlUSCrFPrdyRZCe7trYMA0GCSqGSIb3DQEBCwUA +MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD +EwNSMTMwHhcNMjYwNTE5MTQzNzI2WhcNMjYwNTI2MDYzNzI1WjAAMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuyZL0w5nqA2LFoRr4j5x/bR5dya5ZTDR +aP7RZXy1ZT54xlNSyvbz/t3fpWLRAxARRgnwBpamVpMZLbuCdLBMhknExBqxS7EO +N+/8wV4mMQeCWBQ7DCzUbxYCy4+z8yJ0anSQVdhALceZ1twZvtUv12IyCOWWZ4xb +n8BQYqfbLyGBPPp02pqQOk9h4ZIrg4krZw1M1bY2hdAUp/aaYN5p3XvbjfrEALL9 +FoHmfqt/HjUpXtOMOJI+ogT+Tf9DhDke2vcsy/0xc1V1G4Zyc9+1iIFdz3XAqK/3 +6lXxQ+EXNrowdsSoS0z+vvp7S0u+PxbmiIOzgPYfS9mLqG4zFim99wIDAQABo4IC +FjCCAhIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1Ud +EwEB/wQCMAAwHwYDVR0jBBgwFoAU56ufDywzoFPTXk94yLKEDjvWkjMwMwYIKwYB +BQUHAQEEJzAlMCMGCCsGAQUFBzAChhdodHRwOi8vcjEzLmkubGVuY3Iub3JnLzAx +BgNVHREBAf8EJzAlgiN2YWxpZC54MS50ZXN0LWNlcnRzLmxldHNlbmNyeXB0Lm9y +ZzATBgNVHSAEDDAKMAgGBmeBDAECATAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8v +cjEzLmMubGVuY3Iub3JnLzg5LmNybDCCAQ0GCisGAQQB1nkCBAIEgf4EgfsA+QB2 +AJaXZL9VWJet90OHaDcIQnfp8DrV9qTzNm5GpD8PyqnGAAABnkDhIi4AAAQDAEcw +RQIhANLTRaAbbG4YU/XtMqcf+Z6sCPsd4rJMX1NTfOJYFReWAiATCXc7rBEgE0nu +OcVsD74DXgddCX0eDf6hH88/od/qMgB/ABqLnWlKV5jImaDKiL30j8C0VmDMw2AN +H3H0af/H0ayjAAABnkDhJGgACAAABQBbMoLEBAMASDBGAiEAwJqi8/Q3np7gCGQJ +phaexPFRFmpVLQPOSGshkYyJQEgCIQCOPokouN76stH+SRMaC0J3d88QkHEqvsSZ +l+ZQtlLvtTANBgkqhkiG9w0BAQsFAAOCAQEAEk6Z/+bqRjwlgVqNTJoopNLtHI1F +Z7rGUctUTjPCmfpkMNf7tOK/C4lu1vySt8M9L9Tgl+QXS8SMLYUcTOdie5ekj08N +s+KaJnjTvQHuTyQn/EtoKdfQWYX1sxI0DSuHdRzLJDj4csU8JxQLyIx8mOzOfnJl +3nDGXZ7pzdmX7A9stg9Y6E8uYjQECvJ4MGWYSgXRjpmpXAY8UQX7iOlxc+NS04WV +0O1WeXNexkSNt32GObRMwqoA82lyowR0nwhcga4+rMZB3QVP7wgC5/boePQcxc0J +5d3Ze8hHiUm5qKvtQtVQKZKHugWjoRm0V7TT20RtjmpvpmsWMJWfi5NasQ== +-----END CERTIFICATE----- diff --git a/internal/crl/testdata/letsencrypt/r14-cross.pem b/internal/crl/testdata/letsencrypt/r14-cross.pem new file mode 100644 index 0000000..5b7ffad --- /dev/null +++ b/internal/crl/testdata/letsencrypt/r14-cross.pem @@ -0,0 +1,622 @@ + + +
+ + + +The page you're looking for doesn't exist or has been moved.
+ Return to Homepage +