Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*.dylib
CLAUDE.md
.codex
.claude
data/
tests/certs/generated

# Test binary, built with `go test -c`
*.test
Expand Down
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ rules:
operator: eq
operands: [true]
severity: error
appliesTo: [root, intermediate]
certType: [root, intermediate]

# -------------------------------------------------
# Key Usage (Section 4.2.1.3)
Expand All @@ -169,7 +169,7 @@ rules:
target: certificate.keyUsage
operator: keyUsageCA
severity: error
appliesTo: [root, intermediate]
certType: [root, intermediate]

# -------------------------------------------------
# AIA Extension - Best Practice (INFO severity)
Expand All @@ -180,7 +180,7 @@ rules:
target: certificate.caIssuersURL
operator: present
severity: info
appliesTo: [leaf]
certType: [leaf]

# -------------------------------------------------
# Subject Alternative Name (Section 4.2.1.6)
Expand Down Expand Up @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion cmd/pcl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,4 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
}
80 changes: 48 additions & 32 deletions docs/POLICY_WRITING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

---
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
```

---
Expand Down Expand Up @@ -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
```

---
Expand Down Expand Up @@ -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
Expand All @@ -968,15 +984,15 @@ rules:
operator: eq
operands: [false]
severity: error
appliesTo: [leaf]
certType: [leaf]

# Key Usage
- id: ca-key-cert-sign
reference: RFC5280 4.2.1.3
target: certificate.keyUsage.keyCertSign
operator: present
severity: error
appliesTo: [root, intermediate]
certType: [root, intermediate]

# Algorithm-Specific
- id: rsa-key-size-min
Expand All @@ -996,7 +1012,7 @@ rules:
target: crl
operator: crlValid
severity: error
appliesTo: [crl]
certType: [crl]
```

---
Expand Down
67 changes: 12 additions & 55 deletions internal/aia/aia_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"crypto/rsa"
stdx509 "crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"math/big"
"net/http"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
},
}

Expand Down Expand Up @@ -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...)
}
Loading
Loading