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 @@ + + + + + + + +404 Page not found - Let's Encrypt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ +
+
+
+ + +
+ + +
+ + + +
+
+ + + +
+ +
+ +
+
+

404 - Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+ Return to Homepage +
+
+ +
+ + + + + + + + + + + + diff --git a/internal/crl/tree.go b/internal/crl/tree.go new file mode 100644 index 0000000..b9a67fe --- /dev/null +++ b/internal/crl/tree.go @@ -0,0 +1,49 @@ +package crl + +import ( + "github.com/cavoq/PCL/internal/crl/zcrypto" + "github.com/cavoq/PCL/internal/node" + "github.com/zmap/zcrypto/x509" +) + +// BuildTree builds a CRL node tree without isCACRL (for embedding under certificates). +func BuildTree(revocationList *x509.RevocationList) *node.Node { + return zcrypto.BuildTree(revocationList) +} + +// BuildTreeWithChain builds a CRL node tree and sets isCACRL from issuerCerts. +// isCACRL is true when the CRL signing certificate is a CA, or when the CRL +// validity window exceeds the BR 7.2 subscriber CRL maximum (other CRL profile). +func BuildTreeWithChain(revocationList *x509.RevocationList, issuerCerts []*x509.Certificate) *node.Node { + if revocationList == nil { + return nil + } + n := zcrypto.BuildTree(revocationList) + if n == nil { + return nil + } + + n.Children["isCACRL"] = node.New("isCACRL", IsCACRL(revocationList, issuerCerts)) + return n +} + +// IsCACRL reports whether the CRL follows the CA / other-CRL profile used by BR policies. +func IsCACRL(revocationList *x509.RevocationList, issuerCerts []*x509.Certificate) bool { + return isCACRL(revocationList, issuerCerts) +} + +func isCACRL(revocationList *x509.RevocationList, issuerCerts []*x509.Certificate) bool { + if signer := SigningCertFromPool(revocationList, issuerCerts); signer != nil && signer.IsCA { + return true + } + return inferCACRLFromValidity(revocationList) +} + +// inferCACRLFromValidity applies BR 7.2: subscriber-scope CRLs must have +// nextUpdate within 10 days of thisUpdate; a longer window implies the other CRL profile. +func inferCACRLFromValidity(revocationList *x509.RevocationList) bool { + if revocationList == nil || revocationList.NextUpdate.IsZero() { + return false + } + return revocationList.NextUpdate.Sub(revocationList.ThisUpdate) > subscriberCRLMaxInterval +} diff --git a/internal/crl/zcrypto/builder.go b/internal/crl/zcrypto/builder.go index 222fee3..598a20a 100644 --- a/internal/crl/zcrypto/builder.go +++ b/internal/crl/zcrypto/builder.go @@ -25,29 +25,6 @@ func BuildTree(crl *x509.RevocationList) *node.Node { return NewCRLBuilder().Build(crl) } -// BuildTreeWithChain builds CRL node tree with CA status determined from issuer chain. -// isCACRL is set to true if the CRL issuer is a CA certificate (Root or Intermediate). -func BuildTreeWithChain(crl *x509.RevocationList, issuerCerts []*x509.Certificate) *node.Node { - n := buildCRL(crl) - if n == nil { - return nil - } - - // Determine if CRL issuer is a CA - isCACRL := false - crlIssuer := crl.Issuer.String() - - for _, cert := range issuerCerts { - if cert != nil && cert.Subject.String() == crlIssuer { - isCACRL = cert.IsCA - break - } - } - - n.Children["isCACRL"] = node.New("isCACRL", isCACRL) - return n -} - func buildCRL(crl *x509.RevocationList) *node.Node { root := node.New("crl", nil) diff --git a/internal/data/loader.go b/internal/data/loader.go index d85425f..5f5a5e2 100644 --- a/internal/data/loader.go +++ b/internal/data/loader.go @@ -5,7 +5,8 @@ // - IANA Root Zone Database TLD list // // Data files can be updated via: -// pcl --update-data +// +// pcl --update-data package data import ( @@ -111,15 +112,16 @@ func (l *Loader) LoadPSL(filename string) error { // parsePSLFile parses the Public Suffix List file format. // // Format (from publicsuffix.org): -// // ===BEGIN ICANN DOMAINS=== -// com -// net -// ... -// // ===END ICANN DOMAINS=== -// // ===BEGIN PRIVATE DOMAINS=== -// github.io -// ... -// // ===END PRIVATE DOMAINS=== +// +// // ===BEGIN ICANN DOMAINS=== +// com +// net +// ... +// // ===END ICANN DOMAINS=== +// // ===BEGIN PRIVATE DOMAINS=== +// github.io +// ... +// // ===END PRIVATE DOMAINS=== func parsePSLFile(filePath string) (*PSL, error) { file, err := os.Open(filePath) if err != nil { @@ -328,4 +330,4 @@ func UpdateData(dataDir string) error { fmt.Printf("Successfully loaded PSL: %d ICANN domains, %d private domains\n", icann, private) return nil -} \ No newline at end of file +} diff --git a/internal/data/loader_test.go b/internal/data/loader_test.go index 6db3a07..b04bd82 100644 --- a/internal/data/loader_test.go +++ b/internal/data/loader_test.go @@ -130,13 +130,13 @@ co.uk domain string want bool }{ - {"example.com", true}, // TLD = com - {"test.net", true}, // TLD = net - {"example.org", true}, // TLD = org - {"example.uk", true}, // TLD = uk - {"example.test", false}, // TLD = test (not in list) - {"example.local", false}, // TLD = local (not in list) - {"localhost", false}, // No TLD + {"example.com", true}, // TLD = com + {"test.net", true}, // TLD = net + {"example.org", true}, // TLD = org + {"example.uk", true}, // TLD = uk + {"example.test", false}, // TLD = test (not in list) + {"example.local", false}, // TLD = local (not in list) + {"localhost", false}, // No TLD } for _, tt := range tests { @@ -237,13 +237,13 @@ func TestPSLWildcardDomains(t *testing.T) { // Test wildcard matching logic separately tests := []struct { - domain string - wildcard string - expected bool + domain string + wildcard string + expected bool }{ {"com.ck", "*.ck", true}, {"edu.ck", "*.ck", true}, - {"ck", "*.ck", false}, // TLD itself doesn't match wildcard + {"ck", "*.ck", false}, // TLD itself doesn't match wildcard {"example.jp", "*.jp", true}, } @@ -274,4 +274,4 @@ func matchesWildcard(domain, wildcard string) bool { } return false -} \ No newline at end of file +} diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index 4270b9f..7f5096c 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -2,10 +2,12 @@ package evaluator import ( + "io" + "time" + "github.com/cavoq/PCL/internal/cert" certzcrypto "github.com/cavoq/PCL/internal/cert/zcrypto" "github.com/cavoq/PCL/internal/crl" - crlzcrypto "github.com/cavoq/PCL/internal/crl/zcrypto" "github.com/cavoq/PCL/internal/node" "github.com/cavoq/PCL/internal/ocsp" ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" @@ -23,6 +25,12 @@ type Context struct { CRLs []*crl.Info OCSPs []*ocsp.Info Chain []*cert.Info + + // CRL issuer discovery for isCACRL (optional). When set, PCL may fetch CA + // Issuers URLs from the chain to locate the CRL signing certificate. + CRLResolveTimeout time.Duration + CRLResolveMaxDepth int + CRLResolveWarn io.Writer } func Chain(ctx Context) []policy.Result { @@ -39,7 +47,7 @@ func Chain(ctx Context) []policy.Result { if len(ctx.CRLs) > 0 { for _, crlInfo := range ctx.CRLs { if crlInfo.CRL != nil { - crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + crlNode := crl.BuildTree(crlInfo.CRL) if crlNode != nil { tree.Children["crl"] = crlNode } @@ -94,7 +102,7 @@ func OCSP(ctx Context) []policy.Result { } if ocspInfo.Response.Certificate != nil { - results = append(results, ocspSigningCert(ctx.Policies, ctx.Registry, ctx.OCSPs, ocspInfo, ctx.Chain)...) + results = append(results, ocspSigningCert(ctx, ocspInfo)...) } } @@ -105,13 +113,13 @@ func CRL(ctx Context) []policy.Result { var results []policy.Result for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL == nil { + if crlInfo == nil || crlInfo.CRL == nil { continue } - issuerCerts := ExtractCertsFromInfo(ctx.Chain) + issuerCerts := issuerCertsForCRL(ctx, crlInfo.CRL) - crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) + crlNode := crl.BuildTreeWithChain(crlInfo.CRL, issuerCerts) if crlNode == nil { continue } @@ -153,7 +161,7 @@ func OCSPOnly(policies []policy.Policy, registry *operator.Registry, ocsps []*oc }) } -func ocspSigningCert(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info, ocspInfo *ocsp.Info, chain []*cert.Info) []policy.Result { +func ocspSigningCert(ctx Context, ocspInfo *ocsp.Info) []policy.Result { zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) if err != nil || zcryptoSignerCert == nil { return nil @@ -167,26 +175,42 @@ func ocspSigningCert(policies []policy.Policy, registry *operator.Registry, ocsp Source: source.Info{Type: source.Extracted, Description: "extracted from OCSP response"}, } - evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - evalCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, evalOpts...) + signerChain := ocsp.BuildSignerEvalChain( + zcryptoSignerCert, + ocspSignerInfo, + ctx.Chain, + ctx.CRLResolveTimeout, + ctx.CRLResolveMaxDepth, + ctx.CRLResolveWarn, + ) + + evalOpts := []operator.ContextOption{operator.WithOCSPs(ctx.OCSPs)} + evalCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, signerChain, evalOpts...) var results []policy.Result - signerPolicies := policy.ByCertificate(policies, zcryptoSignerCert) + signerPolicies := policy.ByCertificate(ctx.Policies, zcryptoSignerCert) for _, p := range signerPolicies { - res := policy.Evaluate(p, ocspSignerTree, registry, evalCtx) + res := policy.Evaluate(p, ocspSignerTree, ctx.Registry, evalCtx) results = append(results, res) } return results } +func issuerCertsForCRL(ctx Context, revocationList *x509.RevocationList) []*x509.Certificate { + if ctx.CRLResolveTimeout > 0 && ctx.CRLResolveMaxDepth > 0 { + return crl.ResolveIssuerCerts( + ctx.Chain, + revocationList, + ctx.CRLResolveTimeout, + ctx.CRLResolveMaxDepth, + ctx.CRLResolveWarn, + ) + } + return cert.CertsFromInfos(ctx.Chain) +} + // ExtractCertsFromInfo extracts x509 certificates from cert.Info values. func ExtractCertsFromInfo(infos []*cert.Info) []*x509.Certificate { - var certs []*x509.Certificate - for _, info := range infos { - if info.Cert != nil { - certs = append(certs, info.Cert) - } - } - return certs + return cert.CertsFromInfos(infos) } diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go index 79570f2..dc35945 100644 --- a/internal/evaluator/evaluator_test.go +++ b/internal/evaluator/evaluator_test.go @@ -1,10 +1,24 @@ package evaluator import ( + "crypto/rand" + "crypto/rsa" + cryptox509 "crypto/x509" + cryptopkix "crypto/x509/pkix" + "math/big" + "net/http" + "net/http/httptest" + "path/filepath" "testing" + "time" "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/crl" "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/source" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" ) func TestChainWithEmptyChain(t *testing.T) { @@ -75,3 +89,179 @@ func TestContextDefaults(t *testing.T) { t.Errorf("expected nil OCSPs in default context") } } + +func TestIssuerCertsForCRL_withResolveEnabled(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CA"}, + SubjectKeyId: []byte{0x01}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CA"}, + AuthorityKeyId: []byte{0x01}, + } + chain := []*cert.Info{{Cert: signer}} + + ctx := Context{ + Chain: chain, + CRLResolveTimeout: time.Second, + CRLResolveMaxDepth: 1, + } + pool := issuerCertsForCRL(ctx, revocationList) + if len(pool) != 1 || pool[0] != signer { + t.Fatalf("issuerCertsForCRL() = %v, want chain signer", pool) + } +} + +func TestIssuerCertsForCRL_withoutResolve(t *testing.T) { + signer := &x509.Certificate{SerialNumber: big.NewInt(1)} + chain := []*cert.Info{{Cert: signer}} + ctx := Context{Chain: chain} + + pool := issuerCertsForCRL(ctx, &x509.RevocationList{}) + if len(pool) != 1 || pool[0] != signer { + t.Fatalf("issuerCertsForCRL() = %v", pool) + } +} + +func TestCRL_setsIsCACRLNode(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CRL CA"}, + SubjectKeyId: []byte{0x0c}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CRL CA"}, + AuthorityKeyId: []byte{0x0c}, + ThisUpdate: now, + NextUpdate: now.Add(7 * 24 * time.Hour), + } + + ctx := Context{ + Policies: nil, + Registry: operator.DefaultRegistry(), + CRLs: []*crl.Info{{ + CRL: revocationList, + FilePath: "test.crl", + }}, + Chain: []*cert.Info{{Cert: signer}}, + } + + results := CRL(ctx) + if len(results) != 0 { + t.Fatalf("expected 0 policy results with nil policies, got %d", len(results)) + } + + pool := issuerCertsForCRL(ctx, revocationList) + tree := crl.BuildTreeWithChain(revocationList, pool) + if tree.Children["isCACRL"].Value != true { + t.Fatalf("isCACRL = %v, want true", tree.Children["isCACRL"].Value) + } +} + +func TestIssuerCertsForCRL_withoutResolveFlags(t *testing.T) { + chain := []*cert.Info{{Cert: &x509.Certificate{SerialNumber: big.NewInt(1)}}} + ctx := Context{Chain: chain, CRLResolveTimeout: 0} + pool := issuerCertsForCRL(ctx, &x509.RevocationList{}) + if len(pool) != 1 { + t.Fatalf("issuerCertsForCRL() = %v, want chain only", pool) + } +} + +func TestCRL_skipsNilEntries(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + valid := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CRL CA"}, + AuthorityKeyId: []byte{0x0d}, + ThisUpdate: now, + NextUpdate: now.Add(time.Hour), + } + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CRL CA"}, + SubjectKeyId: []byte{0x0d}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + + pol, err := policy.ParseFile(filepath.Join("..", "..", "tests", "policies", "crl-validity.yaml")) + if err != nil { + t.Fatalf("load policy: %v", err) + } + + ctx := Context{ + Policies: []policy.Policy{pol}, + Registry: operator.DefaultRegistry(), + CRLs: []*crl.Info{nil, {CRL: nil}, {CRL: valid, FilePath: "test.crl"}}, + Chain: []*cert.Info{{Cert: signer}}, + CRLResolveTimeout: time.Second, + CRLResolveMaxDepth: 1, + } + results := CRL(ctx) + if len(results) == 0 { + t.Fatal("expected CRL policy results for valid CRL entry") + } +} + +func TestCRL_resolvesIssuerViaAIA(t *testing.T) { + parentDER, parent := testEvaluatorCRLCA(t) + 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 DN"}, + AuthorityKeyId: []byte{0x01, 0x02}, + ThisUpdate: time.Now().UTC().Add(-time.Hour), + NextUpdate: time.Now().UTC().Add(time.Hour), + } + + ctx := Context{ + Registry: operator.DefaultRegistry(), + CRLs: []*crl.Info{{CRL: revocationList, FilePath: "fetched.crl", Source: source.Info{Type: source.Local}}}, + Chain: []*cert.Info{{Cert: leaf}}, + CRLResolveTimeout: time.Second, + CRLResolveMaxDepth: 2, + } + pool := issuerCertsForCRL(ctx, revocationList) + if len(pool) < 2 { + t.Fatalf("issuerCertsForCRL() len = %d, want fetched signer", len(pool)) + } +} + +func testEvaluatorCRLCA(t *testing.T) ([]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: "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}, + } + 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/evaluator/ocsp_signing_test.go b/internal/evaluator/ocsp_signing_test.go new file mode 100644 index 0000000..b9885b2 --- /dev/null +++ b/internal/evaluator/ocsp_signing_test.go @@ -0,0 +1,159 @@ +package evaluator + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + cryptox509 "crypto/x509" + cryptopkix "crypto/x509/pkix" + "math/big" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/ocsp" + "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/rule" + stdocsp "golang.org/x/crypto/ocsp" + zx509 "github.com/zmap/zcrypto/x509" +) + +func TestOCSP_skipsNilResponse(t *testing.T) { + ctx := Context{ + Registry: operator.DefaultRegistry(), + OCSPs: []*ocsp.Info{{Response: nil, FilePath: "empty.ocsp"}}, + } + if len(OCSP(ctx)) != 0 { + t.Fatal("nil OCSP response should produce no results") + } +} + +func TestOcspSigningCert_invalidEmbeddedCert(t *testing.T) { + ctx := Context{Registry: operator.DefaultRegistry()} + results := ocspSigningCert(ctx, &ocsp.Info{ + Response: &stdocsp.Response{Certificate: &cryptox509.Certificate{}}, + FilePath: "bad.ocsp", + }) + if len(results) != 0 { + t.Fatalf("invalid embedded cert should yield no results, got %d", len(results)) + } +} + +func TestOCSP_runsOcspTargetPolicy(t *testing.T) { + resp := &stdocsp.Response{ + Status: stdocsp.Good, + ThisUpdate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + ctx := Context{ + Policies: []policy.Policy{{ + ID: "ocsp-only", + Rules: []rule.Rule{{ + ID: "ocsp-status-good", + Target: "ocsp.status", + Operator: "eq", + Operands: []any{"Good"}, + Severity: "error", + }}, + }}, + Registry: operator.DefaultRegistry(), + OCSPs: []*ocsp.Info{{Response: resp, FilePath: "inline.ocsp"}}, + } + results := OCSP(ctx) + if len(results) == 0 { + t.Fatal("expected OCSP-target policy results") + } +} + +func TestOCSP_evaluatesEmbeddedSigningCert(t *testing.T) { + issuerKey, _ := rsa.GenerateKey(rand.Reader, 2048) + responderKey, _ := rsa.GenerateKey(rand.Reader, 2048) + + issuerTemplate := &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: "OCSP Issuer 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, + } + issuerDER, err := cryptox509.CreateCertificate(rand.Reader, issuerTemplate, issuerTemplate, &issuerKey.PublicKey, issuerKey) + if err != nil { + t.Fatalf("create issuer: %v", err) + } + issuerStd, err := cryptox509.ParseCertificate(issuerDER) + if err != nil { + t.Fatalf("parse issuer: %v", err) + } + + responderTemplate := &cryptox509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: cryptopkix.Name{CommonName: "OCSP Responder"}, + NotBefore: issuerTemplate.NotBefore, + NotAfter: issuerTemplate.NotAfter, + KeyUsage: cryptox509.KeyUsageDigitalSignature, + ExtKeyUsage: []cryptox509.ExtKeyUsage{cryptox509.ExtKeyUsageOCSPSigning}, + AuthorityKeyId: []byte{0x01}, + } + responderDER, err := cryptox509.CreateCertificate(rand.Reader, responderTemplate, issuerStd, &responderKey.PublicKey, issuerKey) + if err != nil { + t.Fatalf("create responder: %v", err) + } + responderStd, err := cryptox509.ParseCertificate(responderDER) + if err != nil { + t.Fatalf("parse responder: %v", err) + } + + ocspDER, err := stdocsp.CreateResponse(issuerStd, responderStd, stdocsp.Response{ + Status: stdocsp.Good, + SerialNumber: big.NewInt(100), + ThisUpdate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + NextUpdate: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + IssuerHash: crypto.SHA256, + }, responderKey) + if err != nil { + t.Fatalf("CreateResponse: %v", err) + } + resp, err := stdocsp.ParseResponse(ocspDER, nil) + if err != nil { + t.Fatalf("ParseResponse: %v", err) + } + // golang.org/x/crypto/ocsp may omit Certificate; evaluator still needs it. + resp.Certificate = responderStd + + issuerZ, err := zx509.ParseCertificate(issuerDER) + if err != nil { + t.Fatalf("parse issuer zcrypto: %v", err) + } + + pol, err := policy.ParseFile(filepath.Join("..", "..", "tests", "policies", "basic.yaml")) + if err != nil { + t.Fatalf("load policy: %v", err) + } + + ctx := Context{ + Policies: []policy.Policy{pol}, + Registry: operator.DefaultRegistry(), + OCSPs: []*ocsp.Info{{Response: resp, FilePath: "test.ocsp"}}, + Chain: []*cert.Info{{Cert: issuerZ, FilePath: "issuer.pem", Type: "root"}}, + } + + results := OCSP(ctx) + if len(results) == 0 { + t.Fatal("expected OCSP policy results") + } + + var sawSigning bool + for _, r := range results { + if r.CertType == "ocspSigning" || strings.Contains(r.CertPath, "signing cert") { + sawSigning = true + break + } + } + if !sawSigning { + t.Fatalf("expected policy results for embedded OCSP signing cert, got %d results", len(results)) + } +} diff --git a/internal/linter/runner.go b/internal/linter/runner.go index ec39f72..83c75e9 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -153,16 +153,34 @@ func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Reg return nil, cleanup } - // Auto-validate: climb chain via CA Issuers URLs + // Auto-validate: climb chain via CA Issuers URLs (pool fallback when no CaIssuers) if cfg.AutoValidate && !cfg.NoAutoChain { + pool := append([]*cert.Info{}, allCerts...) var climbedCerts []*cert.Info for _, c := range certs { if c.Cert == nil { continue } miniChain := []*cert.Info{c} - miniChain = cert.ClimbChain(miniChain, cfg.CertTimeout, cfg.MaxChainDepth, w) + miniChain = cert.ClimbChainWithPool(miniChain, pool, cfg.CertTimeout, cfg.MaxChainDepth, w) climbedCerts = append(climbedCerts, miniChain...) + for _, info := range miniChain { + if info == nil || info.Cert == nil || info.Cert.SerialNumber == nil { + continue + } + serial := info.Cert.SerialNumber.String() + already := false + for _, existing := range pool { + if existing != nil && existing.Cert != nil && existing.Cert.SerialNumber != nil && + existing.Cert.SerialNumber.String() == serial { + already = true + break + } + } + if !already { + pool = append(pool, info) + } + } } allCerts = append(climbedCerts, issuers...) } @@ -196,11 +214,14 @@ func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Reg } evalCtx := evaluator.Context{ - Policies: policies, - Registry: reg, - CRLs: crls, - OCSPs: ocsps, - Chain: chain, + Policies: policies, + Registry: reg, + CRLs: crls, + OCSPs: ocsps, + Chain: chain, + CRLResolveTimeout: crlResolveTimeout(cfg), + CRLResolveMaxDepth: crlResolveMaxDepth(cfg), + CRLResolveWarn: w, } results := evaluator.Chain(evalCtx) @@ -230,6 +251,20 @@ func outputResults(cfg Config, results []policy.Result, w io.Writer) error { return formatter.Format(w, lintOutput) } +func crlResolveTimeout(cfg Config) time.Duration { + if cfg.CertTimeout > 0 { + return cfg.CertTimeout + } + return cfg.OCSPTimeout +} + +func crlResolveMaxDepth(cfg Config) int { + if cfg.MaxChainDepth > 0 { + return cfg.MaxChainDepth + } + return 10 +} + func applyDefaults(cfg *Config) { if cfg.CertTimeout <= 0 { cfg.CertTimeout = 10 * time.Second diff --git a/internal/linter/runner_test.go b/internal/linter/runner_test.go index 7a39ecf..094305d 100644 --- a/internal/linter/runner_test.go +++ b/internal/linter/runner_test.go @@ -1,12 +1,21 @@ package linter import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "os" "path/filepath" "testing" "time" "github.com/cavoq/PCL/internal/ocsp" + "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/policy" ) func TestApplyDefaults(t *testing.T) { @@ -318,3 +327,173 @@ func TestLoadIssuersIfProvided(t *testing.T) { t.Errorf("expected nil cleanup") } } + +func TestCrlResolveTimeout_prefersCertTimeout(t *testing.T) { + cfg := Config{ + CertTimeout: 30 * time.Second, + OCSPTimeout: 5 * time.Second, + } + if got := crlResolveTimeout(cfg); got != 30*time.Second { + t.Fatalf("crlResolveTimeout() = %v, want 30s", got) + } +} + +func TestCrlResolveTimeout_fallsBackToOCSPTimeout(t *testing.T) { + cfg := Config{OCSPTimeout: 7 * time.Second} + if got := crlResolveTimeout(cfg); got != 7*time.Second { + t.Fatalf("crlResolveTimeout() = %v, want 7s", got) + } +} + +func TestCrlResolveMaxDepth_usesConfig(t *testing.T) { + cfg := Config{MaxChainDepth: 3} + if got := crlResolveMaxDepth(cfg); got != 3 { + t.Fatalf("crlResolveMaxDepth() = %d, want 3", got) + } +} + +func TestCrlResolveMaxDepth_default(t *testing.T) { + cfg := Config{} + if got := crlResolveMaxDepth(cfg); got != 10 { + t.Fatalf("crlResolveMaxDepth() = %d, want 10", got) + } +} + +func TestProcessCertificates_withCRLAndResolve(t *testing.T) { + crlPath := filepath.Join("..", "..", "internal", "crl", "testdata", "test.crl") + crls, err := loadCRLs(crlPath) + if err != nil { + t.Fatalf("loadCRLs: %v", err) + } + + certDir := filepath.Join("..", "..", "tests", "certs") + cfg := Config{ + CertPath: filepath.Join(certDir, "leaf.pem"), + IssuerPaths: []string{ + filepath.Join(certDir, "intermediate.pem"), + filepath.Join(certDir, "root.pem"), + }, + CertTimeout: 5 * time.Second, + OCSPTimeout: 5 * time.Second, + MaxChainDepth: 10, + } + issuers, issuerCleanup, err := loadIssuersIfProvided(cfg, true) + if err != nil { + t.Fatalf("loadIssuersIfProvided: %v", err) + } + + pol, err := policy.ParseFile(filepath.Join("..", "..", "tests", "policies", "crl-validity.yaml")) + if err != nil { + t.Fatalf("load policy: %v", err) + } + + reg := operator.DefaultRegistry() + var buf bytes.Buffer + results, cleanup := processCertificates(cfg, []policy.Policy{pol}, reg, crls, nil, issuers, issuerCleanup, &buf) + if cleanup != nil { + defer cleanup() + } + if results == nil { + t.Fatal("expected results slice from processCertificates") + } +} + +func TestProcessCertificates_autoValidateExtendsChainFromIssuerPool(t *testing.T) { + dir := t.TempDir() + 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: "Trusted Root"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x01}, + } + parentDER, err := x509.CreateCertificate(rand.Reader, parentStd, parentStd, &parentKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create parent: %v", err) + } + parentPath := filepath.Join(dir, "root.pem") + if err := os.WriteFile(parentPath, pemEncodeCert(parentDER), 0644); err != nil { + t.Fatalf("write parent: %v", err) + } + + 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{0x02}, + } + interDER, err := x509.CreateCertificate(rand.Reader, interStd, parentStd, &interKey.PublicKey, parentKey) + if err != nil { + t.Fatalf("create intermediate: %v", err) + } + interPath := filepath.Join(dir, "inter.cer") + if err := os.WriteFile(interPath, interDER, 0644); err != nil { + t.Fatalf("write intermediate: %v", err) + } + + leafStd := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "subscriber.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) + } + leafPath := filepath.Join(dir, "leaf.pem") + if err := os.WriteFile(leafPath, pemEncodeCert(leafDER), 0644); err != nil { + t.Fatalf("write leaf: %v", err) + } + + cfg := Config{ + CertPath: leafPath, + IssuerPaths: []string{interPath, parentPath}, + AutoValidate: true, + NoAutoCRL: true, + NoAutoOCSP: true, + CertTimeout: 5 * time.Second, + MaxChainDepth: 5, + } + applyDefaults(&cfg) + + pol, err := policy.ParseFile(filepath.Join("..", "..", "tests", "policies", "basic.yaml")) + if err != nil { + t.Fatalf("load policy: %v", err) + } + + reg := operator.DefaultRegistry() + var buf bytes.Buffer + results, cleanup := processCertificates(cfg, []policy.Policy{pol}, reg, nil, nil, nil, nil, &buf) + if cleanup != nil { + defer cleanup() + } + if results == nil { + t.Fatal("expected results from auto-validate with issuer pool") + } +} + +func pemEncodeCert(der []byte) []byte { + block := &pem.Block{Type: "CERTIFICATE", Bytes: der} + return pem.EncodeToMemory(block) +} diff --git a/internal/ocsp/issuer.go b/internal/ocsp/issuer.go new file mode 100644 index 0000000..bcb0a15 --- /dev/null +++ b/internal/ocsp/issuer.go @@ -0,0 +1,127 @@ +package ocsp + +import ( + "bytes" + "io" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/zmap/zcrypto/x509" +) + +// SigningIssuerFromPool returns the certificate that signed signer. DN/AKI hints +// alone are not used (same policy as cert.findSigningParentInPool). +func SigningIssuerFromPool(signer *x509.Certificate, pool []*x509.Certificate) *x509.Certificate { + if signer == nil { + return nil + } + for _, c := range pool { + if c != nil && signer.CheckSignatureFrom(c) == nil { + return c + } + } + return nil +} + +func certMatchesSignerIssuer(signer, candidate *x509.Certificate) bool { + if signer == nil || candidate == nil { + return false + } + if signer.Issuer.String() == candidate.Subject.String() { + return true + } + if len(signer.AuthorityKeyId) > 0 && len(candidate.SubjectKeyId) > 0 { + return bytes.Equal(signer.AuthorityKeyId, candidate.SubjectKeyId) + } + return false +} + +// ResolveSignerIssuerPool returns TLS chain certificates plus any issuer needed to +// verify the OCSP responder certificate (AKI/signature match or CA Issuers fetch). +func ResolveSignerIssuerPool( + tlsChain []*cert.Info, + signer *x509.Certificate, + timeout time.Duration, + maxDepth int, + w io.Writer, +) []*x509.Certificate { + pool := cert.CertsFromInfos(tlsChain) + if signer == nil { + return pool + } + if SigningIssuerFromPool(signer, pool) != nil { + return pool + } + return cert.CollectViaCAIssuers(append(pool, signer), cert.AIACollectConfig{ + Timeout: timeout, + MaxDepth: maxDepth, + Warn: w, + StopWhen: func(c *x509.Certificate) bool { + return signer.CheckSignatureFrom(c) == nil + }, + }) +} + +// BuildSignerEvalChain returns [OCSP responder, issuer, …] for signature-valid and +// AKI checks. The responder is always index 0; the tail is taken from tlsChain when +// the issuer is already on the TLS path, otherwise from pool lookup and AIA climb. +func BuildSignerEvalChain( + signer *x509.Certificate, + signerInfo *cert.Info, + tlsChain []*cert.Info, + timeout time.Duration, + maxDepth int, + w io.Writer, +) []*cert.Info { + if signer == nil || signerInfo == nil { + return tlsChain + } + + pool := ResolveSignerIssuerPool(tlsChain, signer, timeout, maxDepth, w) + issuerCert := SigningIssuerFromPool(signer, pool) + if issuerCert == nil { + out := []*cert.Info{signerInfo} + cert.RebuildChainMetadata(out) + return out + } + + if idx, ok := indexInInfoChain(issuerCert, tlsChain); ok { + out := append([]*cert.Info{signerInfo}, tlsChain[idx:]...) + cert.RebuildChainMetadata(out) + return out + } + + issuerInfo := infoFromPoolCert(issuerCert, tlsChain) + tail := []*cert.Info{issuerInfo} + poolInfos := append([]*cert.Info(nil), tlsChain...) + poolInfos = append(poolInfos, tail...) + tail = cert.ClimbChainWithPool(tail, poolInfos, timeout, maxDepth, w) + + out := append([]*cert.Info{signerInfo}, tail...) + cert.RebuildChainMetadata(out) + return out +} + +func indexInInfoChain(target *x509.Certificate, chain []*cert.Info) (int, bool) { + if target == nil || target.SerialNumber == nil { + return 0, false + } + serial := target.SerialNumber.String() + for i, info := range chain { + if info != nil && info.Cert != nil && info.Cert.SerialNumber != nil && + info.Cert.SerialNumber.String() == serial { + return i, true + } + } + return 0, false +} + +func infoFromPoolCert(c *x509.Certificate, tlsChain []*cert.Info) *cert.Info { + if c == nil { + return nil + } + if idx, ok := indexInInfoChain(c, tlsChain); ok { + return tlsChain[idx] + } + return &cert.Info{Cert: c} +} diff --git a/internal/ocsp/issuer_test.go b/internal/ocsp/issuer_test.go new file mode 100644 index 0000000..9840bc7 --- /dev/null +++ b/internal/ocsp/issuer_test.go @@ -0,0 +1,358 @@ +package ocsp + +import ( + "crypto/rand" + "crypto/rsa" + cryptox509 "crypto/x509" + cryptopkix "crypto/x509/pkix" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cavoq/PCL/internal/cert" + zx509 "github.com/zmap/zcrypto/x509" + zx509pkix "github.com/zmap/zcrypto/x509/pkix" +) + +func TestSigningIssuerFromPool_prefersSignature(t *testing.T) { + caKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ocspKey, _ := rsa.GenerateKey(rand.Reader, 2048) + + ca := mustZX509Cert(t, caKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: "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}, + }, nil) + + spoof := mustZX509Cert(t, caKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: cryptopkix.Name{CommonName: "CA"}, + SubjectKeyId: []byte{0x01}, + }, nil) + + signer := mustZX509Cert(t, ocspKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: cryptopkix.Name{CommonName: "OCSP"}, + Issuer: pkixNameToStd(ca.Subject), + AuthorityKeyId: ca.SubjectKeyId, + NotBefore: ca.NotBefore, + NotAfter: ca.NotAfter, + ExtKeyUsage: []cryptox509.ExtKeyUsage{cryptox509.ExtKeyUsageOCSPSigning}, + KeyUsage: cryptox509.KeyUsageDigitalSignature, + SubjectKeyId: []byte{0x02}, + }, toStdCert(ca)) + + got := SigningIssuerFromPool(signer, []*zx509.Certificate{spoof, ca}) + if got != ca { + t.Fatalf("SigningIssuerFromPool() = serial %v, want CA serial %v", got.SerialNumber, ca.SerialNumber) + } +} + +func TestBuildSignerEvalChain_usesTLSChainSuffix(t *testing.T) { + caKey, _ := rsa.GenerateKey(rand.Reader, 2048) + leafKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ocspKey, _ := rsa.GenerateKey(rand.Reader, 2048) + + root := mustZX509Cert(t, caKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: "Root"}, + 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{0x10}, + }, nil) + + intermediate := mustZX509Cert(t, caKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: cryptopkix.Name{CommonName: "Intermediate"}, + Issuer: pkixNameToStd(root.Subject), + AuthorityKeyId: root.SubjectKeyId, + NotBefore: root.NotBefore, + NotAfter: root.NotAfter, + KeyUsage: cryptox509.KeyUsageCertSign | cryptox509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + SubjectKeyId: []byte{0x11}, + }, toStdCert(root)) + + leaf := mustZX509Cert(t, leafKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: cryptopkix.Name{CommonName: "Leaf"}, + Issuer: pkixNameToStd(intermediate.Subject), + NotBefore: root.NotBefore, + NotAfter: root.NotAfter, + }, toStdCert(intermediate)) + + ocspSigner := mustZX509Cert(t, ocspKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(4), + Subject: cryptopkix.Name{CommonName: "OCSP"}, + Issuer: pkixNameToStd(intermediate.Subject), + AuthorityKeyId: intermediate.SubjectKeyId, + NotBefore: root.NotBefore, + NotAfter: root.NotAfter, + ExtKeyUsage: []cryptox509.ExtKeyUsage{cryptox509.ExtKeyUsageOCSPSigning}, + KeyUsage: cryptox509.KeyUsageDigitalSignature, + }, toStdCert(intermediate)) + + tlsChain := []*cert.Info{ + {Cert: leaf, FilePath: "leaf.pem"}, + {Cert: intermediate, FilePath: "intermediate.pem"}, + {Cert: root, FilePath: "root.pem"}, + } + signerInfo := &cert.Info{Cert: ocspSigner, FilePath: "ocsp.pem (signing cert)", Type: "ocspSigning"} + + got := BuildSignerEvalChain(ocspSigner, signerInfo, tlsChain, 0, 0, nil) + if len(got) != 3 { + t.Fatalf("BuildSignerEvalChain() len = %d, want 3", len(got)) + } + if got[0].Cert != ocspSigner { + t.Fatal("position 0 should be OCSP responder") + } + if got[1].Cert != intermediate || got[2].Cert != root { + t.Fatalf("tail = %q -> %q, want intermediate -> root", + got[1].Cert.Subject.CommonName, got[2].Cert.Subject.CommonName) + } + if got[0].Type != "ocspSigning" { + t.Fatalf("signer type = %q, want ocspSigning", got[0].Type) + } + if got[1].Type != "intermediate" || got[2].Type != "root" { + t.Fatalf("types = %q, %q", got[1].Type, got[2].Type) + } +} + +func TestBuildSignerEvalChain_resolvesViaAIA(t *testing.T) { + ocspKey, _ := rsa.GenerateKey(rand.Reader, 2048) + + parentDER, parent, parentKey := testOCSPParentCA(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(parentDER) + })) + defer server.Close() + + signer := mustZX509Cert(t, ocspKey, parentKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: cryptopkix.Name{CommonName: "OCSP"}, + Issuer: pkixNameToStd(parent.Subject), + AuthorityKeyId: parent.SubjectKeyId, + NotBefore: parent.NotBefore, + NotAfter: parent.NotAfter, + IssuingCertificateURL: []string{server.URL}, + ExtKeyUsage: []cryptox509.ExtKeyUsage{cryptox509.ExtKeyUsageOCSPSigning}, + KeyUsage: cryptox509.KeyUsageDigitalSignature, + }, toStdCert(parent)) + + tlsChain := []*cert.Info{} + signerInfo := &cert.Info{Cert: signer, FilePath: "ocsp.pem"} + + got := BuildSignerEvalChain(signer, signerInfo, tlsChain, time.Second, 2, nil) + if len(got) < 2 { + t.Fatalf("BuildSignerEvalChain() len = %d, want responder + issuer", len(got)) + } + if got[0].Cert != signer { + t.Fatal("position 0 should be OCSP responder") + } + if got[1].Cert.Subject.CommonName != parent.Subject.CommonName { + t.Fatalf("issuer CN = %q, want %q", got[1].Cert.Subject.CommonName, parent.Subject.CommonName) + } +} + +func pkixNameToStd(n zx509pkix.Name) cryptopkix.Name { + return cryptopkix.Name{ + Country: n.Country, + Organization: n.Organization, + OrganizationalUnit: n.OrganizationalUnit, + Locality: n.Locality, + Province: n.Province, + StreetAddress: n.StreetAddress, + PostalCode: n.PostalCode, + SerialNumber: n.SerialNumber, + CommonName: n.CommonName, + } +} + +func toStdCert(c *zx509.Certificate) *cryptox509.Certificate { + if c == nil { + return nil + } + std, err := cryptox509.ParseCertificate(c.Raw) + if err != nil { + return &cryptox509.Certificate{Raw: c.Raw} + } + return std +} + +func mustZX509Cert(t *testing.T, pubKey, signKey *rsa.PrivateKey, template, parent *cryptox509.Certificate) *zx509.Certificate { + t.Helper() + parentStd := template + if parent != nil { + parentStd = parent + } + signerKey := signKey + if parent == nil { + signerKey = pubKey + } + der, err := cryptox509.CreateCertificate(rand.Reader, template, parentStd, &pubKey.PublicKey, signerKey) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + c, err := zx509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + return c +} + +func testOCSPParentCA(t *testing.T) ([]byte, *zx509.Certificate, *rsa.PrivateKey) { + 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: "OCSP 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{0x0a}, + } + der, err := cryptox509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + parsed, err := zx509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return der, parsed, key +} + +func TestSigningIssuerFromPool_nilSigner(t *testing.T) { + if got := SigningIssuerFromPool(nil, []*zx509.Certificate{{}}); got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestBuildSignerEvalChain_nilSignerReturnsTLSChain(t *testing.T) { + tls := []*cert.Info{{Cert: &zx509.Certificate{SerialNumber: big.NewInt(1)}}} + got := BuildSignerEvalChain(nil, &cert.Info{}, tls, 0, 0, nil) + if len(got) != len(tls) { + t.Fatalf("len = %d, want %d", len(got), len(tls)) + } +} + +func TestCertMatchesSignerIssuer_subjectDN(t *testing.T) { + signer := &zx509.Certificate{ + Issuer: zx509pkix.Name{CommonName: "Issuing CA"}, + } + parent := &zx509.Certificate{ + Subject: zx509pkix.Name{CommonName: "Issuing CA"}, + } + if !certMatchesSignerIssuer(signer, parent) { + t.Fatal("expected issuer/subject DN match") + } + if certMatchesSignerIssuer(nil, parent) || certMatchesSignerIssuer(signer, nil) { + t.Fatal("nil signer or candidate must not match") + } +} + +func TestCertMatchesSignerIssuer_akiOnly(t *testing.T) { + signer := &zx509.Certificate{ + Issuer: zx509pkix.Name{CommonName: "X"}, + AuthorityKeyId: []byte{0xab}, + } + candidate := &zx509.Certificate{ + Subject: zx509pkix.Name{CommonName: "Y"}, + SubjectKeyId: []byte{0xab}, + } + if !certMatchesSignerIssuer(signer, candidate) { + t.Fatal("expected AKI match") + } + if certMatchesSignerIssuer(signer, &zx509.Certificate{SubjectKeyId: []byte{0x00}}) { + t.Fatal("expected no match") + } +} + +func TestResolveSignerIssuerPool_nilSigner(t *testing.T) { + chain := []*cert.Info{{Cert: &zx509.Certificate{SerialNumber: big.NewInt(1)}}} + pool := ResolveSignerIssuerPool(chain, nil, 0, 0, nil) + if len(pool) != 1 { + t.Fatalf("len = %d, want chain-only pool", len(pool)) + } +} + +func TestResolveSignerIssuerPool_signerAlreadyInChain(t *testing.T) { + caKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ocspKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ca := mustZX509Cert(t, caKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: cryptopkix.Name{CommonName: "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}, + }, nil) + signer := mustZX509Cert(t, ocspKey, caKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: cryptopkix.Name{CommonName: "OCSP"}, + Issuer: pkixNameToStd(ca.Subject), + AuthorityKeyId: ca.SubjectKeyId, + NotBefore: ca.NotBefore, + NotAfter: ca.NotAfter, + ExtKeyUsage: []cryptox509.ExtKeyUsage{cryptox509.ExtKeyUsageOCSPSigning}, + KeyUsage: cryptox509.KeyUsageDigitalSignature, + }, toStdCert(ca)) + tlsChain := []*cert.Info{{Cert: ca, FilePath: "ca.pem"}} + pool := ResolveSignerIssuerPool(tlsChain, signer, 0, 0, nil) + if len(pool) != 1 || pool[0] != ca { + t.Fatalf("pool = %v, want unchanged chain pool", pool) + } +} + +func TestBuildSignerEvalChain_noIssuerReturnsResponderOnly(t *testing.T) { + ocspKey, _ := rsa.GenerateKey(rand.Reader, 2048) + signer := mustZX509Cert(t, ocspKey, ocspKey, &cryptox509.Certificate{ + SerialNumber: big.NewInt(99), + Subject: cryptopkix.Name{CommonName: "Orphan OCSP"}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + ExtKeyUsage: []cryptox509.ExtKeyUsage{cryptox509.ExtKeyUsageOCSPSigning}, + KeyUsage: cryptox509.KeyUsageDigitalSignature, + }, nil) + signerInfo := &cert.Info{Cert: signer, FilePath: "ocsp.pem", Type: "ocspSigning"} + got := BuildSignerEvalChain(signer, signerInfo, nil, 0, 0, nil) + if len(got) != 1 || got[0].Cert != signer { + t.Fatalf("got %d entries, want responder only", len(got)) + } +} + +func TestIndexInInfoChain_nilSerial(t *testing.T) { + if _, ok := indexInInfoChain(&zx509.Certificate{SerialNumber: nil}, nil); ok { + t.Fatal("nil serial should not match") + } +} + +func TestInfoFromPoolCert_reusesTLSInfo(t *testing.T) { + ca := &zx509.Certificate{SerialNumber: big.NewInt(7), Subject: zx509pkix.Name{CommonName: "CA"}} + info := &cert.Info{Cert: ca, FilePath: "trusted.pem"} + got := infoFromPoolCert(ca, []*cert.Info{info}) + if got != info { + t.Fatalf("infoFromPoolCert() should reuse chain Info, got %p want %p", got, info) + } + if infoFromPoolCert(nil, nil) != nil { + t.Fatal("nil cert should return nil") + } +} diff --git a/internal/operator/context.go b/internal/operator/context.go index 6a65470..c9edd8f 100644 --- a/internal/operator/context.go +++ b/internal/operator/context.go @@ -34,32 +34,12 @@ func (ctx *EvaluationContext) HasOCSPs() bool { return ctx != nil && len(ctx.OCSPs) > 0 } -// IsCACRL checks if the CRL issuer is a CA certificate in the chain. -// Returns true if the CRL was issued by a Root or Intermediate CA. +// IsCACRL mirrors the crl.isCACRL policy node (signer in chain pool + validity inference). func (ctx *EvaluationContext) IsCACRL(crlInfo *crl.Info) bool { if ctx == nil || crlInfo == nil || crlInfo.CRL == nil { return false } - - if !ctx.HasChain() { - return false - } - - crlIssuer := crlInfo.CRL.Issuer.String() - - for _, certInfo := range ctx.Chain { - if certInfo.Cert == nil { - continue - } - if certInfo.Cert.Subject.String() == crlIssuer { - // Check if this issuer is a CA - if certInfo.Cert.IsCA { - return true - } - } - } - - return false + return crl.IsCACRL(crlInfo.CRL, cert.CertsFromInfos(ctx.Chain)) } type ContextOption func(*EvaluationContext) diff --git a/internal/operator/context_test.go b/internal/operator/context_test.go index 48ace80..11a9b7d 100644 --- a/internal/operator/context_test.go +++ b/internal/operator/context_test.go @@ -1,10 +1,12 @@ package operator import ( + "math/big" "testing" "time" "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" "github.com/cavoq/PCL/internal/cert" "github.com/cavoq/PCL/internal/crl" @@ -245,3 +247,110 @@ func TestHasOCSPs_Valid(t *testing.T) { t.Error("non-empty OCSPs should return true") } } + +func TestIsCACRL_trueForCASigner(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CA"}, + SubjectKeyId: []byte{0x01}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + crlInfo := &crl.Info{ + CRL: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CA"}, + AuthorityKeyId: []byte{0x01}, + }, + } + ctx := &EvaluationContext{ + Chain: []*cert.Info{{Cert: signer}}, + } + + if !ctx.IsCACRL(crlInfo) { + t.Fatal("expected IsCACRL true for CA signer in chain") + } +} + +func TestIsCACRL_falseWithoutChain(t *testing.T) { + ctx := &EvaluationContext{} + if ctx.IsCACRL(&crl.Info{CRL: &x509.RevocationList{}}) { + t.Fatal("expected false without chain") + } +} + +func TestIsCACRL_falseForNonCASigner(t *testing.T) { + endEntity := &x509.Certificate{ + Subject: pkix.Name{CommonName: "EE"}, + SubjectKeyId: []byte{0x02}, + IsCA: false, + SerialNumber: big.NewInt(2), + } + crlInfo := &crl.Info{ + CRL: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "EE"}, + AuthorityKeyId: []byte{0x02}, + }, + } + ctx := &EvaluationContext{Chain: []*cert.Info{{Cert: endEntity}}} + + if ctx.IsCACRL(crlInfo) { + t.Fatal("expected false when signer is not a CA") + } +} + +func TestIsCACRL_nilCRLInfo(t *testing.T) { + ctx := &EvaluationContext{Chain: []*cert.Info{{Cert: &x509.Certificate{}}}} + if ctx.IsCACRL(nil) { + t.Fatal("expected false for nil CRL info") + } +} + +func TestIsCACRL_skipsNilChainCert(t *testing.T) { + signer := &x509.Certificate{ + Subject: pkix.Name{CommonName: "CA"}, + SubjectKeyId: []byte{0x03}, + IsCA: true, + SerialNumber: big.NewInt(1), + } + crlInfo := &crl.Info{ + CRL: &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CA"}, + AuthorityKeyId: []byte{0x03}, + }, + } + ctx := &EvaluationContext{ + Chain: []*cert.Info{{Cert: nil}, {Cert: signer}}, + } + if !ctx.IsCACRL(crlInfo) { + t.Fatal("expected IsCACRL true when signer appears after nil chain entry") + } +} + +// TestIsCACRL_matchesPolicyNodeForValidityInference ensures EvaluationContext +// agrees with the crl.isCACRL node when only validity implies other CRL profile. +func TestIsCACRL_matchesPolicyNodeForValidityInference(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{0xbb}, + IsCA: false, + SerialNumber: big.NewInt(1), + } + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "Subscriber"}, + AuthorityKeyId: []byte{0xbb}, + ThisUpdate: now, + NextUpdate: now.Add(100 * 24 * time.Hour), + } + crlInfo := &crl.Info{CRL: revocationList} + + tree := crl.BuildTreeWithChain(revocationList, []*x509.Certificate{ee}) + nodeVal := tree.Children["isCACRL"].Value + if nodeVal != true { + t.Fatalf("policy node isCACRL = %v, want true (validity inference)", nodeVal) + } + + ctx := &EvaluationContext{Chain: []*cert.Info{{Cert: ee}}} + if ctx.IsCACRL(crlInfo) != nodeVal { + t.Fatalf("EvaluationContext.IsCACRL() = %v, policy node = %v", ctx.IsCACRL(crlInfo), nodeVal) + } +} diff --git a/internal/operator/crl.go b/internal/operator/crl.go index 2bc79f4..62c9f24 100644 --- a/internal/operator/crl.go +++ b/internal/operator/crl.go @@ -1,6 +1,7 @@ package operator import ( + crlpkg "github.com/cavoq/PCL/internal/crl" "github.com/cavoq/PCL/internal/node" "github.com/zmap/zcrypto/x509" ) @@ -15,7 +16,7 @@ func (CRLValid) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool, e } for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL == nil { + if crlInfo == nil || crlInfo.CRL == nil { continue } crl := crlInfo.CRL @@ -41,7 +42,7 @@ func (CRLNotExpired) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bo } for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL == nil { + if crlInfo == nil || crlInfo.CRL == nil { continue } crl := crlInfo.CRL @@ -68,31 +69,25 @@ func (CRLSignedBy) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool } for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL == nil { + if crlInfo == nil || crlInfo.CRL == nil { continue } crl := crlInfo.CRL - // Find matching issuer from chain - crlIssuer := crl.Issuer.String() - var matchingIssuer *x509.Certificate + chainCerts := make([]*x509.Certificate, 0, len(ctx.Chain)) for _, issuerInfo := range ctx.Chain { - if issuerInfo.Cert == nil { - continue - } - if issuerInfo.Cert.Subject.String() == crlIssuer { - matchingIssuer = issuerInfo.Cert - break + if issuerInfo.Cert != nil { + chainCerts = append(chainCerts, issuerInfo.Cert) } } - // If issuer not in chain, skip this CRL (not applicable to our chain) - if matchingIssuer == nil { + // Same selection as isCACRL: prefer signature-verified signer over the + // first DN/AKI match in chain order (see SigningCertFromPool). + signer := crlpkg.SigningCertFromPool(crl, chainCerts) + if signer == nil { continue } - - // Verify signature only for applicable CRLs (issuer in chain) - if err := crl.CheckSignatureFrom(matchingIssuer); err != nil { + if err := crl.CheckSignatureFrom(signer); err != nil { return false, nil } } @@ -118,7 +113,7 @@ func (NotRevoked) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool, } for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL == nil { + if crlInfo == nil || crlInfo.CRL == nil { continue } crl := crlInfo.CRL diff --git a/internal/operator/crl_chainorder_test.go b/internal/operator/crl_chainorder_test.go new file mode 100644 index 0000000..75808e2 --- /dev/null +++ b/internal/operator/crl_chainorder_test.go @@ -0,0 +1,134 @@ +package operator + +import ( + "crypto/rand" + "crypto/rsa" + cryptox509 "crypto/x509" + cryptopkix "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/crl" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +// signedCRLWithCA builds a CRL signed by ca and an impostor cert with the same +// issuer DN/AKI but a different key (DN-only match). +func signedCRLWithCA(t *testing.T) (*x509.RevocationList, *x509.Certificate, *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: "CRL Signer 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{0x0c, 0x0a}, + } + caDER, err := cryptox509.CreateCertificate(rand.Reader, template, template, &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, 0, 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 := crl.ParseCRL(crlDER) + if err != nil { + t.Fatalf("parse CRL: %v", err) + } + + wrongKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate impostor key: %v", err) + } + impostorDER, err := cryptox509.CreateCertificate(rand.Reader, &cryptox509.Certificate{ + SerialNumber: big.NewInt(99), + Subject: template.Subject, + NotBefore: template.NotBefore, + NotAfter: template.NotAfter, + SubjectKeyId: template.SubjectKeyId, + }, template, &wrongKey.PublicKey, wrongKey) + if err != nil { + t.Fatalf("create impostor: %v", err) + } + impostor, err := x509.ParseCertificate(impostorDER) + if err != nil { + t.Fatalf("parse impostor: %v", err) + } + + return revocationList, impostor, ca +} + +func TestCRLSignedBy_chainOrder_table(t *testing.T) { + revocationList, impostor, ca := signedCRLWithCA(t) + crlInfo := &crl.Info{CRL: revocationList} + op := CRLSignedBy{} + + tests := []struct { + name string + chain []*x509.Certificate + wantOK bool + }{ + { + name: "impostor then real signer: must not stop at DN-only match", + chain: []*x509.Certificate{impostor, ca}, + wantOK: true, + }, + { + name: "real signer first: straightforward pass", + chain: []*x509.Certificate{ca, impostor}, + wantOK: true, + }, + { + name: "impostor only: no valid signature", + chain: []*x509.Certificate{impostor}, + wantOK: false, + }, + { + name: "unrelated CA only: CRL not applicable", + chain: []*x509.Certificate{{Subject: pkix.Name{CommonName: "Other"}, SerialNumber: big.NewInt(1), IsCA: true}}, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chainInfos := make([]*cert.Info, len(tt.chain)) + for i, c := range tt.chain { + chainInfos[i] = &cert.Info{Cert: c} + } + ctx := &EvaluationContext{CRLs: []*crl.Info{crlInfo}, Chain: chainInfos} + got, err := op.Evaluate(nil, ctx, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.wantOK { + t.Fatalf("crlSignedBy = %v, want %v", got, tt.wantOK) + } + }) + } +} diff --git a/internal/operator/crl_le_test.go b/internal/operator/crl_le_test.go new file mode 100644 index 0000000..02a5e6d --- /dev/null +++ b/internal/operator/crl_le_test.go @@ -0,0 +1,138 @@ +package operator + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/crl" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +func readLEFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "crl", "testdata", "letsencrypt", name)) + if err != nil { + t.Fatalf("read LE fixture %s: %v", name, err) + } + return data +} + +func loadLetsEncryptRootX1CRL(t *testing.T) *x509.RevocationList { + t.Helper() + revocationList, err := crl.ParseCRL(readLEFixture(t, "isrg-root-x1.crl")) + if err != nil { + t.Fatalf("parse LE root CRL: %v", err) + } + return revocationList +} + +func loadLetsEncryptRootX1Cert(t *testing.T) *x509.Certificate { + t.Helper() + c, err := x509.ParseCertificate(readLEFixture(t, "isrgrootx1.der")) + if err != nil { + t.Fatalf("parse LE root cert: %v", err) + } + return c +} + +// TestCRLSignedBy_LERoot_verifiesWithOfficialRoot ensures the operator checks +// signatures after CertSignsCRL identity hints. +func TestCRLSignedBy_LERoot_verifiesWithOfficialRoot(t *testing.T) { + revocationList := loadLetsEncryptRootX1CRL(t) + root := loadLetsEncryptRootX1Cert(t) + + ctx := &EvaluationContext{ + CRLs: []*crl.Info{{CRL: revocationList}}, + Chain: []*cert.Info{{Cert: root}}, + } + op := CRLSignedBy{} + got, err := op.Evaluate(nil, ctx, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got { + t.Fatal("expected pass when official ISRG Root X1 is in chain and signature verifies") + } +} + +// TestCRLSignedBy_LERoot_impostorThenRoot_passes when the real ISRG Root X1 is +// later in chain than a DN-only impostor (signature-first selection). +func TestCRLSignedBy_LERoot_impostorThenRoot_passes(t *testing.T) { + revocationList := loadLetsEncryptRootX1CRL(t) + root := loadLetsEncryptRootX1Cert(t) + + impostor := &x509.Certificate{ + Subject: revocationList.Issuer, + SubjectKeyId: revocationList.AuthorityKeyId, + IsCA: true, + SerialNumber: big.NewInt(99), + } + + ctx := &EvaluationContext{ + CRLs: []*crl.Info{{CRL: revocationList}}, + Chain: []*cert.Info{ + {Cert: impostor}, + {Cert: root}, + }, + } + op := CRLSignedBy{} + got, err := op.Evaluate(nil, ctx, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got { + t.Fatal("expected pass: must use signature-verified root, not first DN match") + } +} + +// TestCRLSignedBy_LERoot_impostorOnly_fails when chain has no cert that verifies. +func TestCRLSignedBy_LERoot_impostorOnly_fails(t *testing.T) { + revocationList := loadLetsEncryptRootX1CRL(t) + impostor := &x509.Certificate{ + Subject: revocationList.Issuer, + SubjectKeyId: revocationList.AuthorityKeyId, + IsCA: true, + SerialNumber: big.NewInt(99), + } + + ctx := &EvaluationContext{ + CRLs: []*crl.Info{{CRL: revocationList}}, + Chain: []*cert.Info{{Cert: impostor}}, + } + op := CRLSignedBy{} + got, err := op.Evaluate(nil, ctx, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got { + t.Fatal("expected fail when no chain member verifies CRL signature") + } +} + +// TestCRLSignedBy_LERoot_noMatchingChainMemberIsNotApplicable when the CRL +// issuer is not represented in the chain, the operator treats the CRL as N/A. +func TestCRLSignedBy_LERoot_noMatchingChainMemberIsNotApplicable(t *testing.T) { + revocationList := loadLetsEncryptRootX1CRL(t) + unrelated := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Unrelated CA"}, + SerialNumber: big.NewInt(1), + IsCA: true, + } + + ctx := &EvaluationContext{ + CRLs: []*crl.Info{{CRL: revocationList}}, + Chain: []*cert.Info{{Cert: unrelated}}, + } + op := CRLSignedBy{} + got, err := op.Evaluate(nil, ctx, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got { + t.Fatal("expected pass (skip) when chain has no CRL signer candidate") + } +} diff --git a/internal/operator/crl_test.go b/internal/operator/crl_test.go index 6415b25..10f0bad 100644 --- a/internal/operator/crl_test.go +++ b/internal/operator/crl_test.go @@ -2,6 +2,8 @@ package operator import ( "math/big" + "os" + "path/filepath" "testing" "time" @@ -254,6 +256,37 @@ func TestCRLSignedByNoChain(t *testing.T) { } } +func TestCRLSignedBy_rejectsInvalidSignature(t *testing.T) { + crlDER, err := os.ReadFile(filepath.Join("..", "..", "internal", "crl", "testdata", "test.crl")) + if err != nil { + t.Fatalf("read CRL: %v", err) + } + revocationList, err := crl.ParseCRL(crlDER) + if err != nil { + t.Fatalf("parse CRL: %v", err) + } + + signer := &x509.Certificate{ + Subject: revocationList.Issuer, + SubjectKeyId: revocationList.AuthorityKeyId, + IsCA: true, + SerialNumber: big.NewInt(1), + } + ctx := &EvaluationContext{ + CRLs: []*crl.Info{{CRL: revocationList}}, + Chain: []*cert.Info{{Cert: signer}}, + } + + op := CRLSignedBy{} + got, err := op.Evaluate(nil, ctx, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got { + t.Error("expected false when CRL signature cannot be verified") + } +} + func TestCRLSignedByIssuerMismatch(t *testing.T) { op := CRLSignedBy{} ctx := &EvaluationContext{ diff --git a/internal/policy/engine.go b/internal/policy/engine.go index e935f93..5edf635 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -44,10 +44,21 @@ func Evaluate( reg *operator.Registry, ctx *operator.EvaluationContext, ) Result { + inputType := inputTypeFromContext(ctx) results := make([]rule.Result, 0, len(p.Rules)) verdict := "pass" for _, r := range p.Rules { + if !RuleAppliesToInput(r, inputType, ctx) { + results = append(results, rule.Result{ + RuleID: r.ID, + Reference: r.Reference, + Verdict: rule.VerdictSkip, + Severity: r.Severity, + }) + continue + } + res := rule.Evaluate(root, r, reg, ctx) results = append(results, res) diff --git a/internal/policy/match_rule.go b/internal/policy/match_rule.go new file mode 100644 index 0000000..74dbe35 --- /dev/null +++ b/internal/policy/match_rule.go @@ -0,0 +1,78 @@ +package policy + +import ( + "slices" + "strings" + + "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/rule" +) + +// RuleAppliesToInput reports whether a rule should run for the current evaluation +// input (cert, crl, ocsp). Mixed policies (RFC5280, RFC4055) include both +// certificate.* and crl.* rules; only the matching subset is evaluated per pass. +func RuleAppliesToInput(r rule.Rule, inputType string, ctx *operator.EvaluationContext) bool { + if len(r.CertType) > 0 { + if ctx == nil || ctx.Cert == nil { + return false + } + return slices.Contains(r.CertType, ctx.Cert.Type) + } + + targetInput := inferInputFromTarget(r.Target) + whenInput := "" + if r.When != nil { + whenInput = inferInputFromTarget(r.When.Target) + } + + // Unqualified targets (e.g. custom nodes in unit tests) run on every input. + if targetInput == "" && whenInput == "" { + return true + } + + switch inputType { + case InputCRL: + // Dedicated CRL pass (evaluator.CRL): only crl.* targets, never certificate.*. + return targetInput == InputCRL + case InputCert: + if targetInput == InputCert { + return true + } + // Chain evaluation may attach a crl subtree (see tests/integration_test.go). + if targetInput == InputCRL || whenInput == InputCRL { + return true + } + return false + case InputOCSP: + return targetInput == InputOCSP || whenInput == InputOCSP + default: + return true + } +} + +func inferInputFromTarget(target string) string { + switch { + case target == "certificate" || strings.HasPrefix(target, "certificate."): + return InputCert + case target == "crl" || strings.HasPrefix(target, "crl."): + return InputCRL + case target == "ocsp" || strings.HasPrefix(target, "ocsp."): + return InputOCSP + default: + return "" + } +} + +func inputTypeFromContext(ctx *operator.EvaluationContext) string { + if ctx == nil || ctx.Cert == nil { + return InputCert + } + switch ctx.Cert.Type { + case "crl": + return InputCRL + case "ocsp": + return InputOCSP + default: + return InputCert + } +} diff --git a/internal/policy/match_rule_test.go b/internal/policy/match_rule_test.go new file mode 100644 index 0000000..6a6e997 --- /dev/null +++ b/internal/policy/match_rule_test.go @@ -0,0 +1,163 @@ +package policy + +import ( + "testing" + "time" + + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/crl" + "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/rule" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" +) + +func TestRuleAppliesToInput_mixedRFC5280Style(t *testing.T) { + certRule := rule.Rule{ID: "serial-number-present", Target: "certificate.serialNumber", Operator: "present"} + crlRule := rule.Rule{ID: "crl-valid", Target: "crl", Operator: "crlValid", When: &rule.Condition{Target: "crl", Operator: "present"}} + revokedRule := rule.Rule{ + ID: "cert-not-revoked", + Target: "certificate", + Operator: "notRevoked", + When: &rule.Condition{Target: "crl", Operator: "present"}, + } + brCRL := rule.Rule{ID: "BR_CRL_VALID", Target: "crl", Operator: "crlValid", CertType: []string{"crl"}} + + crlCtx := operator.NewEvaluationContext(nil, &cert.Info{Type: "crl"}, nil) + certCtx := operator.NewEvaluationContext(nil, &cert.Info{Type: "intermediate"}, nil) + + if RuleAppliesToInput(certRule, InputCRL, crlCtx) { + t.Fatal("certificate.serialNumber rule must not run on CRL input") + } + if !RuleAppliesToInput(crlRule, InputCRL, crlCtx) { + t.Fatal("crl-valid must run on CRL input") + } + if !RuleAppliesToInput(crlRule, InputCert, certCtx) { + t.Fatal("crl-valid must run when CRL is embedded in cert tree pass") + } + if !RuleAppliesToInput(revokedRule, InputCert, certCtx) { + t.Fatal("cert-not-revoked must run on cert input") + } + if RuleAppliesToInput(revokedRule, InputCRL, crlCtx) { + t.Fatal("cert-not-revoked must not run on CRL-only pass") + } + if !RuleAppliesToInput(brCRL, InputCRL, crlCtx) { + t.Fatal("certType [crl] must run on CRL input") + } + if RuleAppliesToInput(brCRL, InputCert, certCtx) { + t.Fatal("certType [crl] must not run on intermediate cert input") + } +} + +func TestEvaluate_skipsCertificateRulesOnCRLContext(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + revocationList := &x509.RevocationList{ + Issuer: pkix.Name{CommonName: "CRL Issuer"}, + AuthorityKeyId: []byte{0x01}, + ThisUpdate: now, + NextUpdate: now.Add(24 * time.Hour), + } + tree := crl.BuildTree(revocationList) + if tree == nil { + t.Fatal("BuildTree returned nil") + } + + reg := operator.DefaultRegistry() + evalCtx := operator.NewEvaluationContext( + tree, + &cert.Info{Type: "crl", FilePath: "test.crl"}, + nil, + ) + + p := Policy{ + ID: "mixed", + Rules: []rule.Rule{ + {ID: "cert-serial", Target: "certificate.serialNumber", Operator: "present", Severity: "error"}, + {ID: "crl-issuer", Target: "crl.issuer", Operator: "notEmpty", Severity: "error"}, + }, + } + + res := Evaluate(p, tree, reg, evalCtx) + var serialVerdict, crlVerdict string + for _, r := range res.Results { + switch r.RuleID { + case "cert-serial": + serialVerdict = r.Verdict + case "crl-issuer": + crlVerdict = r.Verdict + } + } + if serialVerdict != rule.VerdictSkip { + t.Fatalf("cert-serial verdict = %q, want skip", serialVerdict) + } + if crlVerdict != rule.VerdictPass { + t.Fatalf("crl-issuer verdict = %q, want pass", crlVerdict) + } +} + +func TestInputTypeFromContext(t *testing.T) { + if inputTypeFromContext(operator.NewEvaluationContext(nil, &cert.Info{Type: "crl"}, nil)) != InputCRL { + t.Fatal("crl type") + } + if inputTypeFromContext(operator.NewEvaluationContext(nil, &cert.Info{Type: "leaf"}, nil)) != InputCert { + t.Fatal("leaf type") + } +} + +// Ensure unqualified custom targets still evaluate (unit-test policies). +func TestRuleAppliesToInput_unqualifiedTarget(t *testing.T) { + r := rule.Rule{Target: "keySize", Operator: "eq"} + ctx := operator.NewEvaluationContext(nil, &cert.Info{Type: "crl"}, nil) + if !RuleAppliesToInput(r, InputCRL, ctx) { + t.Fatal("unqualified target should apply to any input") + } +} + +func TestRuleAppliesToInput_ocspInput(t *testing.T) { + ocspRule := rule.Rule{ID: "ocsp-status", Target: "ocsp.status", Operator: "eq"} + certRule := rule.Rule{ID: "cert-version", Target: "certificate.version", Operator: "eq"} + ocspCtx := operator.NewEvaluationContext(nil, &cert.Info{Type: "ocsp"}, nil) + + if !RuleAppliesToInput(ocspRule, InputOCSP, ocspCtx) { + t.Fatal("ocsp.* must run on OCSP input") + } + if RuleAppliesToInput(certRule, InputOCSP, ocspCtx) { + t.Fatal("certificate.* must not run on OCSP-only pass") + } +} + +func TestRuleAppliesToInput_certTypeNilCtx(t *testing.T) { + r := rule.Rule{Target: "certificate.version", Operator: "eq", CertType: []string{"leaf"}} + if RuleAppliesToInput(r, InputCert, nil) { + t.Fatal("certType rule must not run when ctx is nil") + } +} + +func TestRuleAppliesToInput_unknownInputTypeAllowsUnqualified(t *testing.T) { + r := rule.Rule{Target: "customNode", Operator: "present"} + ctx := operator.NewEvaluationContext(nil, &cert.Info{Type: "ocsp"}, nil) + if !RuleAppliesToInput(r, "other", ctx) { + t.Fatal("unqualified rules should run for non-crl/non-ocsp input labels") + } +} + +func TestInputTypeFromContext_ocspAndNil(t *testing.T) { + if inputTypeFromContext(nil) != InputCert { + t.Fatal("nil ctx defaults to cert input") + } + if inputTypeFromContext(operator.NewEvaluationContext(nil, &cert.Info{Type: "ocsp"}, nil)) != InputOCSP { + t.Fatal("ocsp cert type") + } +} + +func TestRuleAppliesToInput_certTypeOnLeaf(t *testing.T) { + r := rule.Rule{Target: "certificate.version", Operator: "eq", CertType: []string{"leaf"}} + leafCtx := operator.NewEvaluationContext(nil, &cert.Info{Type: "leaf"}, nil) + crlCtx := operator.NewEvaluationContext(nil, &cert.Info{Type: "crl"}, nil) + if !RuleAppliesToInput(r, InputCert, leafCtx) { + t.Fatal("leaf certType on leaf") + } + if RuleAppliesToInput(r, InputCert, crlCtx) { + t.Fatal("leaf certType must not run when evaluating crl object") + } +} diff --git a/tests/integration_test.go b/tests/integration_test.go index 0bf67a2..4b38457 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -11,7 +11,6 @@ import ( "github.com/cavoq/PCL/internal/cert" certzcrypto "github.com/cavoq/PCL/internal/cert/zcrypto" "github.com/cavoq/PCL/internal/crl" - crlzcrypto "github.com/cavoq/PCL/internal/crl/zcrypto" "github.com/cavoq/PCL/internal/ocsp" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/policy" @@ -141,11 +140,12 @@ func runCase(t *testing.T, caseDir string, tc testCase) { for _, c := range chain { tree := certzcrypto.BuildTree(c.Cert) - // Add CRL node to tree if CRLs are present (matching runner.go behavior) + // Add CRL node with isCACRL (same path as evaluator.CRL / auto-validate). if len(crlInfos) > 0 { for _, crlInfo := range crlInfos { if crlInfo.CRL != nil { - crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + issuerPool := crl.ResolveIssuerCerts(chain, crlInfo.CRL, 0, 0, nil) + crlNode := crl.BuildTreeWithChain(crlInfo.CRL, issuerPool) if crlNode != nil { tree.Children["crl"] = crlNode } diff --git a/tests/linter-cases/rfc5280-chain-crl-json.yaml b/tests/linter-cases/rfc5280-chain-crl-json.yaml index 41aff24..edae015 100644 --- a/tests/linter-cases/rfc5280-chain-crl-json.yaml +++ b/tests/linter-cases/rfc5280-chain-crl-json.yaml @@ -12,8 +12,8 @@ expected: total_certs: 4 total_rules: 372 pass: 130 - fail: 11 - skip: 231 + fail: 9 + skip: 233 results: - cert_type: leaf policy: rfc5280 diff --git a/tests/policies/crl-datediff.yaml b/tests/policies/crl-datediff.yaml new file mode 100644 index 0000000..75d5349 --- /dev/null +++ b/tests/policies/crl-datediff.yaml @@ -0,0 +1,34 @@ +id: crl-datediff +version: 1.0 + +rules: + # Test dateDiff operator - CRL has 30-day validity, exceeding 10-day limit + - id: crl-nextupdate-within-10-days + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxDays: 10 + severity: error + + # Test dateDiff operator - CRL has 30-day validity, within 12-month limit + - id: crl-nextupdate-within-12-months + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxMonths: 12 + severity: error + + # Test every operator with skipMissing (empty CRL should pass) + - id: crl-reason-codes-valid + target: crl.revokedCertificates + operator: every + operands: + - path: extensions.2.5.29.21.value + check: in + values: [1, 2, 3, 4, 5, 6, 9] + skipMissing: true + severity: error \ No newline at end of file diff --git a/tests/policies/crl-is-cacrl.yaml b/tests/policies/crl-is-cacrl.yaml new file mode 100644 index 0000000..28f53d8 --- /dev/null +++ b/tests/policies/crl-is-cacrl.yaml @@ -0,0 +1,31 @@ +id: crl-is-cacrl +version: 1.0 + +rules: + # test.crl has ~30-day validity → isCACRL true → subscriber 10-day rule must skip + - id: subscriber-crl-within-10-days + when: + target: crl.isCACRL + operator: eq + operands: [false] + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxDays: 10 + severity: error + + # other CRL profile: long window allowed when isCACRL is true + - id: other-crl-within-100-days + when: + target: crl.isCACRL + operator: eq + operands: [true] + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxDays: 100 + severity: error diff --git a/tests/policies/crl-letsencrypt-root.yaml b/tests/policies/crl-letsencrypt-root.yaml new file mode 100644 index 0000000..9813fb1 --- /dev/null +++ b/tests/policies/crl-letsencrypt-root.yaml @@ -0,0 +1,31 @@ +id: crl-letsencrypt-root +version: 1.0 + +rules: + # ISRG Root X1 CRL (~1 year nextUpdate) → isCACRL true + - id: other-crl-within-one-year + when: + target: crl.isCACRL + operator: eq + operands: [true] + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxDays: 400 + severity: error + + # Must not apply subscriber 10-day limit to root CRL + - id: subscriber-crl-within-10-days + when: + target: crl.isCACRL + operator: eq + operands: [false] + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxDays: 10 + severity: error diff --git a/tests/policy-cases/crl-datediff.yaml b/tests/policy-cases/crl-datediff.yaml new file mode 100644 index 0000000..03033da --- /dev/null +++ b/tests/policy-cases/crl-datediff.yaml @@ -0,0 +1,18 @@ +name: crl-datediff +policy: policies/crl-datediff.yaml +certs: certs +crl: ../internal/crl/testdata/test.crl +eval_time: "2026-01-10T00:00:00Z" +expected: + leaf: + pass: 1 + fail: 1 + skip: 1 + intermediate: + pass: 1 + fail: 1 + skip: 1 + root: + pass: 1 + fail: 1 + skip: 1 diff --git a/tests/policy-cases/crl-is-cacrl.yaml b/tests/policy-cases/crl-is-cacrl.yaml new file mode 100644 index 0000000..18d17dc --- /dev/null +++ b/tests/policy-cases/crl-is-cacrl.yaml @@ -0,0 +1,18 @@ +name: crl-is-cacrl +policy: policies/crl-is-cacrl.yaml +certs: certs +crl: ../internal/crl/testdata/test.crl +eval_time: "2026-01-10T00:00:00Z" +expected: + leaf: + pass: 1 + fail: 0 + skip: 1 + intermediate: + pass: 1 + fail: 0 + skip: 1 + root: + pass: 1 + fail: 0 + skip: 1 diff --git a/tests/policy-cases/crl-letsencrypt-root.yaml b/tests/policy-cases/crl-letsencrypt-root.yaml new file mode 100644 index 0000000..aff6574 --- /dev/null +++ b/tests/policy-cases/crl-letsencrypt-root.yaml @@ -0,0 +1,18 @@ +name: crl-letsencrypt-root +policy: policies/crl-letsencrypt-root.yaml +certs: certs +crl: ../internal/crl/testdata/letsencrypt/isrg-root-x1.crl +eval_time: "2026-06-01T00:00:00Z" +expected: + leaf: + pass: 1 + fail: 0 + skip: 1 + intermediate: + pass: 1 + fail: 0 + skip: 1 + root: + pass: 1 + fail: 0 + skip: 1 diff --git a/tests/scripts/fetch-letsencrypt-crl-fixtures.sh b/tests/scripts/fetch-letsencrypt-crl-fixtures.sh new file mode 100644 index 0000000..a5787b5 --- /dev/null +++ b/tests/scripts/fetch-letsencrypt-crl-fixtures.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Refresh pinned Let's Encrypt CRL/CA fixtures (official HTTP endpoints). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +DEST="${ROOT}/internal/crl/testdata/letsencrypt" +mkdir -p "$DEST" + +curl -sS "http://crl.root-x1.letsencrypt.org/" -o "${DEST}/isrg-root-x1.crl" +curl -sS "http://x2.c.lencr.org/" -o "${DEST}/isrg-root-x2.crl" +curl -sSL "http://letsencrypt.org/certs/isrgrootx1.der" -o "${DEST}/isrgrootx1.der" +curl -sSL "http://letsencrypt.org/certs/2024/e9.der" -o "${DEST}/e9.der" +curl -sS "http://x2.i.lencr.org/" -o "${DEST}/isrg-root-x2.der" + +CERT_DEST="${ROOT}/internal/cert/testdata/letsencrypt" +mkdir -p "$CERT_DEST" +cp "${DEST}/e9.der" "${CERT_DEST}/e9.der" +cp "${DEST}/isrg-root-x2.der" "${CERT_DEST}/isrg-root-x2.der" + +openssl crl -in "${DEST}/isrg-root-x1.crl" -inform DER -noout -issuer -lastupdate -nextupdate +echo "Updated fixtures in ${DEST} and ${CERT_DEST}"