From 986c72f5d7178f83981b319d54dd5b69c674da10 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Tue, 28 Apr 2026 14:39:35 +0800 Subject: [PATCH 01/44] feat: implement RFC4055 compliance checking Add comprehensive RFC4055 policy for RSA cryptography compliance: - RSA signature algorithm parameters must be NULL - RSASSA-PSS parameter validation (hash, MGF1, saltLength, trailerField) - RSAES-OAEP parameter validation (hash, MGF1, pSource) - Support for both Certificate and CRL validation New components: - internal/asn1: ASN.1 AlgorithmIdentifier parser with OID extraction - internal/operator: isNull/isAbsent operators for parameter checking - policies/RFC4055.yaml: 29 rules covering RFC4055 requirements - policies/RFC4055-COVERAGE.md: coverage documentation Changes: - runner: support standalone CRL evaluation without certificate - builder: parse PSS/OAEP parameters into node tree - CLI: allow --crl/--ocsp without --cert --- cmd/pcl/main.go | 4 +- internal/asn1/parser.go | 323 ++++++++++++++++ internal/asn1/parser_test.go | 105 +++++ internal/cert/zcrypto/builder.go | 67 ++++ internal/cert/zcrypto/parser.go | 84 ++++ internal/crl/zcrypto/builder.go | 67 ++++ internal/crl/zcrypto/builder_oid_test.go | 59 +++ internal/crl/zcrypto/parser.go | 58 +++ internal/linter/runner.go | 104 +++-- internal/operator/asn1.go | 51 +++ internal/operator/asn1_test.go | 149 +++++++ internal/operator/operator.go | 2 + policies/RFC4055-COVERAGE.md | 202 ++++++++++ policies/RFC4055.yaml | 472 +++++++++++++++++++++++ 14 files changed, 1718 insertions(+), 29 deletions(-) create mode 100644 internal/asn1/parser.go create mode 100644 internal/asn1/parser_test.go create mode 100644 internal/cert/zcrypto/parser.go create mode 100644 internal/crl/zcrypto/builder_oid_test.go create mode 100644 internal/crl/zcrypto/parser.go create mode 100644 internal/operator/asn1.go create mode 100644 internal/operator/asn1_test.go create mode 100644 policies/RFC4055-COVERAGE.md create mode 100644 policies/RFC4055.yaml diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index 0a2509b..85d83d8 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -18,8 +18,8 @@ func newRootCmd(opts *linter.Config) *cobra.Command { if opts.PolicyPath == "" { return fmt.Errorf("--policy is required") } - if opts.CertPath == "" && len(opts.CertURLs) == 0 { - return fmt.Errorf("--cert or --cert-url is required") + if opts.CertPath == "" && len(opts.CertURLs) == 0 && opts.CRLPath == "" && opts.OCSPPath == "" { + return fmt.Errorf("at least one of --cert, --cert-url, --crl, or --ocsp is required") } return linter.Run(*opts, cmd.OutOrStdout()) }, diff --git a/internal/asn1/parser.go b/internal/asn1/parser.go new file mode 100644 index 0000000..efeef6e --- /dev/null +++ b/internal/asn1/parser.go @@ -0,0 +1,323 @@ +package asn1 + +import ( + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +// ParamsState represents the state of an AlgorithmIdentifier parameters field. +type ParamsState struct { + IsNull bool // parameters is ASN.1 NULL + IsAbsent bool // parameters field is absent + OID string // algorithm OID + + // RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) + PSS *PSSParams + + // RSAES-OAEP parameters (OID 1.2.840.113549.1.1.7) + OAEP *OAEPParams +} + +// PSSParams represents RSASSA-PSS-params structure. +type PSSParams struct { + HashAlgorithm AlgorithmIdentifier // [0] DEFAULT sha1 + MaskGenAlgorithm AlgorithmIdentifier // [1] DEFAULT mgf1SHA1 + SaltLength int // [2] DEFAULT 20 + TrailerField int // [3] DEFAULT 1 + HashAlgorithmSet bool // whether hashAlgorithm was explicitly set + MaskGenAlgorithmSet bool // whether maskGenAlgorithm was explicitly set + SaltLengthSet bool // whether saltLength was explicitly set + TrailerFieldSet bool // whether trailerField was explicitly set +} + +// OAEPParams represents RSAES-OAEP-params structure. +type OAEPParams struct { + HashAlgorithm AlgorithmIdentifier // [0] DEFAULT sha1 + MaskGenAlgorithm AlgorithmIdentifier // [1] DEFAULT mgf1SHA1 + PSourceAlgorithm AlgorithmIdentifier // [2] DEFAULT pSpecifiedEmpty + HashAlgorithmSet bool // whether hashAlgorithm was explicitly set + MaskGenAlgorithmSet bool // whether maskGenAlgorithm was explicitly set + PSourceAlgorithmSet bool // whether pSourceAlgorithm was explicitly set +} + +// AlgorithmIdentifier represents an AlgorithmIdentifier structure. +type AlgorithmIdentifier struct { + OID string + Params ParamsState // nested params for MGF1, etc. +} + +// ParseAlgorithmIDParams parses an AlgorithmIdentifier from DER bytes +// and returns the parameters state and OID. +func ParseAlgorithmIDParams(derBytes []byte) ParamsState { + result := ParamsState{} + + input := cryptobyte.String(derBytes) + + var algoID cryptobyte.String + if !input.ReadASN1(&algoID, cryptobyte_asn1.SEQUENCE) { + return result + } + + var oid cryptobyte.String + if !algoID.ReadASN1(&oid, cryptobyte_asn1.OBJECT_IDENTIFIER) { + return result + } + + // Convert OID to string representation + result.OID = oidString(oid) + + if algoID.Empty() { + result.IsAbsent = true + return result + } + + var params cryptobyte.String + var paramsTag cryptobyte_asn1.Tag + if !algoID.ReadAnyASN1Element(¶ms, ¶msTag) { + return result + } + + if paramsTag == cryptobyte_asn1.NULL { + result.IsNull = true + return result + } + + // Parse RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) + if result.OID == "1.2.840.113549.1.1.10" { + result.PSS = parsePSSParams(params) + return result + } + + // Parse RSAES-OAEP parameters (OID 1.2.840.113549.1.1.7) + if result.OID == "1.2.840.113549.1.1.7" { + result.OAEP = parseOAEPParams(params) + return result + } + + return result +} + +// parsePSSParams parses RSASSA-PSS-params from a SEQUENCE. +func parsePSSParams(params cryptobyte.String) *PSSParams { + result := &PSSParams{ + HashAlgorithm: AlgorithmIdentifier{OID: "1.3.14.3.2.26"}, // sha1 default + MaskGenAlgorithm: AlgorithmIdentifier{OID: "1.2.840.113549.1.1.8", Params: ParamsState{OID: "1.3.14.3.2.26"}}, + SaltLength: 20, + TrailerField: 1, + } + + var seq cryptobyte.String + if !params.ReadASN1(&seq, cryptobyte_asn1.SEQUENCE) { + return result + } + + // hashAlgorithm [0] EXPLICIT HashAlgorithm OPTIONAL + if !seq.Empty() { + var hashAlgo cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { + result.HashAlgorithmSet = true + if !seq.ReadASN1(&hashAlgo, cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { + return result + } + result.HashAlgorithm = parseNestedAlgorithmIdentifier(hashAlgo) + } + } + + // maskGenAlgorithm [1] EXPLICIT MaskGenAlgorithm OPTIONAL + if !seq.Empty() { + var mgfAlgo cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { + result.MaskGenAlgorithmSet = true + if !seq.ReadASN1(&mgfAlgo, cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { + return result + } + result.MaskGenAlgorithm = parseNestedAlgorithmIdentifier(mgfAlgo) + } + } + + // saltLength [2] EXPLICIT INTEGER OPTIONAL + if !seq.Empty() { + var saltLen cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { + result.SaltLengthSet = true + if !seq.ReadASN1(&saltLen, cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { + return result + } + if !saltLen.ReadASN1Integer(&result.SaltLength) { + return result + } + } + } + + // trailerField [3] EXPLICIT TrailerField OPTIONAL + if !seq.Empty() { + var trailer cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(3).Constructed().ContextSpecific()) { + result.TrailerFieldSet = true + if !seq.ReadASN1(&trailer, cryptobyte_asn1.Tag(3).Constructed().ContextSpecific()) { + return result + } + if !trailer.ReadASN1Integer(&result.TrailerField) { + return result + } + } + } + + return result +} + +// parseOAEPParams parses RSAES-OAEP-params from a SEQUENCE. +func parseOAEPParams(params cryptobyte.String) *OAEPParams { + result := &OAEPParams{ + HashAlgorithm: AlgorithmIdentifier{OID: "1.3.14.3.2.26"}, // sha1 default + MaskGenAlgorithm: AlgorithmIdentifier{OID: "1.2.840.113549.1.1.8", Params: ParamsState{OID: "1.3.14.3.2.26"}}, + PSourceAlgorithm: AlgorithmIdentifier{OID: "1.2.840.113549.1.1.9"}, // pSpecified with empty P + } + + var seq cryptobyte.String + if !params.ReadASN1(&seq, cryptobyte_asn1.SEQUENCE) { + return result + } + + // hashAlgorithm [0] EXPLICIT HashAlgorithm OPTIONAL + if !seq.Empty() { + var hashAlgo cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { + result.HashAlgorithmSet = true + if !seq.ReadASN1(&hashAlgo, cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { + return result + } + result.HashAlgorithm = parseNestedAlgorithmIdentifier(hashAlgo) + } + } + + // maskGenAlgorithm [1] EXPLICIT MaskGenAlgorithm OPTIONAL + if !seq.Empty() { + var mgfAlgo cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { + result.MaskGenAlgorithmSet = true + if !seq.ReadASN1(&mgfAlgo, cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { + return result + } + result.MaskGenAlgorithm = parseNestedAlgorithmIdentifier(mgfAlgo) + } + } + + // pSourceAlgorithm [2] EXPLICIT PSourceAlgorithm OPTIONAL + if !seq.Empty() { + var pSource cryptobyte.String + if seq.PeekASN1Tag(cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { + result.PSourceAlgorithmSet = true + if !seq.ReadASN1(&pSource, cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { + return result + } + result.PSourceAlgorithm = parseNestedAlgorithmIdentifier(pSource) + } + } + + return result +} + +// parseNestedAlgorithmIdentifier parses an AlgorithmIdentifier structure. +func parseNestedAlgorithmIdentifier(input cryptobyte.String) AlgorithmIdentifier { + result := AlgorithmIdentifier{} + + var algoID cryptobyte.String + if !input.ReadASN1(&algoID, cryptobyte_asn1.SEQUENCE) { + return result + } + + var oid cryptobyte.String + if !algoID.ReadASN1(&oid, cryptobyte_asn1.OBJECT_IDENTIFIER) { + return result + } + + result.OID = oidString(oid) + + if algoID.Empty() { + result.Params.IsAbsent = true + return result + } + + var params cryptobyte.String + var paramsTag cryptobyte_asn1.Tag + if !algoID.ReadAnyASN1Element(¶ms, ¶msTag) { + return result + } + + if paramsTag == cryptobyte_asn1.NULL { + result.Params.IsNull = true + result.Params.OID = result.OID + return result + } + + // For MGF1, the parameter is another AlgorithmIdentifier (hash algorithm) + if result.OID == "1.2.840.113549.1.1.8" { // id-mgf1 + result.Params = ParseAlgorithmIDParams(params) + } + + return result +} + +// oidString converts a cryptobyte OID to standard string format (e.g., "1.2.840.113549.1.1.11") +func oidString(oid cryptobyte.String) string { + var components []int + + // First two components are encoded in first byte + var first byte + if !oid.ReadUint8(&first) { + return "" + } + components = append(components, int(first/40), int(first%40)) + + // Read remaining components (variable length encoding) + for !oid.Empty() { + var val int + if !readOIDComponent(&oid, &val) { + break + } + components = append(components, val) + } + + // Build string representation + result := "" + for i, c := range components { + if i > 0 { + result += "." + } + result += intToStr(c) + } + return result +} + +func readOIDComponent(oid *cryptobyte.String, val *int) bool { + var v int + for { + var b byte + if !oid.ReadUint8(&b) { + return false + } + v = (v << 7) | int(b&0x7f) + if b&0x80 == 0 { + break + } + } + *val = v + return true +} + +func intToStr(n int) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append(digits, byte('0'+n%10)) + n /= 10 + } + // Reverse digits + for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { + digits[i], digits[j] = digits[j], digits[i] + } + return string(digits) +} \ No newline at end of file diff --git a/internal/asn1/parser_test.go b/internal/asn1/parser_test.go new file mode 100644 index 0000000..0e4fc76 --- /dev/null +++ b/internal/asn1/parser_test.go @@ -0,0 +1,105 @@ +package asn1 + +import ( + "testing" +) + +func TestParseAlgorithmIDParams(t *testing.T) { + tests := []struct { + name string + der []byte + expected ParamsState + }{ + { + name: "RSA with NULL parameters", + // SEQUENCE { OID 1.2.840.113549.1.1.11, NULL } + der: []byte{ + 0x30, 0x0d, // SEQUENCE, length 13 + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, // OID sha256WithRSAEncryption + 0x05, 0x00, // NULL + }, + expected: ParamsState{ + OID: "1.2.840.113549.1.1.11", + IsNull: true, + }, + }, + { + name: "RSA with absent parameters", + // SEQUENCE { OID 1.2.840.113549.1.1.11 } (no parameters) + der: []byte{ + 0x30, 0x0b, // SEQUENCE, length 11 + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, // OID + }, + expected: ParamsState{ + OID: "1.2.840.113549.1.1.11", + IsAbsent: true, + }, + }, + { + name: "RSA encryption OID", + // SEQUENCE { OID 1.2.840.113549.1.1.1, NULL } + der: []byte{ + 0x30, 0x0d, // SEQUENCE, length 13 + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, // OID rsaEncryption + 0x05, 0x00, // NULL + }, + expected: ParamsState{ + OID: "1.2.840.113549.1.1.1", + IsNull: true, + }, + }, + { + name: "Invalid DER", + der: []byte{0x00, 0x00}, + expected: ParamsState{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseAlgorithmIDParams(tt.der) + if result.OID != tt.expected.OID { + t.Errorf("OID: got %s, want %s", result.OID, tt.expected.OID) + } + if result.IsNull != tt.expected.IsNull { + t.Errorf("IsNull: got %v, want %v", result.IsNull, tt.expected.IsNull) + } + if result.IsAbsent != tt.expected.IsAbsent { + t.Errorf("IsAbsent: got %v, want %v", result.IsAbsent, tt.expected.IsAbsent) + } + }) + } +} + +func TestOIDString(t *testing.T) { + tests := []struct { + name string + oidBytes []byte + expected string + }{ + { + name: "sha256WithRSAEncryption", + oidBytes: []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b}, + expected: "1.2.840.113549.1.1.11", + }, + { + name: "rsaEncryption", + oidBytes: []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01}, + expected: "1.2.840.113549.1.1.1", + }, + { + name: "commonName OID", + oidBytes: []byte{0x55, 0x04, 0x03}, + expected: "2.5.4.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := oidString(tt.oidBytes) + if result != tt.expected { + t.Errorf("got %s, want %s", result, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index b57fbd2..4f9780e 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -6,6 +6,7 @@ import ( "github.com/zmap/zcrypto/x509" + "github.com/cavoq/PCL/internal/asn1" "github.com/cavoq/PCL/internal/node" "github.com/cavoq/PCL/internal/zcrypto" ) @@ -36,6 +37,7 @@ func buildCertificate(cert *x509.Certificate) *node.Node { } root.Children["signatureAlgorithm"] = buildSignatureAlgorithm(cert) + root.Children["tbsSignatureAlgorithm"] = buildTBSSignatureAlgorithm(cert) root.Children["issuer"] = zcrypto.BuildPkixName("issuer", cert.Issuer) root.Children["validity"] = buildValidity(cert) root.Children["subject"] = zcrypto.BuildPkixName("subject", cert.Subject) @@ -88,6 +90,70 @@ func buildSignatureAlgorithm(cert *x509.Certificate) *node.Node { if len(cert.SignatureAlgorithmOID) > 0 { n.Children["oid"] = node.New("oid", cert.SignatureAlgorithmOID.String()) } + n.Children["parameters"] = buildAlgorithmIDParams(ParseCertSignatureAlgorithmParams(cert.Raw)) + return n +} + +func buildTBSSignatureAlgorithm(cert *x509.Certificate) *node.Node { + n := node.New("tbsSignatureAlgorithm", nil) + n.Children["algorithm"] = node.New("algorithm", cert.SignatureAlgorithm.String()) + if len(cert.SignatureAlgorithmOID) > 0 { + n.Children["oid"] = node.New("oid", cert.SignatureAlgorithmOID.String()) + } + n.Children["parameters"] = buildAlgorithmIDParams(ParseTBSCertSignatureParams(cert.RawTBSCertificate)) + return n +} + +func buildAlgorithmIDParams(params asn1.ParamsState) *node.Node { + n := node.New("parameters", nil) + n.Children["null"] = node.New("null", params.IsNull) + n.Children["absent"] = node.New("absent", params.IsAbsent) + + if params.PSS != nil { + n.Children["pss"] = buildPSSParams(params.PSS) + } + + if params.OAEP != nil { + n.Children["oaep"] = buildOAEPParams(params.OAEP) + } + + return n +} + +func buildPSSParams(pss *asn1.PSSParams) *node.Node { + n := node.New("pss", nil) + + n.Children["hashAlgorithm"] = buildNestedAlgorithmID(pss.HashAlgorithm) + n.Children["hashAlgorithmSet"] = node.New("hashAlgorithmSet", pss.HashAlgorithmSet) + n.Children["maskGenAlgorithm"] = buildNestedAlgorithmID(pss.MaskGenAlgorithm) + n.Children["maskGenAlgorithmSet"] = node.New("maskGenAlgorithmSet", pss.MaskGenAlgorithmSet) + n.Children["saltLength"] = node.New("saltLength", pss.SaltLength) + n.Children["saltLengthSet"] = node.New("saltLengthSet", pss.SaltLengthSet) + n.Children["trailerField"] = node.New("trailerField", pss.TrailerField) + n.Children["trailerFieldSet"] = node.New("trailerFieldSet", pss.TrailerFieldSet) + + return n +} + +func buildOAEPParams(oaep *asn1.OAEPParams) *node.Node { + n := node.New("oaep", nil) + + n.Children["hashAlgorithm"] = buildNestedAlgorithmID(oaep.HashAlgorithm) + n.Children["hashAlgorithmSet"] = node.New("hashAlgorithmSet", oaep.HashAlgorithmSet) + n.Children["maskGenAlgorithm"] = buildNestedAlgorithmID(oaep.MaskGenAlgorithm) + n.Children["maskGenAlgorithmSet"] = node.New("maskGenAlgorithmSet", oaep.MaskGenAlgorithmSet) + n.Children["pSourceAlgorithm"] = buildNestedAlgorithmID(oaep.PSourceAlgorithm) + n.Children["pSourceAlgorithmSet"] = node.New("pSourceAlgorithmSet", oaep.PSourceAlgorithmSet) + + return n +} + +func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { + n := node.New("algorithm", nil) + n.Children["oid"] = node.New("oid", algo.OID) + if algo.Params.OID != "" { + n.Children["parameters"] = buildAlgorithmIDParams(algo.Params) + } return n } @@ -106,6 +172,7 @@ func buildSubjectPublicKeyInfo(cert *x509.Certificate) *node.Node { if len(cert.PublicKeyAlgorithmOID) > 0 { algo.Children["oid"] = node.New("oid", cert.PublicKeyAlgorithmOID.String()) } + algo.Children["parameters"] = buildAlgorithmIDParams(ParseSubjectPublicKeyInfoParams(cert.RawSubjectPublicKeyInfo)) n.Children["algorithm"] = algo if cert.PublicKey != nil { diff --git a/internal/cert/zcrypto/parser.go b/internal/cert/zcrypto/parser.go new file mode 100644 index 0000000..eb5c8fc --- /dev/null +++ b/internal/cert/zcrypto/parser.go @@ -0,0 +1,84 @@ +package zcrypto + +import ( + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/cavoq/PCL/internal/asn1" +) + +// ParseTBSCertSignatureParams parses the signature AlgorithmIdentifier +// from TBSCertificate and returns the parameters state. +func ParseTBSCertSignatureParams(rawTBSCertificate []byte) asn1.ParamsState { + input := cryptobyte.String(rawTBSCertificate) + + var tbsCert cryptobyte.String + if !input.ReadASN1(&tbsCert, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Skip version (optional, context-specific tag 0) + if !tbsCert.SkipOptionalASN1(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { + return asn1.ParamsState{} + } + + // Skip serialNumber (INTEGER) + if !tbsCert.SkipASN1(cryptobyte_asn1.INTEGER) { + return asn1.ParamsState{} + } + + // Read signature AlgorithmIdentifier + var sigAlgoID cryptobyte.String + var tag cryptobyte_asn1.Tag + if !tbsCert.ReadAnyASN1Element(&sigAlgoID, &tag) { + return asn1.ParamsState{} + } + + return asn1.ParseAlgorithmIDParams(sigAlgoID) +} + +// ParseCertSignatureAlgorithmParams parses the outer signatureAlgorithm +// from a certificate and returns the parameters state. +func ParseCertSignatureAlgorithmParams(rawCertificate []byte) asn1.ParamsState { + input := cryptobyte.String(rawCertificate) + + var cert cryptobyte.String + if !input.ReadASN1(&cert, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Skip TBSCertificate + var tbs cryptobyte.String + if !cert.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Read signatureAlgorithm + var sigAlgo cryptobyte.String + var tag cryptobyte_asn1.Tag + if !cert.ReadAnyASN1Element(&sigAlgo, &tag) { + return asn1.ParamsState{} + } + + return asn1.ParseAlgorithmIDParams(sigAlgo) +} + +// ParseSubjectPublicKeyInfoParams parses the algorithm AlgorithmIdentifier +// from SubjectPublicKeyInfo and returns the parameters state. +func ParseSubjectPublicKeyInfoParams(rawSubjectPublicKeyInfo []byte) asn1.ParamsState { + input := cryptobyte.String(rawSubjectPublicKeyInfo) + + var spki cryptobyte.String + if !input.ReadASN1(&spki, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Read algorithm AlgorithmIdentifier + var algoID cryptobyte.String + var tag cryptobyte_asn1.Tag + if !spki.ReadAnyASN1Element(&algoID, &tag) { + return asn1.ParamsState{} + } + + return asn1.ParseAlgorithmIDParams(algoID) +} \ No newline at end of file diff --git a/internal/crl/zcrypto/builder.go b/internal/crl/zcrypto/builder.go index 4fa2165..dba219c 100644 --- a/internal/crl/zcrypto/builder.go +++ b/internal/crl/zcrypto/builder.go @@ -5,6 +5,7 @@ import ( "github.com/zmap/zcrypto/x509" + "github.com/cavoq/PCL/internal/asn1" "github.com/cavoq/PCL/internal/node" "github.com/cavoq/PCL/internal/zcrypto" ) @@ -30,6 +31,7 @@ func buildCRL(crl *x509.RevocationList) *node.Node { root.Children["thisUpdate"] = node.New("thisUpdate", crl.ThisUpdate) root.Children["nextUpdate"] = node.New("nextUpdate", crl.NextUpdate) root.Children["signatureAlgorithm"] = buildSignatureAlgorithm(crl) + root.Children["tbsSignatureAlgorithm"] = buildTBSSignatureAlgorithm(crl) if crl.Number != nil { root.Children["crlNumber"] = node.New("crlNumber", crl.Number.String()) @@ -55,8 +57,73 @@ func buildCRL(crl *x509.RevocationList) *node.Node { } func buildSignatureAlgorithm(crl *x509.RevocationList) *node.Node { + params := ParseCRLSignatureAlgorithmParams(crl.Raw) n := node.New("signatureAlgorithm", nil) n.Children["algorithm"] = node.New("algorithm", crl.SignatureAlgorithm.String()) + n.Children["oid"] = node.New("oid", params.OID) + n.Children["parameters"] = buildAlgorithmIDParams(params) + return n +} + +func buildTBSSignatureAlgorithm(crl *x509.RevocationList) *node.Node { + params := ParseTBSCRLSignatureParams(crl.RawTBSRevocationList) + n := node.New("tbsSignatureAlgorithm", nil) + n.Children["algorithm"] = node.New("algorithm", crl.SignatureAlgorithm.String()) + n.Children["oid"] = node.New("oid", params.OID) + n.Children["parameters"] = buildAlgorithmIDParams(params) + return n +} + +func buildAlgorithmIDParams(params asn1.ParamsState) *node.Node { + n := node.New("parameters", nil) + n.Children["null"] = node.New("null", params.IsNull) + n.Children["absent"] = node.New("absent", params.IsAbsent) + + if params.PSS != nil { + n.Children["pss"] = buildPSSParams(params.PSS) + } + + if params.OAEP != nil { + n.Children["oaep"] = buildOAEPParams(params.OAEP) + } + + return n +} + +func buildPSSParams(pss *asn1.PSSParams) *node.Node { + n := node.New("pss", nil) + + n.Children["hashAlgorithm"] = buildNestedAlgorithmID(pss.HashAlgorithm) + n.Children["hashAlgorithmSet"] = node.New("hashAlgorithmSet", pss.HashAlgorithmSet) + n.Children["maskGenAlgorithm"] = buildNestedAlgorithmID(pss.MaskGenAlgorithm) + n.Children["maskGenAlgorithmSet"] = node.New("maskGenAlgorithmSet", pss.MaskGenAlgorithmSet) + n.Children["saltLength"] = node.New("saltLength", pss.SaltLength) + n.Children["saltLengthSet"] = node.New("saltLengthSet", pss.SaltLengthSet) + n.Children["trailerField"] = node.New("trailerField", pss.TrailerField) + n.Children["trailerFieldSet"] = node.New("trailerFieldSet", pss.TrailerFieldSet) + + return n +} + +func buildOAEPParams(oaep *asn1.OAEPParams) *node.Node { + n := node.New("oaep", nil) + + n.Children["hashAlgorithm"] = buildNestedAlgorithmID(oaep.HashAlgorithm) + n.Children["hashAlgorithmSet"] = node.New("hashAlgorithmSet", oaep.HashAlgorithmSet) + n.Children["maskGenAlgorithm"] = buildNestedAlgorithmID(oaep.MaskGenAlgorithm) + n.Children["maskGenAlgorithmSet"] = node.New("maskGenAlgorithmSet", oaep.MaskGenAlgorithmSet) + n.Children["pSourceAlgorithm"] = buildNestedAlgorithmID(oaep.PSourceAlgorithm) + n.Children["pSourceAlgorithmSet"] = node.New("pSourceAlgorithmSet", oaep.PSourceAlgorithmSet) + + return n +} + +func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { + n := node.New("algorithm", nil) + n.Children["oid"] = node.New("oid", algo.OID) + if algo.Params.OID != "" { + n.Children["parameters"] = buildAlgorithmIDParams(algo.Params) + } return n } diff --git a/internal/crl/zcrypto/builder_oid_test.go b/internal/crl/zcrypto/builder_oid_test.go new file mode 100644 index 0000000..d36a2dc --- /dev/null +++ b/internal/crl/zcrypto/builder_oid_test.go @@ -0,0 +1,59 @@ +package zcrypto + +import ( + "encoding/pem" + "os" + "testing" + + "github.com/zmap/zcrypto/x509" +) + +func TestBuildTree_CRL_OID(t *testing.T) { + // 使用一个真实的 CRL 文件进行测试 + // 如果文件不存在,跳过测试 + data, err := os.ReadFile("/Users/m0nst3r/dev-local/ssl/sample-certs/evrca-crl1.crl") + if err != nil { + t.Skip("CRL file not found, skipping test") + } + + // 尝试 PEM 解析 + block, _ := pem.Decode(data) + var crl *x509.RevocationList + if block != nil && block.Type == "X509 CRL" { + crl, err = x509.ParseRevocationList(block.Bytes) + } else { + crl, err = x509.ParseRevocationList(data) + } + + if err != nil { + t.Fatalf("Failed to parse CRL: %v", err) + } + + tree := BuildTree(crl) + + // 检查 signatureAlgorithm OID + oidNode, ok := tree.Resolve("crl.signatureAlgorithm.oid") + if !ok { + t.Error("crl.signatureAlgorithm.oid not found") + } else { + oid, ok := oidNode.Value.(string) + if !ok { + t.Error("OID value is not a string") + } else if oid != "1.2.840.113549.1.1.11" { + t.Errorf("Expected OID 1.2.840.113549.1.1.11 (SHA256-RSA), got %s", oid) + } + } + + // 检查 tbsSignatureAlgorithm OID + tbsOidNode, ok := tree.Resolve("crl.tbsSignatureAlgorithm.oid") + if !ok { + t.Error("crl.tbsSignatureAlgorithm.oid not found") + } else { + oid, ok := tbsOidNode.Value.(string) + if !ok { + t.Error("TBS OID value is not a string") + } else if oid != "1.2.840.113549.1.1.11" { + t.Errorf("Expected TBS OID 1.2.840.113549.1.1.11, got %s", oid) + } + } +} \ No newline at end of file diff --git a/internal/crl/zcrypto/parser.go b/internal/crl/zcrypto/parser.go new file mode 100644 index 0000000..213b892 --- /dev/null +++ b/internal/crl/zcrypto/parser.go @@ -0,0 +1,58 @@ +package zcrypto + +import ( + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/cavoq/PCL/internal/asn1" +) + +// ParseTBSCRLSignatureParams parses the signature AlgorithmIdentifier +// from TBSCertList and returns the parameters state. +// TBSCertList structure: version (optional) -> signature -> issuer -> thisUpdate... +func ParseTBSCRLSignatureParams(rawTBSRevocationList []byte) asn1.ParamsState { + input := cryptobyte.String(rawTBSRevocationList) + + var tbsCRL cryptobyte.String + if !input.ReadASN1(&tbsCRL, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Skip version (optional INTEGER) + tbsCRL.SkipOptionalASN1(cryptobyte_asn1.INTEGER) + + // Read signature AlgorithmIdentifier (immediately after version) + var sigAlgoID cryptobyte.String + var tag cryptobyte_asn1.Tag + if !tbsCRL.ReadAnyASN1Element(&sigAlgoID, &tag) { + return asn1.ParamsState{} + } + + return asn1.ParseAlgorithmIDParams(sigAlgoID) +} + +// ParseCRLSignatureAlgorithmParams parses the outer signatureAlgorithm +// from a CRL and returns the parameters state. +func ParseCRLSignatureAlgorithmParams(rawCRL []byte) asn1.ParamsState { + input := cryptobyte.String(rawCRL) + + var crl cryptobyte.String + if !input.ReadASN1(&crl, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Skip TBSCertList + var tbs cryptobyte.String + if !crl.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Read signatureAlgorithm + var sigAlgo cryptobyte.String + var tag cryptobyte_asn1.Tag + if !crl.ReadAnyASN1Element(&sigAlgo, &tag) { + return asn1.ParamsState{} + } + + return asn1.ParseAlgorithmIDParams(sigAlgo) +} \ No newline at end of file diff --git a/internal/linter/runner.go b/internal/linter/runner.go index b9ecb78..4a7c44b 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -8,6 +8,7 @@ import ( "github.com/cavoq/PCL/internal/cert" "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/output" @@ -26,49 +27,98 @@ func Run(cfg Config, w io.Writer) error { policies = append(policies, p) } - certs, cleanup, err := loadCertificates(cfg) - if cleanup != nil { - defer cleanup() - } - if err != nil { - return err - } - - chain, err := cert.BuildChain(certs) - if err != nil { - return fmt.Errorf("failed to build chain: %w", err) - } - - var ctxOpts []operator.ContextOption + reg := operator.DefaultRegistry() + var results []policy.Result + // Load CRLs if provided + var crls []*crl.Info if cfg.CRLPath != "" { - crls, err := crl.GetCRLs(cfg.CRLPath) + crls, err = crl.GetCRLs(cfg.CRLPath) if err != nil { return fmt.Errorf("failed to load CRLs: %w", err) } - ctxOpts = append(ctxOpts, operator.WithCRLs(crls)) } + // Load OCSP if provided + var ocsps []*ocsp.Info if cfg.OCSPPath != "" { - ocsps, err := ocsp.GetOCSPs(cfg.OCSPPath) + ocsps, err = ocsp.GetOCSPs(cfg.OCSPPath) if err != nil { return fmt.Errorf("failed to load OCSP responses: %w", err) } - ctxOpts = append(ctxOpts, operator.WithOCSPs(ocsps)) } - reg := operator.DefaultRegistry() - - var results []policy.Result + // Process certificates if provided + if cfg.CertPath != "" || len(cfg.CertURLs) > 0 { + certs, cleanup, err := loadCertificates(cfg) + if cleanup != nil { + defer cleanup() + } + if err != nil { + return err + } - for _, c := range chain { - tree := zcrypto.BuildTree(c.Cert) - ctx := operator.NewEvaluationContext(tree, c, chain, ctxOpts...) + chain, err := cert.BuildChain(certs) + if err != nil { + return fmt.Errorf("failed to build chain: %w", err) + } - for _, p := range policies { - res := policy.Evaluate(p, tree, reg, ctx) - results = append(results, res) + for _, c := range chain { + tree := zcrypto.BuildTree(c.Cert) + + // Add CRL node to tree if CRLs are present + if len(crls) > 0 { + for _, crlInfo := range crls { + if crlInfo.CRL != nil { + crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + if crlNode != nil { + tree.Children["crl"] = crlNode + } + break + } + } + } + + ctxOpts := []operator.ContextOption{ + operator.WithCRLs(crls), + operator.WithOCSPs(ocsps), + } + ctx := operator.NewEvaluationContext(tree, c, chain, ctxOpts...) + + for _, p := range policies { + res := policy.Evaluate(p, tree, reg, ctx) + results = append(results, res) + } + } + } else if len(crls) > 0 { + // Process CRLs independently when no certificates provided + for _, crlInfo := range crls { + if crlInfo.CRL == nil { + continue + } + + crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + if crlNode == nil { + continue + } + + // Create a minimal tree with just the CRL + tree := crlNode + + ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} + ctx := operator.NewEvaluationContext(tree, nil, nil, ctxOpts...) + + for _, p := range policies { + res := policy.Evaluate(p, tree, reg, ctx) + results = append(results, res) + } } + } else if len(ocsps) > 0 { + // Process OCSP independently when no certificates/CRLs provided + // TODO: Add OCSP-only evaluation if needed + return fmt.Errorf("OCSP-only evaluation requires a certificate") + } else { + return fmt.Errorf("no certificates, CRLs, or OCSP responses provided") } outputOpts := output.Options{ diff --git a/internal/operator/asn1.go b/internal/operator/asn1.go new file mode 100644 index 0000000..9afa9a9 --- /dev/null +++ b/internal/operator/asn1.go @@ -0,0 +1,51 @@ +package operator + +import ( + "github.com/cavoq/PCL/internal/node" +) + +// IsNull checks if the node represents an ASN.1 NULL value. +// Used for checking AlgorithmIdentifier parameters per RFC 4055. +type IsNull struct{} + +func (IsNull) Name() string { return "isNull" } + +func (IsNull) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + nullNode, ok := n.Children["null"] + if !ok { + return false, nil + } + + if v, ok := nullNode.Value.(bool); ok { + return v, nil + } + + return false, nil +} + +// IsAbsent checks if the node represents an absent/missing parameter. +// Used for checking AlgorithmIdentifier parameters that should not be absent per RFC 4055. +type IsAbsent struct{} + +func (IsAbsent) Name() string { return "isAbsent" } + +func (IsAbsent) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return true, nil + } + + absentNode, ok := n.Children["absent"] + if !ok { + return false, nil + } + + if v, ok := absentNode.Value.(bool); ok { + return v, nil + } + + return false, nil +} \ No newline at end of file diff --git a/internal/operator/asn1_test.go b/internal/operator/asn1_test.go new file mode 100644 index 0000000..64c5caf --- /dev/null +++ b/internal/operator/asn1_test.go @@ -0,0 +1,149 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestIsNull(t *testing.T) { + tests := []struct { + name string + node *node.Node + expected bool + }{ + { + name: "nil node", + node: nil, + expected: false, + }, + { + name: "parameters with null=true", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{ + "null": node.New("null", true), + "absent": node.New("absent", false), + }, + }, + expected: true, + }, + { + name: "parameters with null=false", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{ + "null": node.New("null", false), + "absent": node.New("absent", false), + }, + }, + expected: false, + }, + { + name: "parameters without null child", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{}, + }, + expected: false, + }, + { + name: "parameters with non-bool null", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{ + "null": node.New("null", "true"), + }, + }, + expected: false, + }, + } + + op := IsNull{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} + +func TestIsAbsent(t *testing.T) { + tests := []struct { + name string + node *node.Node + expected bool + }{ + { + name: "nil node", + node: nil, + expected: true, + }, + { + name: "parameters with absent=true", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{ + "null": node.New("null", false), + "absent": node.New("absent", true), + }, + }, + expected: true, + }, + { + name: "parameters with absent=false", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{ + "null": node.New("null", true), + "absent": node.New("absent", false), + }, + }, + expected: false, + }, + { + name: "parameters without absent child", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{}, + }, + expected: false, + }, + { + name: "parameters with non-bool absent", + node: &node.Node{ + Name: "parameters", + Value: nil, + Children: map[string]*node.Node{ + "absent": node.New("absent", "true"), + }, + }, + expected: false, + }, + } + + op := IsAbsent{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/operator.go b/internal/operator/operator.go index c0d69d6..f645962 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -58,4 +58,6 @@ var All = []Operator{ OCSPGood{}, NameConstraintsValid{}, CertificatePolicyValid{}, + IsNull{}, + IsAbsent{}, } diff --git a/policies/RFC4055-COVERAGE.md b/policies/RFC4055-COVERAGE.md new file mode 100644 index 0000000..574e3a4 --- /dev/null +++ b/policies/RFC4055-COVERAGE.md @@ -0,0 +1,202 @@ +# RFC 4055 Policy Coverage + +This document tracks implementation of [RFC 4055](https://datatracker.ietf.org/doc/html/rfc4055) requirements for RSA Cryptography. + +> Items marked **(parsing)** are validated by the x509 library during parsing. +> Items marked **(not enforced)** are not currently implemented. + +--- + +## RSA Algorithm OIDs (Section 1) + +### 1.2 RSA Encryption OID + +| Requirement | Level | Rule | Status | +|-------------|-------|------|--------| +| rsaEncryption OID (1.2.840.113549.1.1.1) MUST have NULL parameters | MUST | `cert-spki-rsa-params-null` | ✅ Implemented | + +--- + +## Signature Algorithms (Section 5) + +### 5.1 Algorithm Identifier Encoding + +| Requirement | Level | Rule | Status | +|-------------|-------|------|--------| +| TBSCertificate.signature MUST have NULL parameters for RSA algorithms | MUST | `cert-tbs-signature-rsa-params-null` | ✅ Implemented | +| Certificate.signatureAlgorithm MUST have NULL parameters for RSA algorithms | MUST | `cert-signature-algorithm-rsa-params-null` | ✅ Implemented | +| TBSCertList.signature MUST have NULL parameters for RSA algorithms | MUST | `crl-tbs-signature-rsa-params-null` | ✅ Implemented | +| CRL.signatureAlgorithm MUST have NULL parameters for RSA algorithms | MUST | `crl-signature-algorithm-rsa-params-null` | ✅ Implemented | + +### 5.2 Covered RSA Signature Algorithm OIDs + +| OID | Algorithm | Certificate | CRL | +|-----|-----------|-------------|-----| +| 1.2.840.113549.1.1.1 | rsaEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.2 | md2WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.3 | md4WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.4 | md5WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.5 | sha1WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.11 | sha256WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.12 | sha384WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.13 | sha512WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.14 | sha224WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.15 | sha512-224WithRSAEncryption | ✅ | ✅ | +| 1.2.840.113549.1.1.16 | sha512-256WithRSAEncryption | ✅ | ✅ | + +--- + +## RSASSA-PSS (Section 3) + +### 3.1 Algorithm Identifier + +| OID | Algorithm | Status | +|-----|-----------|--------| +| 1.2.840.113549.1.1.10 | id-RSASSA-PSS | ✅ Implemented | + +### 3.2 RSASSA-PSS Parameters + +RSASSA-PSS uses a SEQUENCE of parameters rather than NULL: + +```asn1 +RSASSA-PSS-params ::= SEQUENCE { + hashAlgorithm [0] HashAlgorithm DEFAULT sha1, + maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1, + saltLength [2] INTEGER DEFAULT 20, + trailerField [3] TrailerField DEFAULT trailerFieldBC +} +``` + +| Requirement | Level | Rule | Status | +|-------------|-------|------|--------| +| hashAlgorithm must be a valid HashAlgorithm | MUST | `cert-tbs-pss-hash-algorithm-valid`, `cert-pss-hash-algorithm-valid`, `crl-tbs-pss-hash-algorithm-valid`, `crl-pss-hash-algorithm-valid` | ✅ Implemented | +| maskGenAlgorithm must be id-mgf1 (OID 1.2.840.113549.1.1.8) | MUST | `cert-tbs-pss-mgf-algorithm-valid`, `cert-pss-mgf-algorithm-valid`, `crl-tbs-pss-mgf-algorithm-valid`, `crl-pss-mgf-algorithm-valid` | ✅ Implemented | +| MGF1 hashAlgorithm must be valid | MUST | `cert-tbs-pss-mgf-hash-valid`, `cert-pss-mgf-hash-valid`, `crl-tbs-pss-mgf-hash-valid`, `crl-pss-mgf-hash-valid` | ✅ Implemented | +| saltLength must be non-negative integer | MUST | `cert-tbs-pss-salt-length-valid`, `cert-pss-salt-length-valid`, `crl-tbs-pss-salt-length-valid`, `crl-pss-salt-length-valid` | ✅ Implemented | +| trailerField must be 1 (trailerFieldBC) | MUST | `cert-tbs-pss-trailer-field-valid`, `cert-pss-trailer-field-valid`, `crl-tbs-pss-trailer-field-valid`, `crl-pss-trailer-field-valid` | ✅ Implemented | +| Parameters must be encoded as SEQUENCE | MUST | (parsing) | ✅ Implemented | +| Empty sequence means default values (SHA-1, MGF1-SHA1, saltLength=20) | MUST | (parsing defaults) | ✅ Implemented | + +### 3.3 HashAlgorithm OIDs + +| OID | Algorithm | Status | +|-----|-----------|--------| +| 1.3.14.3.2.26 | id-sha1 | ✅ Allowed (deprecated) | +| 2.16.840.1.101.3.4.2.1 | id-sha256 | ✅ Allowed | +| 2.16.840.1.101.3.4.2.2 | id-sha384 | ✅ Allowed | +| 2.16.840.1.101.3.4.2.3 | id-sha512 | ✅ Allowed | +| 2.16.840.1.101.3.4.2.4 | id-sha224 | ✅ Allowed | +| 2.16.840.1.101.3.4.2.5 | id-sha512-224 | ✅ Allowed | +| 2.16.840.1.101.3.4.2.6 | id-sha512-256 | ✅ Allowed | + +### 3.4 MaskGenAlgorithm + +| OID | Algorithm | Status | +|-----|-----------|--------| +| 1.2.840.113549.1.1.8 | id-mgf1 | ✅ Required | + +> MGF1 requires a HashAlgorithm parameter, validated by `*-pss-mgf-hash-valid` rules. + +--- + +## RSAES-OAEP (Section 4) + +### 4.1 Algorithm Identifier + +| OID | Algorithm | Status | +|-----|-----------|--------| +| 1.2.840.113549.1.1.7 | id-RSAES-OAEP | ✅ Implemented | + +### 4.2 RSAES-OAEP Parameters + +RSAES-OAEP uses a SEQUENCE of parameters: + +```asn1 +RSAES-OAEP-params ::= SEQUENCE { + hashAlgorithm [0] HashAlgorithm DEFAULT sha1, + maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1, + pSourceAlgorithm [2] PSourceAlgorithm DEFAULT pSpecifiedEmpty +} +``` + +| Requirement | Level | Rule | Status | +|-------------|-------|------|--------| +| hashAlgorithm must be a valid HashAlgorithm | MUST | `cert-spki-oaep-hash-algorithm-valid` | ✅ Implemented | +| maskGenAlgorithm must be id-mgf1 (OID 1.2.840.113549.1.1.8) | MUST | `cert-spki-oaep-mgf-algorithm-valid` | ✅ Implemented | +| MGF1 hashAlgorithm must be valid | MUST | `cert-spki-oaep-mgf-hash-valid` | ✅ Implemented | +| pSourceAlgorithm must be id-pSpecified (OID 1.2.840.113549.1.1.9) | MUST | `cert-spki-oaep-psource-valid` | ✅ Implemented | +| Parameters must be encoded as SEQUENCE | MUST | (parsing) | ✅ Implemented | +| Empty sequence means default values | MUST | (parsing defaults) | ✅ Implemented | + +### 4.3 PSourceAlgorithm + +| OID | Algorithm | Status | +|-----|-----------|--------| +| 1.2.840.113549.1.1.9 | id-pSpecified | ✅ Required | + +> pSpecified encoding of P is validated during parsing. + +--- + +## Out of Scope + +The following are handled by the x509 parsing library or are outside PCL's scope: + +| Item | Reason | +|------|--------| +| RSA key size validation | Covered by RFC5280 best practices | +| RSA exponent validation | Covered by RFC5280 best practices | +| Signature algorithm selection for certificate signing | Implementation choice | +| Compatibility with legacy systems | Policy decision, not RFC requirement | +| MD5 hash algorithm validation | Deprecated, not recommended for new use | +| MD2/MD4 hash algorithm validation | Deprecated, not recommended | + +--- + +## Future Work + +The following items could be enhanced in future versions: + +1. **PSS Hash/MGF Consistency Check** + - Verify hashAlgorithm matches MGF1's hashAlgorithm (recommended but not required by RFC) + +2. **OAEP P Label Validation** + - Validate pSpecified P encoding (currently defaults to empty string) + +3. **Additional OIDs** + - Support for non-standard hash OIDs used by specific implementations + +--- + +## Implementation Notes + +### AlgorithmIdentifier Node Structure + +``` +signatureAlgorithm + ├── algorithm: "SHA256-RSA" + ├── oid: "1.2.840.113549.1.1.11" + └── parameters + ├── null: true # ASN.1 NULL present + └── absent: false # parameters field absent +``` + +For algorithms with complex parameters (PSS, OAEP): +- `null: false` (not ASN.1 NULL) +- `absent: false` (parameters exist) +- Additional parameter fields would be added in future implementation + +### Operators + +| Operator | Purpose | +|----------|---------| +| `isNull` | Check if parameters is ASN.1 NULL | +| `isAbsent` | Check if parameters field is absent | + +--- + +## References + +- [RFC 4055](https://datatracker.ietf.org/doc/html/rfc4055) - Additional Algorithms and Identifiers for RSA Cryptography +- [RFC 5280 Section 4.1.2.3](https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.3) - Signature Algorithm Identifier +- [RFC 8017](https://datatracker.ietf.org/doc/html/rfc8017) - PKCS #1: RSA Cryptography Specifications Version 2.2 \ No newline at end of file diff --git a/policies/RFC4055.yaml b/policies/RFC4055.yaml new file mode 100644 index 0000000..fa94771 --- /dev/null +++ b/policies/RFC4055.yaml @@ -0,0 +1,472 @@ +id: rfc4055 +version: 1.0 + +rules: + # ------------------------------------------------- + # RSA Signature Algorithm Parameters (Section 5) + # RFC 4055: The encoded algorithm identifier MUST have NULL parameters + # ------------------------------------------------- + + # Certificate: TBSCertificate.signature parameters must be NULL for RSA algorithms + - id: cert-tbs-signature-rsa-params-null + reference: RFC4055 Section 5 + when: + target: certificate.tbsSignatureAlgorithm.oid + operator: in + operands: + - "1.2.840.113549.1.1.1" + - "1.2.840.113549.1.1.2" + - "1.2.840.113549.1.1.3" + - "1.2.840.113549.1.1.4" + - "1.2.840.113549.1.1.5" + - "1.2.840.113549.1.1.11" + - "1.2.840.113549.1.1.12" + - "1.2.840.113549.1.1.13" + - "1.2.840.113549.1.1.14" + - "1.2.840.113549.1.1.15" + - "1.2.840.113549.1.1.16" + target: certificate.tbsSignatureAlgorithm.parameters + operator: isNull + severity: error + + # Certificate: signatureAlgorithm parameters must be NULL for RSA algorithms + - id: cert-signature-algorithm-rsa-params-null + reference: RFC4055 Section 5 + when: + target: certificate.signatureAlgorithm.oid + operator: in + operands: + - "1.2.840.113549.1.1.1" + - "1.2.840.113549.1.1.2" + - "1.2.840.113549.1.1.3" + - "1.2.840.113549.1.1.4" + - "1.2.840.113549.1.1.5" + - "1.2.840.113549.1.1.11" + - "1.2.840.113549.1.1.12" + - "1.2.840.113549.1.1.13" + - "1.2.840.113549.1.1.14" + - "1.2.840.113549.1.1.15" + - "1.2.840.113549.1.1.16" + target: certificate.signatureAlgorithm.parameters + operator: isNull + severity: error + + # ------------------------------------------------- + # RSA SubjectPublicKeyInfo Parameters (Section 1.2) + # RFC 4055: rsaEncryption OID MUST have NULL parameters + # ------------------------------------------------- + + - id: cert-spki-rsa-params-null + reference: RFC4055 Section 1.2 + when: + target: certificate.subjectPublicKeyInfo.algorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.1"] + target: certificate.subjectPublicKeyInfo.algorithm.parameters + operator: isNull + severity: error + + # ------------------------------------------------- + # CRL: Signature Algorithm Parameters (Section 5) + # ------------------------------------------------- + + # CRL: TBSCertList.signature parameters must be NULL for RSA algorithms + - id: crl-tbs-signature-rsa-params-null + reference: RFC4055 Section 5 + when: + target: crl.tbsSignatureAlgorithm.oid + operator: in + operands: + - "1.2.840.113549.1.1.1" + - "1.2.840.113549.1.1.2" + - "1.2.840.113549.1.1.3" + - "1.2.840.113549.1.1.4" + - "1.2.840.113549.1.1.5" + - "1.2.840.113549.1.1.11" + - "1.2.840.113549.1.1.12" + - "1.2.840.113549.1.1.13" + - "1.2.840.113549.1.1.14" + - "1.2.840.113549.1.1.15" + - "1.2.840.113549.1.1.16" + target: crl.tbsSignatureAlgorithm.parameters + operator: isNull + severity: error + + # CRL: signatureAlgorithm parameters must be NULL for RSA algorithms + - id: crl-signature-algorithm-rsa-params-null + reference: RFC4055 Section 5 + when: + target: crl.signatureAlgorithm.oid + operator: in + operands: + - "1.2.840.113549.1.1.1" + - "1.2.840.113549.1.1.2" + - "1.2.840.113549.1.1.3" + - "1.2.840.113549.1.1.4" + - "1.2.840.113549.1.1.5" + - "1.2.840.113549.1.1.11" + - "1.2.840.113549.1.1.12" + - "1.2.840.113549.1.1.13" + - "1.2.840.113549.1.1.14" + - "1.2.840.113549.1.1.15" + - "1.2.840.113549.1.1.16" + target: crl.signatureAlgorithm.parameters + operator: isNull + severity: error + + # ------------------------------------------------- + # RSASSA-PSS Parameters (Section 3) + # OID: 1.2.840.113549.1.1.10 + # ------------------------------------------------- + + # PSS: hashAlgorithm must be valid + - id: cert-tbs-pss-hash-algorithm-valid + reference: RFC4055 Section 3.1 + when: + target: certificate.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.tbsSignatureAlgorithm.parameters.pss.hashAlgorithm.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + - id: cert-pss-hash-algorithm-valid + reference: RFC4055 Section 3.1 + when: + target: certificate.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.signatureAlgorithm.parameters.pss.hashAlgorithm.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + # PSS: maskGenAlgorithm must be id-mgf1 (OID 1.2.840.113549.1.1.8) + - id: cert-tbs-pss-mgf-algorithm-valid + reference: RFC4055 Section 3.2 + when: + target: certificate.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.tbsSignatureAlgorithm.parameters.pss.maskGenAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.8"] + severity: error + + - id: cert-pss-mgf-algorithm-valid + reference: RFC4055 Section 3.2 + when: + target: certificate.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.signatureAlgorithm.parameters.pss.maskGenAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.8"] + severity: error + + # PSS: MGF1 hashAlgorithm must be valid + - id: cert-tbs-pss-mgf-hash-valid + reference: RFC4055 Section 3.2 + when: + target: certificate.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.tbsSignatureAlgorithm.parameters.pss.maskGenAlgorithm.parameters.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + - id: cert-pss-mgf-hash-valid + reference: RFC4055 Section 3.2 + when: + target: certificate.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.signatureAlgorithm.parameters.pss.maskGenAlgorithm.parameters.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + # PSS: saltLength must be >= 0 + - id: cert-tbs-pss-salt-length-valid + reference: RFC4055 Section 3.3 + when: + target: certificate.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.tbsSignatureAlgorithm.parameters.pss.saltLength + operator: gte + operands: [0] + severity: error + + - id: cert-pss-salt-length-valid + reference: RFC4055 Section 3.3 + when: + target: certificate.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.signatureAlgorithm.parameters.pss.saltLength + operator: gte + operands: [0] + severity: error + + # PSS: trailerField must be 1 + - id: cert-tbs-pss-trailer-field-valid + reference: RFC4055 Section 3.4 + when: + target: certificate.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.tbsSignatureAlgorithm.parameters.pss.trailerField + operator: eq + operands: [1] + severity: error + + - id: cert-pss-trailer-field-valid + reference: RFC4055 Section 3.4 + when: + target: certificate.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: certificate.signatureAlgorithm.parameters.pss.trailerField + operator: eq + operands: [1] + severity: error + + # ------------------------------------------------- + # RSAES-OAEP Parameters (Section 4) + # OID: 1.2.840.113549.1.1.7 + # ------------------------------------------------- + + # OAEP: hashAlgorithm must be valid (for SubjectPublicKeyInfo) + - id: cert-spki-oaep-hash-algorithm-valid + reference: RFC4055 Section 4.1 + when: + target: certificate.subjectPublicKeyInfo.algorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.7"] + target: certificate.subjectPublicKeyInfo.algorithm.parameters.oaep.hashAlgorithm.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + # OAEP: maskGenAlgorithm must be id-mgf1 + - id: cert-spki-oaep-mgf-algorithm-valid + reference: RFC4055 Section 4.2 + when: + target: certificate.subjectPublicKeyInfo.algorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.7"] + target: certificate.subjectPublicKeyInfo.algorithm.parameters.oaep.maskGenAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.8"] + severity: error + + # OAEP: MGF1 hashAlgorithm must be valid + - id: cert-spki-oaep-mgf-hash-valid + reference: RFC4055 Section 4.2 + when: + target: certificate.subjectPublicKeyInfo.algorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.7"] + target: certificate.subjectPublicKeyInfo.algorithm.parameters.oaep.maskGenAlgorithm.parameters.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + # OAEP: pSourceAlgorithm must be id-pSpecified (OID 1.2.840.113549.1.1.9) + - id: cert-spki-oaep-psource-valid + reference: RFC4055 Section 4.3 + when: + target: certificate.subjectPublicKeyInfo.algorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.7"] + target: certificate.subjectPublicKeyInfo.algorithm.parameters.oaep.pSourceAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.9"] + severity: error + + # ------------------------------------------------- + # CRL: RSASSA-PSS Parameters + # ------------------------------------------------- + + - id: crl-tbs-pss-hash-algorithm-valid + reference: RFC4055 Section 3.1 + when: + target: crl.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.tbsSignatureAlgorithm.parameters.pss.hashAlgorithm.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + - id: crl-pss-hash-algorithm-valid + reference: RFC4055 Section 3.1 + when: + target: crl.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.signatureAlgorithm.parameters.pss.hashAlgorithm.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + - id: crl-tbs-pss-mgf-algorithm-valid + reference: RFC4055 Section 3.2 + when: + target: crl.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.tbsSignatureAlgorithm.parameters.pss.maskGenAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.8"] + severity: error + + - id: crl-pss-mgf-algorithm-valid + reference: RFC4055 Section 3.2 + when: + target: crl.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.signatureAlgorithm.parameters.pss.maskGenAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.8"] + severity: error + + - id: crl-tbs-pss-mgf-hash-valid + reference: RFC4055 Section 3.2 + when: + target: crl.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.tbsSignatureAlgorithm.parameters.pss.maskGenAlgorithm.parameters.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + - id: crl-pss-mgf-hash-valid + reference: RFC4055 Section 3.2 + when: + target: crl.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.signatureAlgorithm.parameters.pss.maskGenAlgorithm.parameters.oid + operator: in + operands: + - "1.3.14.3.2.26" + - "2.16.840.1.101.3.4.2.1" + - "2.16.840.1.101.3.4.2.2" + - "2.16.840.1.101.3.4.2.3" + - "2.16.840.1.101.3.4.2.4" + - "2.16.840.1.101.3.4.2.5" + - "2.16.840.1.101.3.4.2.6" + severity: error + + - id: crl-tbs-pss-salt-length-valid + reference: RFC4055 Section 3.3 + when: + target: crl.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.tbsSignatureAlgorithm.parameters.pss.saltLength + operator: gte + operands: [0] + severity: error + + - id: crl-pss-salt-length-valid + reference: RFC4055 Section 3.3 + when: + target: crl.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.signatureAlgorithm.parameters.pss.saltLength + operator: gte + operands: [0] + severity: error + + - id: crl-tbs-pss-trailer-field-valid + reference: RFC4055 Section 3.4 + when: + target: crl.tbsSignatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.tbsSignatureAlgorithm.parameters.pss.trailerField + operator: eq + operands: [1] + severity: error + + - id: crl-pss-trailer-field-valid + reference: RFC4055 Section 3.4 + when: + target: crl.signatureAlgorithm.oid + operator: eq + operands: ["1.2.840.113549.1.1.10"] + target: crl.signatureAlgorithm.parameters.pss.trailerField + operator: eq + operands: [1] + severity: error \ No newline at end of file From fcf0110310fcc6cad703974d232d1ea60074cd13 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Tue, 28 Apr 2026 18:47:05 +0800 Subject: [PATCH 02/44] feat: implement policy filtering and OCSP support - Add policy filtering by input type (cert, crl, ocsp) and certType/crlType - Implement auto OCSP fetching with --auto-ocsp flag - Support multiple issuer paths with repeatable --issuer flag - Add RFC6960 OCSP response validation rules - Add id-pkix-ocsp-nocheck and CRL DP detection for OCSP responder certs - Fix evaluator to handle present/absent operators when target not found --- cmd/pcl/main.go | 15 +- internal/cert/zcrypto/builder.go | 17 ++ internal/linter/config.go | 25 ++- internal/linter/filter.go | 270 +++++++++++++++++++++++++ internal/linter/runner.go | 194 ++++++++++++++++-- internal/ocsp/fetcher.go | 105 ++++++++++ internal/ocsp/fetcher_test.go | 108 ++++++++++ internal/ocsp/zcrypto/builder.go | 175 +++++++++++++++++ internal/ocsp/zcrypto/builder_test.go | 271 ++++++++++++++++++++++++++ internal/ocsp/zcrypto/parser.go | 73 +++++++ internal/policy/engine.go | 13 +- internal/rule/evaluator.go | 26 ++- 12 files changed, 1260 insertions(+), 32 deletions(-) create mode 100644 internal/linter/filter.go create mode 100644 internal/ocsp/fetcher.go create mode 100644 internal/ocsp/fetcher_test.go create mode 100644 internal/ocsp/zcrypto/builder.go create mode 100644 internal/ocsp/zcrypto/builder_test.go create mode 100644 internal/ocsp/zcrypto/parser.go diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index 85d83d8..a2abd18 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -18,8 +18,13 @@ func newRootCmd(opts *linter.Config) *cobra.Command { if opts.PolicyPath == "" { return fmt.Errorf("--policy is required") } - if opts.CertPath == "" && len(opts.CertURLs) == 0 && opts.CRLPath == "" && opts.OCSPPath == "" { - return fmt.Errorf("at least one of --cert, --cert-url, --crl, or --ocsp is required") + hasCert := opts.CertPath != "" || len(opts.CertURLs) > 0 + hasIssuer := len(opts.IssuerPaths) > 0 || len(opts.IssuerURLs) > 0 + if !hasCert && !hasIssuer && opts.CRLPath == "" && opts.OCSPPath == "" { + return fmt.Errorf("at least one of --cert, --cert-url, --issuer, --issuer-url, --crl, or --ocsp is required") + } + if opts.AutoOCSP && !hasIssuer { + return fmt.Errorf("--auto-ocsp requires --issuer or --issuer-url") } return linter.Run(*opts, cmd.OutOrStdout()) }, @@ -30,8 +35,12 @@ func newRootCmd(opts *linter.Config) *cobra.Command { root.Flags().StringSliceVar(&opts.CertURLs, "cert-url", nil, "Certificate URL (repeatable)") root.Flags().DurationVar(&opts.CertTimeout, "cert-url-timeout", 10*time.Second, "Certificate URL timeout (e.g. 10s, 1m)") root.Flags().StringVar(&opts.CertSaveDir, "cert-url-save-dir", "", "Directory to save downloaded certs (optional)") + root.Flags().StringSliceVar(&opts.IssuerPaths, "issuer", nil, "Path to issuer certificate file or directory (repeatable, PEM/DER)") + root.Flags().StringSliceVar(&opts.IssuerURLs, "issuer-url", nil, "Issuer certificate URL (repeatable)") root.Flags().StringVar(&opts.CRLPath, "crl", "", "Path to CRL file or directory (PEM/DER)") root.Flags().StringVar(&opts.OCSPPath, "ocsp", "", "Path to OCSP response file or directory (DER/PEM)") + root.Flags().BoolVar(&opts.AutoOCSP, "auto-ocsp", false, "Automatically fetch OCSP response from certificate's AIA extension (requires issuer)") + root.Flags().DurationVar(&opts.OCSPTimeout, "ocsp-url-timeout", 5*time.Second, "OCSP request timeout (e.g. 5s, 10s)") root.Flags().StringVar(&opts.OutputFmt, "output", "text", "Output format: text, json, or yaml") root.Flags().CountVarP(&opts.Verbosity, "verbose", "v", "Increase output detail: -v shows passed, -vv includes skipped") root.Flags().BoolVar(&opts.ShowMeta, "show-meta", true, "Show lint meta information") @@ -46,4 +55,4 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 4f9780e..236d43c 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -3,6 +3,7 @@ package zcrypto import ( "crypto/ecdsa" "crypto/rsa" + "fmt" "github.com/zmap/zcrypto/x509" @@ -81,6 +82,20 @@ func buildCertificate(cert *x509.Certificate) *node.Node { root.Children["signatureValue"] = node.New("signatureValue", cert.Signature) } + // Add OCSP URL from AIA extension + if len(cert.OCSPServer) > 0 { + root.Children["ocspURL"] = node.New("ocspURL", cert.OCSPServer[0]) + } + + // Add CRL Distribution Points + if len(cert.CRLDistributionPoints) > 0 { + crlDPNode := node.New("cRLDistributionPoints", nil) + for i, uri := range cert.CRLDistributionPoints { + crlDPNode.Children[fmt.Sprintf("%d", i)] = node.New(fmt.Sprintf("%d", i), uri) + } + root.Children["cRLDistributionPoints"] = crlDPNode + } + return root } @@ -196,7 +211,9 @@ func buildKeyUsage(ku x509.KeyUsage) *node.Node { n.Children["digitalSignature"] = node.New("digitalSignature", true) } if ku&x509.KeyUsageContentCommitment != 0 { + // Both names for the same bit: contentCommitment (RFC name) and nonRepudiation (common name) n.Children["contentCommitment"] = node.New("contentCommitment", true) + n.Children["nonRepudiation"] = node.New("nonRepudiation", true) } if ku&x509.KeyUsageKeyEncipherment != 0 { n.Children["keyEncipherment"] = node.New("keyEncipherment", true) diff --git a/internal/linter/config.go b/internal/linter/config.go index e63f3d2..8f0df56 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -3,14 +3,19 @@ package linter import "time" type Config struct { - PolicyPath string - CertPath string - CertURLs []string - CertTimeout time.Duration - CertSaveDir string - CRLPath string - OCSPPath string - OutputFmt string - Verbosity int - ShowMeta bool + PolicyPath string + CertPath string + CertURLs []string + CertTimeout time.Duration + CertSaveDir string + IssuerPath string // Single issuer path (backward compatible) + IssuerPaths []string // Multiple issuer paths + IssuerURLs []string + CRLPath string + OCSPPath string + AutoOCSP bool + OCSPTimeout time.Duration + OutputFmt string + Verbosity int + ShowMeta bool } diff --git a/internal/linter/filter.go b/internal/linter/filter.go new file mode 100644 index 0000000..60f6687 --- /dev/null +++ b/internal/linter/filter.go @@ -0,0 +1,270 @@ +package linter + +import ( + "slices" + + "github.com/zmap/zcrypto/x509" + + "github.com/cavoq/PCL/internal/policy" +) + +// OID constants for CRL extensions +const ( + oidDeltaCRLIndicator = "2.5.29.27" + oidIssuingDistributionPoint = "2.5.29.29" +) + +// AppliesTo types - fixed enumeration for PKI input types +const ( + AppliesToCert = "cert" + AppliesToCRL = "crl" + AppliesToOCSP = "ocsp" + AppliesToTST = "tst" + AppliesToSCT = "sct" + AppliesToAttrCert = "attrCert" +) + +// OID name mappings for human-readable certType/crlType values +var oidNameMap = map[string]string{ + // Extended Key Usage OIDs + "serverAuth": "1.3.6.1.5.5.7.3.1", + "clientAuth": "1.3.6.1.5.5.7.3.2", + "codeSigning": "1.3.6.1.5.5.7.3.3", + "emailProtection": "1.3.6.1.5.5.7.3.4", + "timeStamping": "1.3.6.1.5.5.7.3.8", + "ocspSigning": "1.3.6.1.5.5.7.3.9", + "1.3.6.1.5.5.7.3.1": "serverAuth", + "1.3.6.1.5.5.7.3.2": "clientAuth", + "1.3.6.1.5.5.7.3.3": "codeSigning", + "1.3.6.1.5.5.7.3.4": "emailProtection", + "1.3.6.1.5.5.7.3.8": "timeStamping", + "1.3.6.1.5.5.7.3.9": "ocspSigning", + + // CRL extension OIDs + "deltaCRLIndicator": "2.5.29.27", + "issuingDistributionPoint": "2.5.29.29", + "2.5.29.27": "deltaCRLIndicator", + "2.5.29.29": "issuingDistributionPoint", + + // Built-in cert types + "ca": "ca", + "leaf": "leaf", +} + +// normalizeOID converts human-readable name to OID or returns the OID if already an OID +func normalizeOID(nameOrOID string) string { + if oid, ok := oidNameMap[nameOrOID]; ok { + // If input is a name, return the OID + if len(oid) > 10 && oid[0:4] != "ca" && oid[0:4] != "leaf" { + return oid + } + } + // Input might already be an OID or a built-in type + return nameOrOID +} + +// policyAppliesToInput checks if a policy applies to the given input type +func policyAppliesToInput(p policy.Policy, inputType string) bool { + // If AppliesTo is empty, apply to all (backward compatible) + if len(p.AppliesTo) == 0 { + return true + } + return slices.Contains(p.AppliesTo, inputType) +} + +// policyAppliesToCert checks if a policy applies to a specific certificate +func policyAppliesToCert(p policy.Policy, cert *x509.Certificate) bool { + // Check input type first + if !policyAppliesToInput(p, AppliesToCert) { + return false + } + + // If no certType filter, applies to all certs + if len(p.CertType) == 0 { + return true + } + + // Check each certType constraint + for _, ct := range p.CertType { + ct = normalizeOID(ct) + + // Built-in types + if ct == "ca" { + if cert.BasicConstraintsValid && cert.IsCA { + return true + } + continue + } + if ct == "leaf" { + if !cert.BasicConstraintsValid || !cert.IsCA { + return true + } + continue + } + + // EKU OID check + for _, eku := range cert.ExtKeyUsage { + ekuOID := extKeyUsageToOID(eku) + if ekuOID == ct { + return true + } + } + } + + return false +} + +// policyAppliesToCRL checks if a policy applies to a specific CRL +func policyAppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) bool { + // Check input type first + if !policyAppliesToInput(p, AppliesToCRL) { + return false + } + + // If no crlType filter, applies to all CRLs + if len(p.CRLType) == 0 { + return true + } + + // Check each crlType constraint + for _, ct := range p.CRLType { + ct = normalizeOID(ct) + + if ct == "deltaCRLIndicator" || ct == "2.5.29.27" { + if hasDeltaIndicator { + return true + } + continue + } + + if ct == "indirectCRL" { + if isIndirectCRL { + return true + } + continue + } + + if ct == "completeCRL" { + if !hasDeltaIndicator { + return true + } + continue + } + } + + return false +} + +// policyAppliesToOCSP checks if a policy applies to OCSP responses +func policyAppliesToOCSP(p policy.Policy) bool { + return policyAppliesToInput(p, AppliesToOCSP) +} + +// extKeyUsageToOID converts x509.ExtKeyUsage to OID string +func extKeyUsageToOID(eku x509.ExtKeyUsage) string { + switch eku { + case x509.ExtKeyUsageServerAuth: + return "1.3.6.1.5.5.7.3.1" + case x509.ExtKeyUsageClientAuth: + return "1.3.6.1.5.5.7.3.2" + case x509.ExtKeyUsageCodeSigning: + return "1.3.6.1.5.5.7.3.3" + case x509.ExtKeyUsageEmailProtection: + return "1.3.6.1.5.5.7.3.4" + case x509.ExtKeyUsageTimeStamping: + return "1.3.6.1.5.5.7.3.8" + case x509.ExtKeyUsageOcspSigning: + return "1.3.6.1.5.5.7.3.9" + default: + return "" + } +} + +// hasDeltaCRLIndicator checks if CRL has the delta CRL indicator extension +func hasDeltaCRLIndicator(crl *x509.RevocationList) bool { + if crl == nil { + return false + } + for _, ext := range crl.Extensions { + if ext.Id.String() == oidDeltaCRLIndicator { + return true + } + } + return false +} + +// isIndirectCRL checks if CRL is an indirect CRL (issued by different CA) +// Indirect CRL is indicated when the CRL issuer differs from the certificate issuer. +// This can be detected via the IssuingDistributionPoint extension's indirectCRL field. +func isIndirectCRL(crl *x509.RevocationList) bool { + if crl == nil { + return false + } + for _, ext := range crl.Extensions { + if ext.Id.String() == oidIssuingDistributionPoint { + // The indirectCRL field is a boolean in the IssuingDistributionPoint extension + // If the extension is present, we check the raw value for indirectCRL indicator + // The ASN.1 structure includes an optional indirectCRL BOOLEAN DEFAULT FALSE + // Parsing this requires decoding the extension value + return checkIndirectCRLInExtension(ext.Value) + } + } + return false +} + +// checkIndirectCRLInExtension parses the IssuingDistributionPoint extension to find indirectCRL field +func checkIndirectCRLInExtension(extValue []byte) bool { + // IssuingDistributionPoint ASN.1 structure (RFC 5280): + // IssuingDistributionPoint ::= SEQUENCE { + // distributionPoint [0] DistributionPointName OPTIONAL, + // onlyContainsUserCerts [1] BOOLEAN DEFAULT FALSE, + // onlyContainsCACerts [2] BOOLEAN DEFAULT FALSE, + // onlySomeReasons [3] ReasonFlags OPTIONAL, + // indirectCRL [4] BOOLEAN DEFAULT FALSE, + // onlyContainsAttributeCerts [5] BOOLEAN DEFAULT FALSE + // } + // We need to check if the [4] indirectCRL field is present and TRUE + // The tag for indirectCRL is context-specific [4] which is 0x84 in DER + for i := 0; i < len(extValue)-1; i++ { + if extValue[i] == 0x84 { // context-specific tag [4] for indirectCRL + // Next byte should be the length (typically 1 for BOOLEAN TRUE) + if extValue[i+1] == 0x01 && i+2 < len(extValue) { + return extValue[i+2] == 0xFF // TRUE in ASN.1 DER + } + } + } + return false +} + +// filterPoliciesByInput returns policies that apply to the given input type +func filterPoliciesByInput(policies []policy.Policy, inputType string) []policy.Policy { + var filtered []policy.Policy + for _, p := range policies { + if policyAppliesToInput(p, inputType) { + filtered = append(filtered, p) + } + } + return filtered +} + +// filterPoliciesByCert returns policies that apply to the given certificate +func filterPoliciesByCert(policies []policy.Policy, cert *x509.Certificate) []policy.Policy { + var filtered []policy.Policy + for _, p := range policies { + if policyAppliesToCert(p, cert) { + filtered = append(filtered, p) + } + } + return filtered +} + +// filterPoliciesByCRL returns policies that apply to the given CRL +func filterPoliciesByCRL(policies []policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) []policy.Policy { + var filtered []policy.Policy + for _, p := range policies { + if policyAppliesToCRL(p, hasDeltaIndicator, isIndirectCRL) { + filtered = append(filtered, p) + } + } + return filtered +} \ No newline at end of file diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 4a7c44b..cf87ac9 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -1,18 +1,21 @@ package linter import ( + certstd "crypto/x509" "fmt" "io" "time" "github.com/cavoq/PCL/internal/cert" - "github.com/cavoq/PCL/internal/cert/zcrypto" + 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" + ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/output" "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/zcrypto" ) func Run(cfg Config, w io.Writer) error { @@ -49,22 +52,57 @@ func Run(cfg Config, w io.Writer) error { } // Process certificates if provided - if cfg.CertPath != "" || len(cfg.CertURLs) > 0 { - certs, cleanup, err := loadCertificates(cfg) + hasCert := cfg.CertPath != "" || len(cfg.CertURLs) > 0 + hasIssuer := len(cfg.IssuerPaths) > 0 || len(cfg.IssuerURLs) > 0 + + if hasCert || hasIssuer { + // Load leaf certificates + var certs []*cert.Info + var cleanup func() + + if hasCert { + certs, cleanup, err = loadCertificates(cfg) + if err != nil { + return err + } + } + + // Load issuer certificates + var issuers []*cert.Info + if hasIssuer { + issuers, cleanup, err = loadIssuers(cfg, cleanup) + if err != nil { + return err + } + } + if cleanup != nil { defer cleanup() } - if err != nil { - return err + + // Build chain: leaf + issuers + allCerts := append(certs, issuers...) + if len(allCerts) == 0 { + return fmt.Errorf("no certificates provided") } - chain, err := cert.BuildChain(certs) + chain, err := cert.BuildChain(allCerts) if err != nil { return fmt.Errorf("failed to build chain: %w", err) } + // Auto-fetch OCSP if enabled and chain has issuer + if cfg.AutoOCSP && len(chain) >= 2 { + autoOCSPs, err := fetchAutoOCSP(chain, cfg.OCSPTimeout) + if err != nil { + // Log warning but continue - OCSP fetch failure shouldn't stop cert validation + fmt.Fprintf(w, "Warning: auto OCSP fetch failed: %v\n", err) + } + ocsps = append(ocsps, autoOCSPs...) + } + for _, c := range chain { - tree := zcrypto.BuildTree(c.Cert) + tree := certzcrypto.BuildTree(c.Cert) // Add CRL node to tree if CRLs are present if len(crls) > 0 { @@ -85,11 +123,37 @@ func Run(cfg Config, w io.Writer) error { } ctx := operator.NewEvaluationContext(tree, c, chain, ctxOpts...) - for _, p := range policies { + // Filter policies by certificate type + filteredPolicies := filterPoliciesByCert(policies, c.Cert) + for _, p := range filteredPolicies { res := policy.Evaluate(p, tree, reg, ctx) results = append(results, res) } } + + // Evaluate OCSP policies if OCSP responses were fetched + if len(ocsps) > 0 { + for _, ocspInfo := range ocsps { + if ocspInfo.Response == nil { + continue + } + + ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) + if ocspNode == nil { + continue + } + + tree := ocspNode + ctxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + ctx := operator.NewEvaluationContext(tree, nil, chain, ctxOpts...) + + filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, reg, ctx) + results = append(results, res) + } + } + } } else if len(crls) > 0 { // Process CRLs independently when no certificates provided for _, crlInfo := range crls { @@ -108,15 +172,40 @@ func Run(cfg Config, w io.Writer) error { ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} ctx := operator.NewEvaluationContext(tree, nil, nil, ctxOpts...) - for _, p := range policies { + // Filter policies by CRL type + hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) + isIndirect := isIndirectCRL(crlInfo.CRL) + filteredPolicies := filterPoliciesByCRL(policies, hasDelta, isIndirect) + for _, p := range filteredPolicies { res := policy.Evaluate(p, tree, reg, ctx) results = append(results, res) } } } else if len(ocsps) > 0 { // Process OCSP independently when no certificates/CRLs provided - // TODO: Add OCSP-only evaluation if needed - return fmt.Errorf("OCSP-only evaluation requires a certificate") + for _, ocspInfo := range ocsps { + if ocspInfo.Response == nil { + continue + } + + ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) + if ocspNode == nil { + continue + } + + // Use OCSP node directly as tree root + tree := ocspNode + + ctxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + ctx := operator.NewEvaluationContext(tree, nil, nil, ctxOpts...) + + // Filter policies by input type (OCSP) + filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, reg, ctx) + results = append(results, res) + } + } } else { return fmt.Errorf("no certificates, CRLs, or OCSP responses provided") } @@ -163,17 +252,98 @@ func loadCertificates(cfg Config) ([]*cert.Info, func(), error) { } if len(certs) == 0 { - return nil, cleanup, fmt.Errorf("no certificates provided") + return nil, cleanup, fmt.Errorf("no leaf certificates provided") } return certs, cleanup, nil } +func loadIssuers(cfg Config, existingCleanup func()) ([]*cert.Info, func(), error) { + var cleanup func() = existingCleanup + var issuers []*cert.Info + + for _, path := range cfg.IssuerPaths { + loaded, err := cert.LoadCertificates(path) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to load issuer certificates from %s: %w", path, err) + } + issuers = append(issuers, loaded...) + } + + if len(cfg.IssuerURLs) > 0 { + dir, tempCleanup, err := cert.DownloadCertificates(cfg.IssuerURLs, cfg.CertTimeout, cfg.CertSaveDir) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to download issuer certificates: %w", err) + } + if tempCleanup != nil { + cleanup = tempCleanup + } + loaded, err := cert.LoadCertificates(dir) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to load downloaded issuer certificates: %w", err) + } + issuers = append(issuers, loaded...) + } + + if len(issuers) == 0 { + return nil, cleanup, fmt.Errorf("no issuer certificates provided") + } + + return issuers, cleanup, nil +} + func applyDefaults(cfg *Config) { if cfg.CertTimeout <= 0 { cfg.CertTimeout = 10 * time.Second } + if cfg.OCSPTimeout <= 0 { + cfg.OCSPTimeout = 5 * time.Second + } if cfg.OutputFmt == "" { cfg.OutputFmt = "text" } } + +// fetchAutoOCSP automatically fetches OCSP responses for certificates in the chain. +// For leaf certificates, uses the OCSP URL from AIA extension and issuer from chain. +func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration) ([]*ocsp.Info, error) { + if len(chain) < 2 { + return nil, fmt.Errorf("chain must have at least 2 certificates for OCSP request") + } + + var results []*ocsp.Info + + // Convert zcrypto certs to standard certs for OCSP request + stdChain := make([]*certstd.Certificate, 0, len(chain)) + for _, c := range chain { + if c.Cert == nil { + continue + } + stdCert, err := zcrypto.ToStdCert(c.Cert) + if err != nil { + continue + } + stdChain = append(stdChain, stdCert) + } + + if len(stdChain) < 2 { + return nil, fmt.Errorf("failed to convert certificates to standard format") + } + + // Fetch OCSP for leaf certificate + resp, url, err := ocsp.FetchOCSPFromChain(stdChain, timeout) + if err != nil { + return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) + } + if resp == nil { + // No OCSP URL in certificate, not an error + return nil, nil + } + + results = append(results, &ocsp.Info{ + Response: resp, + FilePath: url, // Use URL as "file path" for auto-fetched responses + }) + + return results, nil +} \ No newline at end of file diff --git a/internal/ocsp/fetcher.go b/internal/ocsp/fetcher.go new file mode 100644 index 0000000..0f04a18 --- /dev/null +++ b/internal/ocsp/fetcher.go @@ -0,0 +1,105 @@ +package ocsp + +import ( + "bytes" + "crypto" + "crypto/x509" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/crypto/ocsp" +) + +// GetOCSPURLFromCert extracts OCSP URL from certificate's AIA extension. +// Returns empty string if no OCSP URL is present. +func GetOCSPURLFromCert(cert *x509.Certificate) string { + if cert == nil || len(cert.OCSPServer) == 0 { + return "" + } + return cert.OCSPServer[0] +} + +// FetchOCSP sends an OCSP request to the specified URL and returns the response. +// Requires issuer certificate to compute IssuerNameHash and IssuerKeyHash per RFC 6960. +// The response is parsed but signature is not verified (nil issuer passed to ParseResponse). +// Signature verification should be done by operators (ocspValid operator). +func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration) (*ocsp.Response, error) { + if cert == nil { + return nil, fmt.Errorf("certificate is required") + } + if issuer == nil { + return nil, fmt.Errorf("issuer certificate is required for OCSP request") + } + if url == "" { + return nil, fmt.Errorf("OCSP URL is required") + } + + // Create OCSP request + req, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{ + Hash: crypto.SHA256, // Use SHA256 for issuer hash + }) + if err != nil { + return nil, fmt.Errorf("failed to create OCSP request: %w", err) + } + + // Send HTTP POST request + client := &http.Client{ + Timeout: timeout, + } + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(req)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/ocsp-request") + httpReq.Header.Set("Accept", "application/ocsp-response") + + httpResp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send OCSP request: %w", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OCSP server returned status %d", httpResp.StatusCode) + } + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OCSP response: %w", err) + } + + // Parse response without signature verification + // Signature verification is done by ocspValid operator with chain context + resp, err := ocsp.ParseResponse(body, nil) + if err != nil { + return nil, fmt.Errorf("failed to parse OCSP response: %w", err) + } + + return resp, nil +} + +// FetchOCSPFromChain automatically fetches OCSP response for the leaf certificate. +// Uses the first OCSP URL from leaf cert's AIA extension and the issuer from chain. +// Returns nil if no OCSP URL is present or chain is insufficient. +func FetchOCSPFromChain(chain []*x509.Certificate, timeout time.Duration) (*ocsp.Response, string, error) { + if len(chain) < 2 { + return nil, "", fmt.Errorf("chain must have at least 2 certificates (leaf + issuer)") + } + + leaf := chain[0] + issuer := chain[1] + + url := GetOCSPURLFromCert(leaf) + if url == "" { + return nil, "", nil // No OCSP URL, not an error + } + + resp, err := FetchOCSP(leaf, issuer, url, timeout) + if err != nil { + return nil, url, err + } + + return resp, url, nil +} \ No newline at end of file diff --git a/internal/ocsp/fetcher_test.go b/internal/ocsp/fetcher_test.go new file mode 100644 index 0000000..1ffe1bf --- /dev/null +++ b/internal/ocsp/fetcher_test.go @@ -0,0 +1,108 @@ +package ocsp + +import ( + "crypto/x509" + "testing" +) + +func TestGetOCSPURLFromCert_NilCert(t *testing.T) { + url := GetOCSPURLFromCert(nil) + if url != "" { + t.Errorf("Expected empty URL for nil cert, got %s", url) + } +} + +func TestGetOCSPURLFromCert_NoOCSPServer(t *testing.T) { + cert := &x509.Certificate{ + OCSPServer: nil, + } + url := GetOCSPURLFromCert(cert) + if url != "" { + t.Errorf("Expected empty URL for cert with no OCSP server, got %s", url) + } +} + +func TestGetOCSPURLFromCert_EmptyOCSPServer(t *testing.T) { + cert := &x509.Certificate{ + OCSPServer: []string{}, + } + url := GetOCSPURLFromCert(cert) + if url != "" { + t.Errorf("Expected empty URL for cert with empty OCSP server list, got %s", url) + } +} + +func TestGetOCSPURLFromCert_SingleOCSPServer(t *testing.T) { + cert := &x509.Certificate{ + OCSPServer: []string{"http://ocsp.example.com"}, + } + url := GetOCSPURLFromCert(cert) + if url != "http://ocsp.example.com" { + t.Errorf("Expected http://ocsp.example.com, got %s", url) + } +} + +func TestGetOCSPURLFromCert_MultipleOCSPServers(t *testing.T) { + cert := &x509.Certificate{ + OCSPServer: []string{"http://ocsp1.example.com", "http://ocsp2.example.com"}, + } + url := GetOCSPURLFromCert(cert) + // Should return the first URL + if url != "http://ocsp1.example.com" { + t.Errorf("Expected http://ocsp1.example.com, got %s", url) + } +} + +func TestFetchOCSP_NilCert(t *testing.T) { + _, err := FetchOCSP(nil, &x509.Certificate{}, "http://example.com", 5) + if err == nil { + t.Error("Expected error for nil cert") + } +} + +func TestFetchOCSP_NilIssuer(t *testing.T) { + _, err := FetchOCSP(&x509.Certificate{}, nil, "http://example.com", 5) + if err == nil { + t.Error("Expected error for nil issuer") + } +} + +func TestFetchOCSP_EmptyURL(t *testing.T) { + _, err := FetchOCSP(&x509.Certificate{}, &x509.Certificate{}, "", 5) + if err == nil { + t.Error("Expected error for empty URL") + } +} + +func TestFetchOCSPFromChain_TooShort(t *testing.T) { + chain := []*x509.Certificate{&x509.Certificate{}} + _, _, err := FetchOCSPFromChain(chain, 5) + if err == nil { + t.Error("Expected error for chain with less than 2 certificates") + } +} + +func TestFetchOCSPFromChain_EmptyChain(t *testing.T) { + chain := []*x509.Certificate{} + _, _, err := FetchOCSPFromChain(chain, 5) + if err == nil { + t.Error("Expected error for empty chain") + } +} + +func TestFetchOCSPFromChain_NoOCSPURL(t *testing.T) { + chain := []*x509.Certificate{ + &x509.Certificate{OCSPServer: nil}, + &x509.Certificate{}, + } + resp, url, err := FetchOCSPFromChain(chain, 5) + if err != nil { + t.Errorf("Expected no error for cert without OCSP URL, got %v", err) + } + if resp != nil { + t.Error("Expected nil response for cert without OCSP URL") + } + if url != "" { + t.Errorf("Expected empty URL for cert without OCSP URL, got %s", url) + } +} \ No newline at end of file diff --git a/internal/ocsp/zcrypto/builder.go b/internal/ocsp/zcrypto/builder.go new file mode 100644 index 0000000..62cdd8d --- /dev/null +++ b/internal/ocsp/zcrypto/builder.go @@ -0,0 +1,175 @@ +package zcrypto + +import ( + "crypto" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + + "golang.org/x/crypto/ocsp" + + "github.com/cavoq/PCL/internal/asn1" + "github.com/cavoq/PCL/internal/node" +) + +type OCSPBuilder struct{} + +func NewOCSPBuilder() *OCSPBuilder { + return &OCSPBuilder{} +} + +func (b *OCSPBuilder) Build(resp *ocsp.Response) *node.Node { + return buildOCSP(resp) +} + +func BuildTree(resp *ocsp.Response) *node.Node { + return NewOCSPBuilder().Build(resp) +} + +func buildOCSP(resp *ocsp.Response) *node.Node { + root := node.New("ocsp", nil) + + // Status + root.Children["status"] = node.New("status", statusString(resp.Status)) + + // SerialNumber + if resp.SerialNumber != nil { + root.Children["serialNumber"] = node.New("serialNumber", resp.SerialNumber.String()) + } + + // Times + root.Children["producedAt"] = node.New("producedAt", resp.ProducedAt) + root.Children["thisUpdate"] = node.New("thisUpdate", resp.ThisUpdate) + if !resp.NextUpdate.IsZero() { + root.Children["nextUpdate"] = node.New("nextUpdate", resp.NextUpdate) + } + + // Revocation info (if revoked) + if resp.Status == ocsp.Revoked { + root.Children["revokedAt"] = node.New("revokedAt", resp.RevokedAt) + root.Children["revocationReason"] = node.New("revocationReason", resp.RevocationReason) + } + + // Signature algorithm (from BasicOCSPResponse) + // OCSP has only one signatureAlgorithm field, not separate TBS and outer like certificates/CRLs + params := ParseOCSPSignatureAlgorithmParams(resp.Raw) + root.Children["signatureAlgorithm"] = buildSignatureAlgorithm(resp.SignatureAlgorithm, params) + // For consistency with cert/CRL tree structure, we also create tbsSignatureAlgorithm + // pointing to the same signature algorithm + root.Children["tbsSignatureAlgorithm"] = buildSignatureAlgorithm(resp.SignatureAlgorithm, params) + + // Responder ID + root.Children["responderID"] = buildResponderID(resp) + + // Issuer hash + root.Children["issuerHash"] = node.New("issuerHash", hashString(resp.IssuerHash)) + + // Extensions + if len(resp.Extensions) > 0 { + root.Children["extensions"] = buildExtensions(resp.Extensions) + } + + return root +} + +func buildExtensions(extensions []pkix.Extension) *node.Node { + n := node.New("extensions", nil) + + for _, ext := range extensions { + extNode := node.New(ext.Id.String(), nil) + extNode.Children["oid"] = node.New("oid", ext.Id.String()) + extNode.Children["critical"] = node.New("critical", ext.Critical) + extNode.Children["value"] = node.New("value", ext.Value) + n.Children[ext.Id.String()] = extNode + } + + return n +} + +func buildSignatureAlgorithm(algo x509.SignatureAlgorithm, params asn1.ParamsState) *node.Node { + n := node.New("signatureAlgorithm", nil) + n.Children["algorithm"] = node.New("algorithm", algo.String()) + n.Children["oid"] = node.New("oid", params.OID) + n.Children["parameters"] = buildAlgorithmIDParams(params) + return n +} + +func buildAlgorithmIDParams(params asn1.ParamsState) *node.Node { + n := node.New("parameters", nil) + n.Children["null"] = node.New("null", params.IsNull) + n.Children["absent"] = node.New("absent", params.IsAbsent) + + if params.PSS != nil { + n.Children["pss"] = buildPSSParams(params.PSS) + } + + return n +} + +func buildPSSParams(pss *asn1.PSSParams) *node.Node { + n := node.New("pss", nil) + + n.Children["hashAlgorithm"] = buildNestedAlgorithmID(pss.HashAlgorithm) + n.Children["hashAlgorithmSet"] = node.New("hashAlgorithmSet", pss.HashAlgorithmSet) + n.Children["maskGenAlgorithm"] = buildNestedAlgorithmID(pss.MaskGenAlgorithm) + n.Children["maskGenAlgorithmSet"] = node.New("maskGenAlgorithmSet", pss.MaskGenAlgorithmSet) + n.Children["saltLength"] = node.New("saltLength", pss.SaltLength) + n.Children["saltLengthSet"] = node.New("saltLengthSet", pss.SaltLengthSet) + n.Children["trailerField"] = node.New("trailerField", pss.TrailerField) + n.Children["trailerFieldSet"] = node.New("trailerFieldSet", pss.TrailerFieldSet) + + return n +} + +func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { + n := node.New("algorithm", nil) + n.Children["oid"] = node.New("oid", algo.OID) + if algo.Params.OID != "" { + n.Children["parameters"] = buildAlgorithmIDParams(algo.Params) + } + return n +} + +func buildResponderID(resp *ocsp.Response) *node.Node { + n := node.New("responderID", nil) + + if len(resp.RawResponderName) > 0 { + n.Children["byName"] = node.New("byName", true) + n.Children["rawName"] = node.New("rawName", resp.RawResponderName) + } + + if len(resp.ResponderKeyHash) > 0 { + n.Children["byKey"] = node.New("byKey", true) + n.Children["keyHash"] = node.New("keyHash", fmt.Sprintf("%x", resp.ResponderKeyHash)) + } + + return n +} + +func statusString(status int) string { + switch status { + case ocsp.Good: + return "Good" + case ocsp.Revoked: + return "Revoked" + case ocsp.Unknown: + return "Unknown" + default: + return fmt.Sprintf("Unknown(%d)", status) + } +} + +func hashString(h crypto.Hash) string { + switch h { + case crypto.SHA1: + return "SHA1" + case crypto.SHA256: + return "SHA256" + case crypto.SHA384: + return "SHA384" + case crypto.SHA512: + return "SHA512" + default: + return fmt.Sprintf("Unknown(%d)", h) + } +} \ No newline at end of file diff --git a/internal/ocsp/zcrypto/builder_test.go b/internal/ocsp/zcrypto/builder_test.go new file mode 100644 index 0000000..6e46c33 --- /dev/null +++ b/internal/ocsp/zcrypto/builder_test.go @@ -0,0 +1,271 @@ +package zcrypto + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "golang.org/x/crypto/ocsp" +) + +func TestBuildTree_OCSP_RSA_SHA256(t *testing.T) { + // Generate test issuer and responder certificates + issuerKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate issuer key: %v", err) + } + + responderKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate responder key: %v", err) + } + + // Create issuer certificate + issuerTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test Issuer"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + issuerCert, err := x509.CreateCertificate(rand.Reader, issuerTemplate, issuerTemplate, &issuerKey.PublicKey, issuerKey) + if err != nil { + t.Fatalf("Failed to create issuer certificate: %v", err) + } + + // Create responder certificate + responderTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test OCSP Responder"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, + } + issuerCertParsed, err := x509.ParseCertificate(issuerCert) + if err != nil { + t.Fatalf("Failed to parse issuer certificate: %v", err) + } + responderCert, err := x509.CreateCertificate(rand.Reader, responderTemplate, issuerCertParsed, &responderKey.PublicKey, issuerKey) + if err != nil { + t.Fatalf("Failed to create responder certificate: %v", err) + } + responderCertParsed, err := x509.ParseCertificate(responderCert) + if err != nil { + t.Fatalf("Failed to parse responder certificate: %v", err) + } + + // Create OCSP response template + template := ocsp.Response{ + Status: ocsp.Good, + SerialNumber: big.NewInt(100), + ThisUpdate: time.Now().Truncate(time.Minute), + NextUpdate: time.Now().Add(1 * time.Hour).Truncate(time.Minute), + IssuerHash: crypto.SHA256, + } + + // Create OCSP response with SHA256WithRSA (OID 1.2.840.113549.1.1.11) + ocspRespBytes, err := ocsp.CreateResponse(issuerCertParsed, responderCertParsed, template, responderKey) + if err != nil { + t.Fatalf("Failed to create OCSP response: %v", err) + } + + // Parse OCSP response without signature verification (we just want to test parsing) + ocspResp, err := ocsp.ParseResponse(ocspRespBytes, nil) + if err != nil { + t.Fatalf("Failed to parse OCSP response: %v", err) + } + + // Build tree + tree := BuildTree(ocspResp) + if tree == nil { + t.Fatal("BuildTree returned nil") + } + + // Verify status + statusNode, ok := tree.Resolve("ocsp.status") + if !ok { + t.Error("ocsp.status not found") + } else { + status, ok := statusNode.Value.(string) + if !ok { + t.Error("Status value is not a string") + } else if status != "Good" { + t.Errorf("Expected status 'Good', got %s", status) + } + } + + // Verify serial number + serialNode, ok := tree.Resolve("ocsp.serialNumber") + if !ok { + t.Error("ocsp.serialNumber not found") + } else { + serial, ok := serialNode.Value.(string) + if !ok { + t.Error("SerialNumber value is not a string") + } else if serial != "100" { + t.Errorf("Expected serial '100', got %s", serial) + } + } + + // Verify signature algorithm OID + oidNode, ok := tree.Resolve("ocsp.signatureAlgorithm.oid") + if !ok { + t.Error("ocsp.signatureAlgorithm.oid not found") + } else { + oid, ok := oidNode.Value.(string) + if !ok { + t.Error("OID value is not a string") + } else if oid != "1.2.840.113549.1.1.11" { + t.Logf("SignatureAlgorithm: %s", ocspResp.SignatureAlgorithm.String()) + t.Errorf("Expected OID 1.2.840.113549.1.1.11 (SHA256-RSA), got %s", oid) + } + } + + // Verify TBS signature algorithm OID (should be same as outer) + tbsOidNode, ok := tree.Resolve("ocsp.tbsSignatureAlgorithm.oid") + if !ok { + t.Error("ocsp.tbsSignatureAlgorithm.oid not found") + } else { + oid, ok := tbsOidNode.Value.(string) + if !ok { + t.Error("TBS OID value is not a string") + } else if oid != "1.2.840.113549.1.1.11" { + t.Errorf("Expected TBS OID 1.2.840.113549.1.1.11, got %s", oid) + } + } + + // Verify NULL parameters for RSA + nullNode, ok := tree.Resolve("ocsp.signatureAlgorithm.parameters.null") + if !ok { + t.Error("ocsp.signatureAlgorithm.parameters.null not found") + } else { + isNull, ok := nullNode.Value.(bool) + if !ok { + t.Error("Null parameter value is not a bool") + } else if !isNull { + t.Error("Expected NULL parameters for RSA signature algorithm") + } + } + + // Verify issuer hash + issuerHashNode, ok := tree.Resolve("ocsp.issuerHash") + if !ok { + t.Error("ocsp.issuerHash not found") + } else { + hash, ok := issuerHashNode.Value.(string) + if !ok { + t.Error("IssuerHash value is not a string") + } else if hash != "SHA256" { + t.Errorf("Expected issuerHash 'SHA256', got %s", hash) + } + } +} + +func TestBuildTree_OCSP_Revoked(t *testing.T) { + // Generate test issuer and responder certificates + issuerKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate issuer key: %v", err) + } + + responderKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate responder key: %v", err) + } + + // Create issuer certificate + issuerTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test Issuer"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + issuerCert, err := x509.CreateCertificate(rand.Reader, issuerTemplate, issuerTemplate, &issuerKey.PublicKey, issuerKey) + if err != nil { + t.Fatalf("Failed to create issuer certificate: %v", err) + } + issuerCertParsed, err := x509.ParseCertificate(issuerCert) + if err != nil { + t.Fatalf("Failed to parse issuer certificate: %v", err) + } + + // Create responder certificate + responderTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test OCSP Responder"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, + } + responderCert, err := x509.CreateCertificate(rand.Reader, responderTemplate, issuerCertParsed, &responderKey.PublicKey, issuerKey) + if err != nil { + t.Fatalf("Failed to create responder certificate: %v", err) + } + responderCertParsed, err := x509.ParseCertificate(responderCert) + if err != nil { + t.Fatalf("Failed to parse responder certificate: %v", err) + } + + // Create OCSP response with Revoked status + template := ocsp.Response{ + Status: ocsp.Revoked, + SerialNumber: big.NewInt(100), + ThisUpdate: time.Now().Truncate(time.Minute), + NextUpdate: time.Now().Add(1 * time.Hour).Truncate(time.Minute), + RevokedAt: time.Now().Add(-30 * time.Minute).Truncate(time.Minute), + RevocationReason: ocsp.Superseded, + IssuerHash: crypto.SHA256, + } + + ocspRespBytes, err := ocsp.CreateResponse(issuerCertParsed, responderCertParsed, template, responderKey) + if err != nil { + t.Fatalf("Failed to create OCSP response: %v", err) + } + + ocspResp, err := ocsp.ParseResponse(ocspRespBytes, nil) + if err != nil { + t.Fatalf("Failed to parse OCSP response: %v", err) + } + + tree := BuildTree(ocspResp) + if tree == nil { + t.Fatal("BuildTree returned nil") + } + + // Verify status is Revoked + statusNode, ok := tree.Resolve("ocsp.status") + if !ok { + t.Error("ocsp.status not found") + } else { + status, ok := statusNode.Value.(string) + if !ok { + t.Error("Status value is not a string") + } else if status != "Revoked" { + t.Errorf("Expected status 'Revoked', got %s", status) + } + } + + // Verify revokedAt exists + _, ok = tree.Resolve("ocsp.revokedAt") + if !ok { + t.Error("ocsp.revokedAt not found for revoked certificate") + } + + // Verify revocationReason exists + _, ok = tree.Resolve("ocsp.revocationReason") + if !ok { + t.Error("ocsp.revocationReason not found for revoked certificate") + } +} \ No newline at end of file diff --git a/internal/ocsp/zcrypto/parser.go b/internal/ocsp/zcrypto/parser.go new file mode 100644 index 0000000..e4882b3 --- /dev/null +++ b/internal/ocsp/zcrypto/parser.go @@ -0,0 +1,73 @@ +package zcrypto + +import ( + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/cavoq/PCL/internal/asn1" +) + +// ParseOCSPSignatureAlgorithmParams parses the signatureAlgorithm +// from an OCSP response and returns the parameters state. +// OCSP structure: OCSPResponse -> responseBytes -> BasicOCSPResponse -> signatureAlgorithm +func ParseOCSPSignatureAlgorithmParams(rawOCSP []byte) asn1.ParamsState { + input := cryptobyte.String(rawOCSP) + + // Read OCSPResponse SEQUENCE + var ocspResp cryptobyte.String + if !input.ReadASN1(&ocspResp, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Read ResponseStatus (ENUMERATED - Tag(10)) + var status cryptobyte.String + if !ocspResp.ReadASN1(&status, cryptobyte_asn1.ENUM) { + return asn1.ParamsState{} + } + + // Read ResponseBytes (context-specific [0] EXPLICIT) + // The [0] tag is context-specific and constructed (EXPLICIT means wrapped in another SEQUENCE) + var responseBytesOuter cryptobyte.String + if !ocspResp.ReadASN1(&responseBytesOuter, cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) { + return asn1.ParamsState{} + } + + // Inside the [0] wrapper, we have ResponseBytes SEQUENCE + var responseBytes cryptobyte.String + if !responseBytesOuter.ReadASN1(&responseBytes, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Skip responseType OID + var responseType cryptobyte.String + if !responseBytes.ReadASN1(&responseType, cryptobyte_asn1.OBJECT_IDENTIFIER) { + return asn1.ParamsState{} + } + + // Read response OCTET STRING (contains BasicOCSPResponse) + var responseOctet cryptobyte.String + if !responseBytes.ReadASN1(&responseOctet, cryptobyte_asn1.OCTET_STRING) { + return asn1.ParamsState{} + } + + // Parse BasicOCSPResponse from OCTET STRING + var basicResp cryptobyte.String + if !responseOctet.ReadASN1(&basicResp, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Skip TBSResponseData SEQUENCE + var tbsResp cryptobyte.String + if !basicResp.ReadASN1(&tbsResp, cryptobyte_asn1.SEQUENCE) { + return asn1.ParamsState{} + } + + // Read signatureAlgorithm (AlgorithmIdentifier after TBSResponseData) + var sigAlgo cryptobyte.String + var tag cryptobyte_asn1.Tag + if !basicResp.ReadAnyASN1Element(&sigAlgo, &tag) { + return asn1.ParamsState{} + } + + return asn1.ParseAlgorithmIDParams(sigAlgo) +} \ No newline at end of file diff --git a/internal/policy/engine.go b/internal/policy/engine.go index a9d183c..8605a3e 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -9,10 +9,15 @@ import ( ) type Policy struct { - ID string `yaml:"id"` - Version string `yaml:"version"` - Includes []string `yaml:"includes,omitempty"` - Rules []rule.Rule `yaml:"rules"` + ID string `yaml:"id"` + Version string `yaml:"version"` + Includes []string `yaml:"includes,omitempty"` + AppliesTo []string `yaml:"appliesTo,omitempty"` + CertType []string `yaml:"certType,omitempty"` + CRLType []string `yaml:"crlType,omitempty"` + TSTType []string `yaml:"tstType,omitempty"` + SCTType []string `yaml:"sctType,omitempty"` + Rules []rule.Rule `yaml:"rules"` } type Result struct { diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index 14c3f4c..a8523ce 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -58,7 +58,27 @@ func Evaluate( } } - n, _ := root.Resolve(r.Target) + n, found := root.Resolve(r.Target) + + // For presence/absence operators, continue evaluation even if target not found + // present: returns false when target not found (expected behavior) + // absent: returns true when target not found (expected behavior) + // For other operators, skip when target not found (e.g., certificate rules when processing CRLs) + if !found && r.Operator != "present" && r.Operator != "absent" { + return Result{ + RuleID: r.ID, + Reference: r.Reference, + Verdict: VerdictSkip, + Severity: r.Severity, + Message: "target not found: " + r.Target, + } + } + + // Pass nil node if target not found (for present/absent operators) + var targetNode *node.Node + if found { + targetNode = n + } op, err := reg.Get(r.Operator) if err != nil { @@ -71,7 +91,7 @@ func Evaluate( } } - ok, err := op.Evaluate(n, ctx, r.Operands) + ok, err := op.Evaluate(targetNode, ctx, r.Operands) if err != nil { return Result{ RuleID: r.ID, @@ -119,4 +139,4 @@ func appliesTo(r Rule, ctx *operator.EvaluationContext) bool { return true } return slices.Contains(r.AppliesTo, ctx.Cert.Type) -} +} \ No newline at end of file From 41666b484e4d58e9229d6b14370cc161f061272c Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 00:20:03 +0800 Subject: [PATCH 03/44] feat: implement auto-validate mode with PKCS#7 support - Add --auto-validate flag for automatic chain climbing via CA Issuers URLs - Implement PKCS#7 (p7c) certificate bundle parsing per RFC 5280/5652 - Add INFO severity level for best practice recommendations - Support multi-certificate PKCS#7 bundles with issuer matching - Auto-fetch OCSP responses and CRLs from certificate extensions - Add certificate policies node tree for EV policy detection - Fix implicit boolean handling in eq/neq operators - Add FromStdCert helper for certificate format conversion - Fix gitignore to only match root-level pcl binary --- .gitignore | 2 +- cmd/pcl/main.go | 7 + internal/aia/fetcher.go | 266 +++++++++++++++++++++++++++++++ internal/cert/cert.go | 6 +- internal/cert/chain.go | 6 +- internal/cert/zcrypto/builder.go | 33 ++++ internal/crl/fetcher.go | 89 +++++++++++ internal/linter/config.go | 7 + internal/linter/filter.go | 57 ++++++- internal/linter/runner.go | 207 +++++++++++++++++++++++- internal/operator/equality.go | 28 +++- internal/output/text.go | 30 +++- internal/rule/evaluator.go | 58 +++++++ internal/zcrypto/helpers.go | 7 + tests/integration_test.go | 30 +++- 15 files changed, 812 insertions(+), 21 deletions(-) create mode 100644 internal/aia/fetcher.go create mode 100644 internal/crl/fetcher.go diff --git a/.gitignore b/.gitignore index 2084b61..bb29201 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -pcl +/pcl *.exe *.exe~ *.dll diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index a2abd18..6919e19 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -45,6 +45,13 @@ func newRootCmd(opts *linter.Config) *cobra.Command { root.Flags().CountVarP(&opts.Verbosity, "verbose", "v", "Increase output detail: -v shows passed, -vv includes skipped") root.Flags().BoolVar(&opts.ShowMeta, "show-meta", true, "Show lint meta information") + // Auto-validate mode flags + root.Flags().BoolVar(&opts.AutoValidate, "auto-validate", false, "Enable automatic PKI resource fetching (OCSP, CRL, chain climbing)") + root.Flags().BoolVar(&opts.AutoValidateOCSP, "auto-ocsp-chain", true, "Fetch OCSP for all certificates in chain (only with --auto-validate)") + root.Flags().BoolVar(&opts.AutoValidateCRL, "auto-crl", true, "Fetch CRLs from CRL Distribution Points (only with --auto-validate)") + root.Flags().BoolVar(&opts.AutoValidateChain, "auto-chain", true, "Climb chain via CA Issuers URLs (only with --auto-validate)") + root.Flags().IntVar(&opts.MaxChainDepth, "max-chain-depth", 10, "Maximum chain depth for climbing (only with --auto-validate)") + return root } diff --git a/internal/aia/fetcher.go b/internal/aia/fetcher.go new file mode 100644 index 0000000..0d3ed38 --- /dev/null +++ b/internal/aia/fetcher.go @@ -0,0 +1,266 @@ +package aia + +import ( + certstd "crypto/x509" + "encoding/asn1" + "encoding/pem" + "fmt" + "io" + "net/http" + "time" + + "github.com/zmap/zcrypto/x509" + + zcryptoconv "github.com/cavoq/PCL/internal/zcrypto" +) + +// Format represents the certificate format fetched from CA Issuers URL. +type Format string + +const ( + FormatDER Format = "DER" // RFC 5280: single DER-encoded certificate + FormatPKCS7 Format = "PKCS7" // RFC 5280: BER/DER-encoded PKCS#7 certs-only + FormatPEM Format = "PEM" // Fallback format (not RFC compliant) +) + +// PKCS#7 OIDs +var oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} // id-signedData (1.2.840.113549.1.7.2) + +// Result contains the fetched certificate and format information. +type Result struct { + Cert *x509.Certificate + Format Format + URL string +} + +// PKCS7Result contains multiple certificates from a PKCS#7 bundle. +type PKCS7Result struct { + Certs []*x509.Certificate + Format Format + URL string +} + +// FetchCAIssuer downloads and parses a certificate from a CA Issuers URL. +// Per RFC 5280 Section 4.2.2.1, the CA Issuers URL must point to: +// - Single DER-encoded certificate, OR +// - BER/DER-encoded PKCS#7 certs-only bundle +// Returns zcrypto certificate for consistency with the rest of the codebase. +func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { + if url == "" { + return nil, fmt.Errorf("CA Issuers URL is required") + } + + client := &http.Client{ + Timeout: timeout, + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch CA Issuers from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("CA Issuers server returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read CA Issuers response: %w", err) + } + + // Try DER format first (single DER-encoded certificate per RFC 5280) + cert, err := x509.ParseCertificate(body) + if err == nil { + return &Result{Cert: cert, Format: FormatDER, URL: url}, nil + } + + // Try PKCS#7 SignedData format (certs-only bundle per RFC 5280) + pkcs7Certs, err := parsePKCS7CertsOnly(body) + if err == nil && len(pkcs7Certs) > 0 { + // Return the first certificate (typically the issuer certificate) + // Caller can use FetchCAIssuerPKCS7 to get all certificates if needed + return &Result{Cert: pkcs7Certs[0], Format: FormatPKCS7, URL: url}, nil + } + + // Try PEM format as fallback (non-compliant but commonly used) + block, _ := pem.Decode(body) + if block != nil && block.Type == "CERTIFICATE" { + cert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) + } + return &Result{Cert: cert, Format: FormatPEM, URL: url}, nil + } + + return nil, fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") +} + +// FetchCAIssuerPKCS7 downloads and parses certificates from a CA Issuers URL, +// returning all certificates if the response is a PKCS#7 bundle. +// Useful when the PKCS#7 bundle contains multiple certificates (e.g., chain). +func FetchCAIssuerPKCS7(url string, timeout time.Duration) (*PKCS7Result, error) { + if url == "" { + return nil, fmt.Errorf("CA Issuers URL is required") + } + + client := &http.Client{ + Timeout: timeout, + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch CA Issuers from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("CA Issuers server returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read CA Issuers response: %w", err) + } + + // Try DER format first (single certificate) + cert, err := x509.ParseCertificate(body) + if err == nil { + return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: FormatDER, URL: url}, nil + } + + // Try PKCS#7 format (certificate bundle) + pkcs7Certs, err := parsePKCS7CertsOnly(body) + if err == nil && len(pkcs7Certs) > 0 { + return &PKCS7Result{Certs: pkcs7Certs, Format: FormatPKCS7, URL: url}, nil + } + + // Try PEM format + block, _ := pem.Decode(body) + if block != nil && block.Type == "CERTIFICATE" { + cert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) + } + return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: FormatPEM, URL: url}, nil + } + + return nil, fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") +} + +// FetchCAIssuers downloads certificates from multiple CA Issuer URLs. +// Returns results and any errors encountered. +// Network failures are returned as errors, not as policy failures. +func FetchCAIssuers(urls []string, timeout time.Duration) ([]*Result, []error) { + var results []*Result + var errs []error + + for _, url := range urls { + result, err := FetchCAIssuer(url, timeout) + if err != nil { + errs = append(errs, err) + continue + } + results = append(results, result) + } + + return results, errs +} + +// ToStdCert converts zcrypto certificate to standard Go certificate. +func ToStdCert(cert *x509.Certificate) (*certstd.Certificate, error) { + return zcryptoconv.ToStdCert(cert) +} + +// parsePKCS7CertsOnly parses a PKCS#7 SignedData structure and extracts certificates. +// PKCS#7 SignedData (certs-only) structure per RFC 5652: +// ContentInfo ::= SEQUENCE { +// contentType ContentType, -- OID: 1.2.840.113549.1.7.2 for signedData +// content [0] EXPLICIT ANY DEFINED BY contentType +// } +// SignedData ::= SEQUENCE { +// version INTEGER, +// digestAlgorithms SET, +// encapContentInfo SEQUENCE, +// certificates [0] IMPLICIT SET OF Certificate OPTIONAL, +// signerInfos SET +// } +func parsePKCS7CertsOnly(data []byte) ([]*x509.Certificate, error) { + // Parse ContentInfo + var contentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue `asn1:"explicit,tag:0"` + } + if _, err := asn1.Unmarshal(data, &contentInfo); err != nil { + return nil, fmt.Errorf("failed to parse PKCS#7 ContentInfo: %w", err) + } + + // Verify it's signedData (1.2.840.113549.1.7.2) + if !contentInfo.ContentType.Equal(oidSignedData) { + return nil, fmt.Errorf("PKCS#7 contentType is not signedData: %v", contentInfo.ContentType) + } + + // Parse SignedData from Content.Bytes (strips explicit tag wrapper) + var signedData struct { + Version int + DigestAlgorithms asn1.RawValue + EncapContentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue `asn1:"optional,explicit,tag:0"` + } + Certificates asn1.RawValue `asn1:"optional,implicit,tag:0"` + CRLs asn1.RawValue `asn1:"optional,implicit,tag:1"` + SignerInfos asn1.RawValue + } + if _, err := asn1.Unmarshal(contentInfo.Content.Bytes, &signedData); err != nil { + return nil, fmt.Errorf("failed to parse PKCS#7 SignedData: %w", err) + } + + // Extract certificates from the [0] IMPLICIT SET OF Certificate + // Due to implicit tagging, the tag is replaced from SET (17) to context-specific [0] + // FullBytes contains the complete tagged content, Bytes contains inner SET data + if len(signedData.Certificates.Bytes) == 0 { + return nil, fmt.Errorf("PKCS#7 SignedData contains no certificates") + } + + certs, err := parseCertificateSet(signedData.Certificates.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS#7 certificates: %w", err) + } + + return certs, nil +} + +// parseCertificateSet parses a SET OF Certificate bytes and returns zcrypto certificates. +func parseCertificateSet(data []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + + // Parse the SET content directly + remaining := data + for len(remaining) > 0 { + // Each element in the SET is a SEQUENCE (Certificate) + var certRaw asn1.RawValue + n, err := asn1.Unmarshal(remaining, &certRaw) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate element: %w", err) + } + + // Parse the certificate DER bytes + cert, err := x509.ParseCertificate(certRaw.FullBytes) + if err != nil { + // Try standard library parser as fallback + stdCert, stdErr := certstd.ParseCertificate(certRaw.FullBytes) + if stdErr != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + // Convert std cert to zcrypto cert + cert, err = zcryptoconv.FromStdCert(stdCert) + if err != nil { + return nil, fmt.Errorf("failed to convert certificate: %w", err) + } + } + + certs = append(certs, cert) + remaining = n + } + + return certs, nil +} \ No newline at end of file diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 3acd072..c425917 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -36,15 +36,15 @@ func GetCertFiles(path string) ([]string, error) { return io.GetFilesWithExtensions(path, extensions...) } -func isSelfSigned(cert *x509.Certificate) bool { +func IsSelfSigned(cert *x509.Certificate) bool { return cert.Subject.String() == cert.Issuer.String() } -func getCertType(cert *x509.Certificate, position, chainLen int) string { +func GetCertType(cert *x509.Certificate, position, chainLen int) string { if position == 0 { return "leaf" } - if position == chainLen-1 && isSelfSigned(cert) { + if position == chainLen-1 && IsSelfSigned(cert) { return "root" } return "intermediate" diff --git a/internal/cert/chain.go b/internal/cert/chain.go index 486015f..e08ed5d 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -38,7 +38,7 @@ func BuildChain(certs []*Info) ([]*Info, error) { if len(certs) == 1 { certs[0].Position = 0 - certs[0].Type = getCertType(certs[0].Cert, 0, 1) + certs[0].Type = GetCertType(certs[0].Cert, 0, 1) return certs, nil } @@ -54,7 +54,7 @@ func BuildChain(certs []*Info) ([]*Info, error) { current := leaf for { - if isSelfSigned(current.Cert) { + if IsSelfSigned(current.Cert) { break } @@ -82,7 +82,7 @@ func BuildChain(certs []*Info) ([]*Info, error) { for i, c := range longestChain { c.Position = i - c.Type = getCertType(c.Cert, i, len(longestChain)) + c.Type = GetCertType(c.Cert, i, len(longestChain)) } return longestChain, nil diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 236d43c..aa4c1f8 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -87,6 +87,11 @@ func buildCertificate(cert *x509.Certificate) *node.Node { root.Children["ocspURL"] = node.New("ocspURL", cert.OCSPServer[0]) } + // Add CA Issuers URL from AIA extension + if len(cert.IssuingCertificateURL) > 0 { + root.Children["caIssuersURL"] = node.New("caIssuersURL", cert.IssuingCertificateURL[0]) + } + // Add CRL Distribution Points if len(cert.CRLDistributionPoints) > 0 { crlDPNode := node.New("cRLDistributionPoints", nil) @@ -96,6 +101,26 @@ func buildCertificate(cert *x509.Certificate) *node.Node { root.Children["cRLDistributionPoints"] = crlDPNode } + // Add Signed Certificate Timestamps (SCT) from CT extension + if len(cert.SignedCertificateTimestampList) > 0 { + sctNode := node.New("signedCertificateTimestamps", nil) + for i, sct := range cert.SignedCertificateTimestampList { + sctNode.Children[fmt.Sprintf("%d", i)] = buildSCT(sct, i) + } + root.Children["signedCertificateTimestamps"] = sctNode + } + + // Add Certificate Policies + if len(cert.PolicyIdentifiers) > 0 { + policiesNode := node.New("certificatePolicies", nil) + for i, oid := range cert.PolicyIdentifiers { + policyNode := node.New(fmt.Sprintf("%d", i), nil) + policyNode.Children["oid"] = node.New("oid", oid.String()) + policiesNode.Children[oid.String()] = policyNode + } + root.Children["certificatePolicies"] = policiesNode + } + return root } @@ -332,3 +357,11 @@ func buildECDSAKey(key *ecdsa.PublicKey) *node.Node { n.Children["curve"] = node.New("curve", key.Curve.Params().Name) return n } + +func buildSCT(sct interface{}, index int) *node.Node { + n := node.New(fmt.Sprintf("%d", index), nil) + // SCT contains: LogID, Timestamp, Extensions, Signature + // We store basic presence info; detailed validation in operators + n.Children["present"] = node.New("present", true) + return n +} diff --git a/internal/crl/fetcher.go b/internal/crl/fetcher.go new file mode 100644 index 0000000..95801d9 --- /dev/null +++ b/internal/crl/fetcher.go @@ -0,0 +1,89 @@ +package crl + +import ( + "encoding/pem" + "fmt" + "io" + "net/http" + "time" + + "github.com/zmap/zcrypto/x509" +) + +// Format represents the CRL format fetched from CRL Distribution Points URL. +type Format string + +const ( + FormatDER Format = "DER" // RFC required format + FormatPEM Format = "PEM" // Fallback format +) + +// FetchResult contains the fetched CRL and format information. +type FetchResult struct { + CRL *x509.RevocationList + Format Format + URL string +} + +// FetchCRL downloads and parses a CRL from a CRL Distribution Points URL. +// Per RFC 5280, CRL Distribution Points must point to DER format CRLs. +func FetchCRL(url string, timeout time.Duration) (*FetchResult, error) { + if url == "" { + return nil, fmt.Errorf("CRL URL is required") + } + + client := &http.Client{ + Timeout: timeout, + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch CRL from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("CRL server returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read CRL response: %w", err) + } + + // Try DER format first (RFC requirement) + crl, err := x509.ParseRevocationList(body) + if err == nil { + return &FetchResult{CRL: crl, Format: FormatDER, URL: url}, nil + } + + // Try PEM format as fallback + block, _ := pem.Decode(body) + if block != nil && block.Type == "X509 CRL" { + crl, err = x509.ParseRevocationList(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PEM CRL: %w", err) + } + return &FetchResult{CRL: crl, Format: FormatPEM, URL: url}, nil + } + + return nil, fmt.Errorf("failed to parse CRL as DER/BER format") +} + +// FetchCRLs downloads CRLs from multiple CRL Distribution Points URLs. +// Returns results and any errors encountered. +// Network failures are returned as errors, not as policy failures. +func FetchCRLs(urls []string, timeout time.Duration) ([]*FetchResult, []error) { + var results []*FetchResult + var errs []error + + for _, url := range urls { + result, err := FetchCRL(url, timeout) + if err != nil { + errs = append(errs, err) + continue + } + results = append(results, result) + } + + return results, errs +} \ No newline at end of file diff --git a/internal/linter/config.go b/internal/linter/config.go index 8f0df56..dcea95f 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -18,4 +18,11 @@ type Config struct { OutputFmt string Verbosity int ShowMeta bool + + // Auto-validate mode options + AutoValidate bool // Enable automatic PKI resource fetching + AutoValidateOCSP bool // Fetch OCSP from AIA (default true when AutoValidate) + AutoValidateCRL bool // Fetch CRL from CRL DP (default true when AutoValidate) + AutoValidateChain bool // Climb chain via CA Issuers URLs (default true when AutoValidate) + MaxChainDepth int // Maximum chain depth for climbing (default 10) } diff --git a/internal/linter/filter.go b/internal/linter/filter.go index 60f6687..2334987 100644 --- a/internal/linter/filter.go +++ b/internal/linter/filter.go @@ -2,10 +2,12 @@ package linter import ( "slices" + "strings" "github.com/zmap/zcrypto/x509" "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/rule" ) // OID constants for CRL extensions @@ -65,11 +67,58 @@ func normalizeOID(nameOrOID string) string { // policyAppliesToInput checks if a policy applies to the given input type func policyAppliesToInput(p policy.Policy, inputType string) bool { - // If AppliesTo is empty, apply to all (backward compatible) - if len(p.AppliesTo) == 0 { - return true + // If AppliesTo is explicitly set, use it + if len(p.AppliesTo) > 0 { + return slices.Contains(p.AppliesTo, inputType) + } + + // Infer from rule targets if AppliesTo is not set + // If all targets start with "certificate.", it's a cert policy + // If all targets start with "crl.", it's a CRL policy + // If all targets start with "ocsp.", it's an OCSP policy + if len(p.Rules) > 0 { + inferredType := inferInputTypeFromRules(p.Rules) + return inferredType == inputType || inferredType == "" + } + + // Default: apply to all (backward compatible for empty policies) + return true +} + +// inferInputTypeFromRules determines the input type from rule targets +func inferInputTypeFromRules(rules []rule.Rule) string { + if len(rules) == 0 { + return "" + } + + // Check first rule target + target := rules[0].Target + if strings.HasPrefix(target, "certificate.") { + return AppliesToCert + } + if strings.HasPrefix(target, "crl.") { + return AppliesToCRL + } + if strings.HasPrefix(target, "ocsp.") { + return AppliesToOCSP } - return slices.Contains(p.AppliesTo, inputType) + + // Check "when" condition target if main target doesn't indicate type + if rules[0].When != nil && rules[0].When.Target != "" { + whenTarget := rules[0].When.Target + if strings.HasPrefix(whenTarget, "certificate.") { + return AppliesToCert + } + if strings.HasPrefix(whenTarget, "crl.") { + return AppliesToCRL + } + if strings.HasPrefix(whenTarget, "ocsp.") { + return AppliesToOCSP + } + } + + // Unknown target format, apply to all + return "" } // policyAppliesToCert checks if a policy applies to a specific certificate diff --git a/internal/linter/runner.go b/internal/linter/runner.go index cf87ac9..4fa29c1 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -6,16 +6,19 @@ import ( "io" "time" + "github.com/cavoq/PCL/internal/aia" "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" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/output" "github.com/cavoq/PCL/internal/policy" "github.com/cavoq/PCL/internal/zcrypto" + "github.com/zmap/zcrypto/x509" ) func Run(cfg Config, w io.Writer) error { @@ -86,12 +89,56 @@ func Run(cfg Config, w io.Writer) error { return fmt.Errorf("no certificates provided") } + // Auto-validate: climb chain via CA Issuers URLs BEFORE BuildChain + // This fetches missing intermediates to complete the chain + if cfg.AutoValidate && cfg.AutoValidateChain { + // Start from leaf certificates and climb toward root + // We need to climb from each leaf to find intermediates + var climbedCerts []*cert.Info + for _, c := range certs { + if c.Cert == nil { + continue + } + // Start mini-chain with just this cert + miniChain := []*cert.Info{c} + // Climb from this cert via CA Issuers URLs + miniChain = climbChain(miniChain, cfg.CertTimeout, cfg.MaxChainDepth, w) + climbedCerts = append(climbedCerts, miniChain...) + } + // Merge climbed certs with provided issuers + allCerts = append(climbedCerts, issuers...) + } + chain, err := cert.BuildChain(allCerts) if err != nil { return fmt.Errorf("failed to build chain: %w", err) } - // Auto-fetch OCSP if enabled and chain has issuer + // Auto-validate: fetch CRLs from CRL Distribution Points + if cfg.AutoValidate && cfg.AutoValidateCRL { + autoCRLs := fetchAutoCRL(chain, cfg.OCSPTimeout, w) + crls = append(crls, autoCRLs...) + } + + // Auto-validate: fetch OCSP for all certificates in chain + if cfg.AutoValidate && cfg.AutoValidateOCSP { + for i := 0; i < len(chain)-1; i++ { + c := chain[i] + if c.Cert == nil || len(c.Cert.OCSPServer) == 0 { + continue + } + // Build mini chain for OCSP request: [cert, issuer] + miniChain := []*cert.Info{c, chain[i+1]} + autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout) + if err != nil { + fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) + continue + } + ocsps = append(ocsps, autoOCSPs...) + } + } + + // Auto-fetch OCSP if enabled and chain has issuer (legacy mode) if cfg.AutoOCSP && len(chain) >= 2 { autoOCSPs, err := fetchAutoOCSP(chain, cfg.OCSPTimeout) if err != nil { @@ -302,6 +349,15 @@ func applyDefaults(cfg *Config) { if cfg.OutputFmt == "" { cfg.OutputFmt = "text" } + + // Auto-validate defaults + if cfg.AutoValidate { + if cfg.MaxChainDepth <= 0 { + cfg.MaxChainDepth = 10 + } + // Default all auto-fetch options to true when AutoValidate is enabled + // unless explicitly set to false + } } // fetchAutoOCSP automatically fetches OCSP responses for certificates in the chain. @@ -346,4 +402,153 @@ func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration) ([]*ocsp.Info, err }) return results, nil +} + +// climbChain recursively fetches issuer certificates via CA Issuers URLs. +// Starts from the top of the chain and climbs toward root until: +// - Self-signed certificate found (root) +// - Max depth reached +// - No CA Issuers URL found +// - Circular certificate detected +// +// Handles PKCS#7 bundles: extracts all certificates and selects the correct issuer +// by matching Issuer DN and/or AKI extension. +func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Writer) []*cert.Info { + if len(chain) == 0 || maxDepth <= 0 { + return chain + } + + // Track seen certificates to detect circular chains + 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 + + for depth < maxDepth { + // Get the highest certificate in the chain (potential issuer to climb) + top := result[len(result)-1] + if top.Cert == nil { + break + } + + // Check if it's self-signed (root) + if top.Cert.Issuer.String() == top.Cert.Subject.String() { + break + } + + // Check for CA Issuers URL + if len(top.Cert.IssuingCertificateURL) == 0 { + break + } + + // Fetch issuer(s) from first CA Issuers URL (may be PKCS#7 bundle) + url := top.Cert.IssuingCertificateURL[0] + pkcs7Result, err := aia.FetchCAIssuerPKCS7(url, timeout) + if err != nil { + fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) + break + } + + // Find the correct issuer certificate from the bundle + // Match by Issuer DN (subject of issuer should match issuer of cert) + var issuerCert *x509.Certificate + for _, cert := range pkcs7Result.Certs { + // Check if this cert's subject matches the current cert's issuer + if cert.Subject.String() == top.Cert.Issuer.String() { + issuerCert = cert + break + } + } + + // If no exact DN match, try AKI-SKI matching + if issuerCert == nil && len(top.Cert.AuthorityKeyId) > 0 { + for _, cert := range pkcs7Result.Certs { + if len(cert.SubjectKeyId) > 0 && string(cert.SubjectKeyId) == string(top.Cert.AuthorityKeyId) { + issuerCert = cert + break + } + } + } + + // Fallback: use first certificate if only one, or continue with best guess + if issuerCert == nil { + if len(pkcs7Result.Certs) == 1 { + issuerCert = pkcs7Result.Certs[0] + } else { + // Multiple certs with no match - use first as best guess + issuerCert = pkcs7Result.Certs[0] + fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(pkcs7Result.Certs)) + } + } + + // Check for circular certificate + if issuerCert.SerialNumber != nil { + serial := issuerCert.SerialNumber.String() + if seen[serial] { + fmt.Fprintf(w, "Warning: circular certificate detected at %s\n", url) + break + } + seen[serial] = true + } + + // Add issuer to chain + issuerInfo := &cert.Info{ + Cert: issuerCert, + FilePath: url, + Type: cert.GetCertType(issuerCert, len(result), len(result)+1), + Position: len(result), + } + result = append(result, issuerInfo) + + depth++ + } + + // Rebuild chain types after climbing is complete + for i, c := range result { + c.Position = i + c.Type = cert.GetCertType(c.Cert, i, len(result)) + } + + return result +} + +// fetchAutoCRL fetches CRLs from CRL Distribution Points for certificates in chain. +func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl.Info { + var results []*crl.Info + + for _, c := range chain { + if c.Cert == nil || len(c.Cert.CRLDistributionPoints) == 0 { + continue + } + + for _, url := range c.Cert.CRLDistributionPoints { + fetchResult, err := crl.FetchCRL(url, timeout) + if err != nil { + fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) + continue + } + + results = append(results, &crl.Info{ + CRL: fetchResult.CRL, + FilePath: url, + }) + } + } + + return results +} + +// addFetchedInfoToNode adds format and fetch status info to node tree for policy evaluation. +func addFetchedInfoToNode(tree *node.Node, caIssuerFormat aia.Format, crlFormat crl.Format) { + if caIssuerFormat != "" { + tree.Children["caIssuersFormat"] = node.New("caIssuersFormat", string(caIssuerFormat)) + } + if crlFormat != "" { + tree.Children["crlFormat"] = node.New("crlFormat", string(crlFormat)) + } } \ No newline at end of file diff --git a/internal/operator/equality.go b/internal/operator/equality.go index c19156e..316395f 100644 --- a/internal/operator/equality.go +++ b/internal/operator/equality.go @@ -12,9 +12,21 @@ type Eq struct{} func (Eq) Name() string { return "eq" } func (Eq) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { - if n == nil || len(operands) != 1 { + if len(operands) != 1 { return false, nil } + + // Handle nil node for implicit false boolean comparison + if n == nil { + // For keyUsage boolean fields, nil means implicit false + // eq false on nil → true (PASS) + // eq true on nil → false (FAIL) + if b, ok := operands[0].(bool); ok { + return !b, nil // nil == false, so eq false = true, eq true = false + } + return false, nil + } + return reflect.DeepEqual(n.Value, operands[0]), nil } @@ -23,9 +35,21 @@ type Neq struct{} func (Neq) Name() string { return "neq" } func (Neq) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { - if n == nil || len(operands) != 1 { + if len(operands) != 1 { return false, nil } + + // Handle nil node for implicit false boolean comparison + if n == nil { + // For keyUsage boolean fields, nil means implicit false + // neq false on nil → false (nil == false, so not equal is false) + // neq true on nil → true (PASS, because nil != true) + if b, ok := operands[0].(bool); ok { + return b, nil // nil == false, so neq false = false, neq true = true + } + return false, nil + } + return !reflect.DeepEqual(n.Value, operands[0]), nil } diff --git a/internal/output/text.go b/internal/output/text.go index 6ab1ba4..4ad77d8 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -114,6 +114,28 @@ func verdictLabelColoredPadded(verdict string, width int) string { } } +func verdictLabelColoredPaddedWithSeverity(verdict string, severity string, width int) string { + label := verdictLabel(verdict) + padded := fmt.Sprintf("%-*s", width, label) + switch verdict { + case rule.VerdictPass: + return colorize(padded, ansiGreen) + case rule.VerdictFail: + // INFO level failures use blue (informational), not red (error) + if severity == "info" { + return colorize(padded, ansiBlue) + } + if severity == "warning" { + return colorize(padded, ansiYellow) + } + return colorize(padded, ansiRed) + case rule.VerdictSkip: + return colorize(padded, ansiCyan) + default: + return padded + } +} + func colorize(s string, color string) string { return color + s + ansiReset } @@ -123,6 +145,7 @@ const ( ansiRed = "\033[31m" ansiGreen = "\033[32m" ansiYellow = "\033[33m" + ansiBlue = "\033[34m" ansiCyan = "\033[36m" ) @@ -144,7 +167,7 @@ func writeRulesTable(w io.Writer, results []rule.Result, passCount, failCount, s if rr.Reference != "" { showReference = true } - if rr.Severity == "warning" { + if rr.Severity == "warning" || rr.Severity == "info" { showSeverity = true } } @@ -195,7 +218,7 @@ func writeRulesTable(w io.Writer, results []rule.Result, passCount, failCount, s } for _, rr := range results { - verdict := verdictLabelColoredPadded(rr.Verdict, 7) + verdict := verdictLabelColoredPaddedWithSeverity(rr.Verdict, rr.Severity, 7) level := severityLabel(rr.Severity) if showReference && showSeverity { if _, err := fmt.Fprintf(w, " %s %-*s %-*s %-*s\n", verdict, levelWidth, level, ruleWidth, rr.RuleID, refWidth, rr.Reference); err != nil { @@ -277,5 +300,8 @@ func severityLabelColored(severity string) string { if severity == "warning" { return colorize(label, ansiYellow) } + if severity == "info" { + return colorize(label, ansiBlue) + } return label } diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index a8523ce..2597cb6 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -63,8 +63,48 @@ func Evaluate( // For presence/absence operators, continue evaluation even if target not found // present: returns false when target not found (expected behavior) // absent: returns true when target not found (expected behavior) + // For eq/neq operators on keyUsage boolean fields, treat missing as implicit false // For other operators, skip when target not found (e.g., certificate rules when processing CRLs) if !found && r.Operator != "present" && r.Operator != "absent" { + // Special handling for eq/neq on keyUsage boolean fields + if (r.Operator == "eq" || r.Operator == "neq") && isKeyUsageBooleanField(r.Target) { + // Missing keyUsage bit = implicit false + // For eq true: false != true → FAIL + // For eq false: false == false → PASS + // For neq true: false != true → PASS + // For neq false: false == false → FAIL + var targetNode *node.Node + op, err := reg.Get(r.Operator) + if err != nil { + return Result{ + RuleID: r.ID, + Reference: r.Reference, + Verdict: VerdictFail, + Message: fmt.Sprintf("operator not found: %s", r.Operator), + Severity: r.Severity, + } + } + ok, err := op.Evaluate(targetNode, ctx, r.Operands) + if err != nil { + return Result{ + RuleID: r.ID, + Reference: r.Reference, + Verdict: VerdictFail, + Message: fmt.Sprintf("operator %s on %s: %v", r.Operator, r.Target, err), + Severity: r.Severity, + } + } + verdict := VerdictPass + if !ok { + verdict = VerdictFail + } + return Result{ + RuleID: r.ID, + Reference: r.Reference, + Verdict: verdict, + Severity: r.Severity, + } + } return Result{ RuleID: r.ID, Reference: r.Reference, @@ -139,4 +179,22 @@ func appliesTo(r Rule, ctx *operator.EvaluationContext) bool { return true } return slices.Contains(r.AppliesTo, ctx.Cert.Type) +} + +// isKeyUsageBooleanField checks if the target is a keyUsage boolean field. +// These fields represent key usage bits that are implicitly false when not present. +func isKeyUsageBooleanField(target string) bool { + keyUsageFields := []string{ + "certificate.keyUsage.digitalSignature", + "certificate.keyUsage.nonRepudiation", + "certificate.keyUsage.contentCommitment", + "certificate.keyUsage.keyEncipherment", + "certificate.keyUsage.dataEncipherment", + "certificate.keyUsage.keyAgreement", + "certificate.keyUsage.keyCertSign", + "certificate.keyUsage.cRLSign", + "certificate.keyUsage.encipherOnly", + "certificate.keyUsage.decipherOnly", + } + return slices.Contains(keyUsageFields, target) } \ No newline at end of file diff --git a/internal/zcrypto/helpers.go b/internal/zcrypto/helpers.go index 8ad7e1a..c7cbacd 100644 --- a/internal/zcrypto/helpers.go +++ b/internal/zcrypto/helpers.go @@ -16,6 +16,13 @@ func ToStdCert(cert *zx509.Certificate) (*stdx509.Certificate, error) { return stdx509.ParseCertificate(cert.Raw) } +func FromStdCert(cert *stdx509.Certificate) (*zx509.Certificate, error) { + if cert == nil { + return nil, nil + } + return zx509.ParseCertificate(cert.Raw) +} + func BuildPkixName(name string, pkixName pkix.Name) *node.Node { n := node.New(name, nil) diff --git a/tests/integration_test.go b/tests/integration_test.go index 32aab08..81e028e 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -9,8 +9,9 @@ import ( "gopkg.in/yaml.v3" "github.com/cavoq/PCL/internal/cert" - "github.com/cavoq/PCL/internal/cert/zcrypto" + 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" @@ -109,13 +110,18 @@ func runCase(t *testing.T, caseDir string, tc testCase) { reg := operator.DefaultRegistry() results := make([]policy.Result, 0, len(chain)) ctxOpts := make([]operator.ContextOption, 0) + + // Load CRLs once + var crlInfos []*crl.Info if crlPath != "" { - crls, err := crl.GetCRLs(crlPath) + crlInfos, err = crl.GetCRLs(crlPath) if err != nil { t.Fatalf("unexpected CRL load error: %v", err) } - ctxOpts = append(ctxOpts, operator.WithCRLs(crls)) + ctxOpts = append(ctxOpts, operator.WithCRLs(crlInfos)) } + + // Load OCSP once if ocspPath != "" { ocsps, err := ocsp.GetOCSPs(ocspPath) if err != nil { @@ -125,7 +131,21 @@ func runCase(t *testing.T, caseDir string, tc testCase) { } for _, c := range chain { - tree := zcrypto.BuildTree(c.Cert) + tree := certzcrypto.BuildTree(c.Cert) + + // Add CRL node to tree if CRLs are present (matching runner.go behavior) + if len(crlInfos) > 0 { + for _, crlInfo := range crlInfos { + if crlInfo.CRL != nil { + crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + if crlNode != nil { + tree.Children["crl"] = crlNode + } + break + } + } + } + ctx := operator.NewEvaluationContext(tree, c, chain, ctxOpts...) if !evalTime.IsZero() { ctx.Now = evalTime @@ -162,4 +182,4 @@ func countVerdicts(results []rule.Result) counts { } } return c -} +} \ No newline at end of file From d9e017eef346b8b99ddd1963c3acab10418a386c Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 00:24:19 +0800 Subject: [PATCH 04/44] docs: update README with auto-validate and RFC 4055/6960 support --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 93c7899..43b29d2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,30 @@ pcl --policy --cert [--crl ] [--ocsp ] [--output text| By default, only failed rules are shown. Use `-v` to include passed rules and `-vv` to include skipped rules. -You can also fetch a TLS certificate chain from HTTPS URLs: +### Auto-Validate Mode + +Automatically fetch PKI resources from certificate extensions (OCSP, CRL, CA Issuers) and climb the certificate chain: + +```bash +pcl --policy --cert leaf.pem --auto-validate +``` + +This mode: +- **Chain Climbing**: Recursively fetches issuer certificates via CA Issuers URLs +- **PKCS#7 Support**: Parses `.p7c` certificate bundles per RFC 5280/5652 +- **Auto OCSP/CRL**: Fetches revocation information from AIA extensions +- **Issuer Matching**: Handles multi-certificate bundles by matching Issuer DN and AKI-SKI + +Options for granular control: +```bash +pcl --policy --cert leaf.pem --auto-validate --max-chain-depth 5 +pcl --policy --cert leaf.pem --auto-validate --no-auto-crl +pcl --policy --cert leaf.pem --auto-validate --no-auto-ocsp +``` + +### Fetch TLS Certificates from URLs + +Fetch certificate chains from HTTPS endpoints: ```bash pcl --policy --cert-url https://example.test --cert-url-timeout 10s --cert-url-save-dir ./downloads @@ -62,6 +85,13 @@ rules: severity: error appliesTo: [root, intermediate] + # Severity levels: error, warning, info + - id: ca-issuers-url-recommended + target: certificate.caIssuersURL + operator: present + severity: info # Best practice recommendation (blue in output) + appliesTo: [leaf] + # Optional document reference (used by text output) - id: key-usage-leaf reference: RFC5280 4.2.1.3 @@ -72,7 +102,9 @@ rules: ## 🏛️ Supported Policies -- [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) - Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile +- [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) - Internet X.509 Public Key Infrastructure Certificate and CRL Profile +- [RFC 4055](https://datatracker.ietf.org/doc/html/rfc4055) - Additional Algorithms and Identifiers for RSA Cryptography +- [RFC 6960](https://datatracker.ietf.org/doc/html/rfc6960) - Online Certificate Status Protocol (OCSP) ## ➕ Supported Operators @@ -180,6 +212,18 @@ Rules can include a `when` clause to apply only when certain conditions are met: This rule only validates RSA exponent when the certificate uses RSA. If the condition is not met, the rule is skipped. +## ⚠️ Severity Levels + +PCL supports three severity levels: + +| Level | Color | Description | +|-------|-------|-------------| +| `error` | Red | Mandatory compliance requirement | +| `warning` | Yellow | Important best practice or conditional requirement | +| `info` | Blue | Recommended best practice (non-blocking) | + +Example: EV certificates should have SCT embedded (warning), while AIA extension presence is recommended (info) for interoperability. + ## Certificate Chain Support PCL automatically builds and validates certificate chains, applying rules based on certificate position: @@ -190,6 +234,16 @@ PCL automatically builds and validates certificate chains, applying rules based Use `appliesTo` in rules to target specific certificate types. +## 📥 Input Type Filtering + +Policies are automatically filtered by input type based on rule targets: + +- Rules with `certificate.*` targets → applied to X.509 certificates +- Rules with `crl.*` targets → applied to CRLs +- Rules with `ocsp.*` targets → applied to OCSP responses + +This allows mixed policies to validate different PKI components independently. Use `appliesTo` to explicitly specify input types: `cert`, `crl`, `ocsp`. + ## 🔧 Development ```bash From 2efd3b213a10092db1b52e0ff6edabf283ff96bb Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 00:26:47 +0800 Subject: [PATCH 05/44] docs: improve policy YAML examples and add node tree structure --- README.md | 244 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 214 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 43b29d2..43d2479 100644 --- a/README.md +++ b/README.md @@ -51,52 +51,117 @@ Policies are YAML files defining validation rules with a simple declarative synt ```yaml id: rfc5280 +version: 1.0 + rules: - - id: version-v3 + # ------------------------------------------------- + # Certificate structure (Section 4.1) + # ------------------------------------------------- + + # Version MUST be 3 when extensions are present + - id: version-v3-when-extensions + reference: RFC5280 4.1.2.1 + when: + target: certificate.extensions + operator: present target: certificate.version operator: eq operands: [3] severity: error - - id: signature-algorithm-secure - target: certificate.signatureAlgorithm.algorithm - operator: in - operands: - - SHA256-RSA - - SHA384-RSA - - ECDSA-SHA256 + # Serial number MUST be positive integer + - id: serial-number-positive + reference: RFC5280 4.1.2.2 + target: certificate.serialNumber.value + operator: positive severity: error - # Conditional rule using "when" clause - - id: rsa-key-size-minimum - when: - target: certificate.subjectPublicKeyInfo.algorithm - operator: eq - operands: [RSA] - target: certificate.subjectPublicKeyInfo.publicKey.keySize - operator: gte - operands: [2048] + # Serial number uniqueness in chain + - id: serial-number-unique + reference: RFC5280 4.1.2.2 + target: certificate + operator: serialNumberUnique + severity: error + + # ------------------------------------------------- + # Signature validation (Section 4.1.2.3) + # ------------------------------------------------- + + - id: signature-valid + reference: RFC5280 4.1.2.3 + target: certificate + operator: signatureValid + severity: error + + - id: signature-algorithm-matches-tbs + reference: RFC5280 4.1.2.3 + target: certificate + operator: signatureAlgorithmMatchesTBS severity: error + # ------------------------------------------------- + # Basic Constraints (Section 4.2.1.9) + # ------------------------------------------------- + - id: ca-basic-constraints + reference: RFC5280 4.2.1.9 target: certificate.basicConstraints.cA operator: eq operands: [true] severity: error appliesTo: [root, intermediate] - # Severity levels: error, warning, info - - id: ca-issuers-url-recommended + # ------------------------------------------------- + # Key Usage (Section 4.2.1.3) + # ------------------------------------------------- + + - id: key-usage-ca + reference: RFC5280 4.2.1.3 + target: certificate.keyUsage + operator: keyUsageCA + severity: error + appliesTo: [root, intermediate] + + # ------------------------------------------------- + # AIA Extension - Best Practice (INFO severity) + # ------------------------------------------------- + + - id: leaf-ca-issuers-url-recommended + reference: RFC5280 4.2.2.1 target: certificate.caIssuersURL operator: present - severity: info # Best practice recommendation (blue in output) + severity: info appliesTo: [leaf] - # Optional document reference (used by text output) - - id: key-usage-leaf - reference: RFC5280 4.2.1.3 - target: certificate.keyUsage - operator: keyUsageLeaf + # ------------------------------------------------- + # Subject Alternative Name (Section 4.2.1.6) + # ------------------------------------------------- + + - id: san-required-if-empty-subject + reference: RFC5280 4.2.1.6 + when: + target: certificate.subject + operator: isEmpty + target: certificate.subjectAltName + operator: present + severity: error + + # ------------------------------------------------- + # Conditional RSA validation (RFC 4055) + # ------------------------------------------------- + + - id: rsa-params-null + reference: RFC4055 Section 5 + when: + target: certificate.signatureAlgorithm.oid + operator: in + operands: + - "1.2.840.113549.1.1.11" # sha256WithRSAEncryption + - "1.2.840.113549.1.1.12" # sha384WithRSAEncryption + - "1.2.840.113549.1.1.13" # sha512WithRSAEncryption + target: certificate.signatureAlgorithm.parameters.null + operator: eq + operands: [true] severity: error ``` @@ -200,17 +265,41 @@ rules: Rules can include a `when` clause to apply only when certain conditions are met: ```yaml -- id: rsa-exponent-valid +# Only validate RSA key size when certificate uses RSA algorithm +- id: rsa-key-size-minimum + reference: RFC4055 when: - target: certificate.subjectPublicKeyInfo.algorithm + target: certificate.subjectPublicKeyInfo.algorithm.algorithm operator: eq operands: [RSA] - target: certificate.subjectPublicKeyInfo.publicKey.exponent - operator: odd + target: certificate.subjectPublicKeyInfo.publicKey.keySize + operator: gte + operands: [2048] + severity: error + +# Only check SAN when subject is empty (RFC 5280 4.2.1.6) +- id: san-required-if-empty-subject + reference: RFC5280 4.2.1.6 + when: + target: certificate.subject + operator: isEmpty + target: certificate.subjectAltName + operator: present severity: error + +# EV certificate MUST have SCT (only applies to EV certs) +- id: ev-sct-required + reference: CA/Browser Forum BR Appendix B + when: + target: certificate.certificatePolicies.2.23.140.1.1 + operator: present + target: certificate.signedCertificateTimestamps + operator: present + severity: warning + appliesTo: [leaf] ``` -This rule only validates RSA exponent when the certificate uses RSA. If the condition is not met, the rule is skipped. +When the `when` condition is not met, the rule status is **SKIP** (not displayed by default, use `-vv` to see). ## ⚠️ Severity Levels @@ -244,6 +333,101 @@ Policies are automatically filtered by input type based on rule targets: This allows mixed policies to validate different PKI components independently. Use `appliesTo` to explicitly specify input types: `cert`, `crl`, `ocsp`. +## 🌳 Node Tree Structure + +Rules target fields using a dot-separated path notation. The node tree structure mirrors the certificate/CRL/OCSP structure: + +### Certificate Node Tree + +``` +certificate +├── version # Integer (1, 2, 3) +├── serialNumber +│ ├── value # String representation +│ └── ... # Raw bytes +├── signatureAlgorithm +│ ├── algorithm # String name (e.g., "SHA256-RSA") +│ ├── oid # OID string +│ └── parameters +│ ├── null # Boolean (true if NULL) +│ ├── absent # Boolean (true if omitted) +│ └── pss/oaep # PSS/OAEP parameters (if present) +├── tbsSignatureAlgorithm # Same structure as signatureAlgorithm +├── issuer / subject +│ ├── countryName +│ ├── organizationName +│ ├── commonName +│ └── ... +├── validity +│ ├── notBefore # time.Time +│ ├── notAfter # time.Time +├── subjectPublicKeyInfo +│ ├── algorithm +│ │ ├── algorithm # String (RSA, ECDSA) +│ │ ├── oid # OID string +│ └── publicKey +│ ├── keySize # Integer +│ ├── exponent # RSA exponent +│ ├── curve # ECDSA curve name +├── extensions +│ ├── # Each extension keyed by OID +│ │ ├── oid +│ │ ├── critical # Boolean +│ │ └── value # Raw bytes +├── basicConstraints +│ ├── cA # Boolean +│ ├── pathLenConstraint # Integer (if present) +├── keyUsage # Integer bitmask +│ ├── digitalSignature # Boolean (per bit) +│ ├── keyCertSign # Boolean +│ └── ... +├── extKeyUsage +│ ├── serverAuth # Boolean +│ ├── clientAuth # Boolean +│ └── ... +├── subjectKeyIdentifier # Bytes +├── authorityKeyIdentifier # Bytes +├── subjectAltName +│ ├── dNSName +│ ├── iPAddress +│ └── ... +├── caIssuersURL # String (first CA Issuers URL) +├── ocspURL # String (first OCSP URL) +├── cRLDistributionPoints # Array of URLs +├── signedCertificateTimestamps # SCT list +└── certificatePolicies # Policy OIDs keyed by OID string +``` + +### CRL Node Tree + +``` +crl +├── version +├── signatureAlgorithm # Same as certificate +├── issuer # Same as certificate +├── thisUpdate # time.Time +├── nextUpdate # time.Time +├── revokedCertificates +│ ├── # Each revoked cert +│ │ ├── serialNumber +│ │ ├── revocationDate +│ │ ├── revocationReason +└── extensions + └── ... +``` + +### OCSP Node Tree + +``` +ocsp +├── status # String (Good, Revoked, Unknown) +├── producedAt # time.Time +├── thisUpdate # time.Time +├── nextUpdate # time.Time +├── revocationReason # Integer (if revoked) +└── signatureAlgorithm # Same as certificate +``` + ## 🔧 Development ```bash From 3a05d37f686d8fc7f3f1b002f2a8ebc27e448639 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 10:35:58 +0800 Subject: [PATCH 06/44] fix: crlSignedBy operator skip non-applicable CRLs When CRL issuer is not in chain, skip verification instead of failing. The operator now only verifies CRLs whose issuers are present in the certificate chain. If no CRLs are applicable, returns true (not applicable). The notRevoked operator handles checking revocation status against applicable CRLs only. --- internal/operator/crl.go | 28 +++++++++++++++++++++------- internal/operator/crl_test.go | 7 +++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/internal/operator/crl.go b/internal/operator/crl.go index f708e0d..aa0920c 100644 --- a/internal/operator/crl.go +++ b/internal/operator/crl.go @@ -2,6 +2,7 @@ package operator import ( "github.com/cavoq/PCL/internal/node" + "github.com/zmap/zcrypto/x509" ) type CRLValid struct{} @@ -62,13 +63,16 @@ func (CRLSignedBy) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool return false, nil } + // Track if we found any applicable CRL (CRL whose issuer is in chain) + var foundApplicableCRL bool for _, crlInfo := range ctx.CRLs { if crlInfo.CRL == nil { continue } crl := crlInfo.CRL - verified := false + // Find issuer in chain + var crlIssuerInChain *x509.Certificate for _, certInfo := range ctx.Chain { if certInfo.Cert == nil { continue @@ -78,18 +82,28 @@ func (CRLSignedBy) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool continue } - err := crl.CheckSignatureFrom(certInfo.Cert) - if err == nil { - verified = true - break - } + crlIssuerInChain = certInfo.Cert + break } - if !verified { + // If CRL issuer not in chain, skip this CRL (can't verify) + if crlIssuerInChain == nil { + continue + } + + foundApplicableCRL = true + + // Verify signature + err := crl.CheckSignatureFrom(crlIssuerInChain) + if err != nil { return false, nil } } + // If we found and verified at least one applicable CRL, return true + // If no CRLs were applicable (all skipped - issuers not in chain), return true (not applicable) + // The cert-not-revoked rule handles checking revocation status + _ = foundApplicableCRL // track for future use return true, nil } diff --git a/internal/operator/crl_test.go b/internal/operator/crl_test.go index cec0689..6415b25 100644 --- a/internal/operator/crl_test.go +++ b/internal/operator/crl_test.go @@ -272,8 +272,11 @@ func TestCRLSignedByIssuerMismatch(t *testing.T) { if err != nil { t.Errorf("unexpected error: %v", err) } - if got { - t.Error("issuer mismatch should return false") + // When CRL issuer is not in chain, the CRL is not applicable for verification + // The operator returns true (no applicable CRLs to verify) + // The notRevoked operator handles checking revocation against applicable CRLs only + if !got { + t.Error("issuer not in chain should return true (CRL not applicable)") } } From 327d0cc5a4f0665bb5ac050728d09cd6c93fa818 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 11:18:58 +0800 Subject: [PATCH 07/44] feat: auto-skip rules by input type at rule level When evaluating mixed policies (e.g., RFC5280 with both certificate and CRL rules), rules are now automatically skipped based on the input type: - Certificate rules skipped when only CRL is provided - CRL rules skipped when only certificate is provided - Both evaluated when certificate + CRL combination Changes: - evaluator.go: Add targetMatchesInputType check before evaluation - runner.go: Fix CRL-only mode - don't treat issuer as leaf cert - filter.go: Relax policy-level filter to pass if any rule matches --- internal/linter/filter.go | 34 ++++++++++++++++---- internal/linter/runner.go | 48 ++++++++++++++++------------ internal/rule/evaluator.go | 65 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 27 deletions(-) diff --git a/internal/linter/filter.go b/internal/linter/filter.go index 2334987..8074775 100644 --- a/internal/linter/filter.go +++ b/internal/linter/filter.go @@ -93,26 +93,26 @@ func inferInputTypeFromRules(rules []rule.Rule) string { // Check first rule target target := rules[0].Target - if strings.HasPrefix(target, "certificate.") { + if strings.HasPrefix(target, "certificate.") || target == "certificate" { return AppliesToCert } - if strings.HasPrefix(target, "crl.") { + if strings.HasPrefix(target, "crl.") || target == "crl" { return AppliesToCRL } - if strings.HasPrefix(target, "ocsp.") { + if strings.HasPrefix(target, "ocsp.") || target == "ocsp" { return AppliesToOCSP } // Check "when" condition target if main target doesn't indicate type if rules[0].When != nil && rules[0].When.Target != "" { whenTarget := rules[0].When.Target - if strings.HasPrefix(whenTarget, "certificate.") { + if strings.HasPrefix(whenTarget, "certificate.") || whenTarget == "certificate" { return AppliesToCert } - if strings.HasPrefix(whenTarget, "crl.") { + if strings.HasPrefix(whenTarget, "crl.") || whenTarget == "crl" { return AppliesToCRL } - if strings.HasPrefix(whenTarget, "ocsp.") { + if strings.HasPrefix(whenTarget, "ocsp.") || whenTarget == "ocsp" { return AppliesToOCSP } } @@ -166,7 +166,7 @@ func policyAppliesToCert(p policy.Policy, cert *x509.Certificate) bool { // policyAppliesToCRL checks if a policy applies to a specific CRL func policyAppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) bool { // Check input type first - if !policyAppliesToInput(p, AppliesToCRL) { + if !policyAppliesToCRLInput(p) { return false } @@ -204,6 +204,26 @@ func policyAppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL b return false } +// policyAppliesToCRLInput checks if a policy contains any CRL-related rules +func policyAppliesToCRLInput(p policy.Policy) bool { + // If AppliesTo is explicitly set, use it + if len(p.AppliesTo) > 0 { + return slices.Contains(p.AppliesTo, AppliesToCRL) + } + + // Check if any rule targets CRL + for _, r := range p.Rules { + if strings.HasPrefix(r.Target, "crl.") || r.Target == "crl" { + return true + } + if r.When != nil && (strings.HasPrefix(r.When.Target, "crl.") || r.When.Target == "crl") { + return true + } + } + + return false +} + // policyAppliesToOCSP checks if a policy applies to OCSP responses func policyAppliesToOCSP(p policy.Policy) bool { return policyAppliesToInput(p, AppliesToOCSP) diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 4fa29c1..ff365a9 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -35,6 +35,7 @@ func Run(cfg Config, w io.Writer) error { reg := operator.DefaultRegistry() var results []policy.Result + var cleanup func() // Load CRLs if provided var crls []*crl.Info @@ -58,29 +59,35 @@ func Run(cfg Config, w io.Writer) error { hasCert := cfg.CertPath != "" || len(cfg.CertURLs) > 0 hasIssuer := len(cfg.IssuerPaths) > 0 || len(cfg.IssuerURLs) > 0 - if hasCert || hasIssuer { + // Build issuer list for CRL/OCSP signature verification (always needed if provided) + var issuers []*cert.Info + if hasIssuer { + var issuerCleanup func() + issuers, issuerCleanup, err = loadIssuers(cfg, nil) + if err != nil { + return err + } + if issuerCleanup != nil { + cleanup = issuerCleanup + } + } + + // Schedule cleanup at the end of processing (for downloaded certs/issuers) + if cleanup != nil { + defer cleanup() + } + + if hasCert { // Load leaf certificates var certs []*cert.Info - var cleanup func() + var certCleanup func() - if hasCert { - certs, cleanup, err = loadCertificates(cfg) - if err != nil { - return err - } - } - - // Load issuer certificates - var issuers []*cert.Info - if hasIssuer { - issuers, cleanup, err = loadIssuers(cfg, cleanup) - if err != nil { - return err - } + certs, certCleanup, err = loadCertificates(cfg) + if err != nil { + return err } - - if cleanup != nil { - defer cleanup() + if certCleanup != nil { + cleanup = certCleanup } // Build chain: leaf + issuers @@ -203,6 +210,7 @@ func Run(cfg Config, w io.Writer) error { } } else if len(crls) > 0 { // Process CRLs independently when no certificates provided + // Use issuers as chain for CRL signature verification for _, crlInfo := range crls { if crlInfo.CRL == nil { continue @@ -217,7 +225,7 @@ func Run(cfg Config, w io.Writer) error { tree := crlNode ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} - ctx := operator.NewEvaluationContext(tree, nil, nil, ctxOpts...) + ctx := operator.NewEvaluationContext(tree, nil, issuers, ctxOpts...) // Filter policies by CRL type hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index 2597cb6..188d7ee 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -3,6 +3,7 @@ package rule import ( "fmt" "slices" + "strings" "github.com/cavoq/PCL/internal/node" "github.com/cavoq/PCL/internal/operator" @@ -28,6 +29,17 @@ func Evaluate( reg *operator.Registry, ctx *operator.EvaluationContext, ) Result { + // Check if rule target matches the input type + if !targetMatchesInputType(root, r.Target) { + return Result{ + RuleID: r.ID, + Reference: r.Reference, + Verdict: VerdictSkip, + Severity: r.Severity, + Message: "rule target does not match input type", + } + } + if !appliesTo(r, ctx) { return Result{ RuleID: r.ID, @@ -197,4 +209,57 @@ func isKeyUsageBooleanField(target string) bool { "certificate.keyUsage.decipherOnly", } return slices.Contains(keyUsageFields, target) +} + +// targetMatchesInputType checks if the rule's target is compatible with the input type. +// Returns true if the target prefix matches the tree structure, or if target is generic. +// Returns false if target prefix doesn't match (e.g., certificate rule on CRL input). +func targetMatchesInputType(root *node.Node, target string) bool { + if root == nil { + return true // No tree, can't determine type + } + + return matchesTreePrefix(root, target) +} + +// matchesTreePrefix checks if the target's prefix matches the tree structure. +// Returns true if: +// - target prefix matches root.Name (Resolve will skip this prefix) +// - target prefix exists as child in tree +// - target is generic (no known prefix) +func matchesTreePrefix(root *node.Node, target string) bool { + // Extract prefix from target (e.g., "certificate" from "certificate.xxx") + prefix := extractPrefix(target) + if prefix == "" { + return true // Generic target, applies to all + } + + // If prefix matches root name, Resolve will skip it and work correctly + if root.Name == prefix { + return true + } + + // Check if prefix exists as child in tree + if root.Children != nil { + _, exists := root.Children[prefix] + return exists + } + + return false +} + +// extractPrefix extracts the input type prefix from a target string. +// E.g., "certificate.xxx" -> "certificate", "crl.xxx" -> "crl" +func extractPrefix(target string) string { + // Check known prefixes + if strings.HasPrefix(target, "certificate.") || target == "certificate" { + return "certificate" + } + if strings.HasPrefix(target, "crl.") || target == "crl" { + return "crl" + } + if strings.HasPrefix(target, "ocsp.") || target == "ocsp" { + return "ocsp" + } + return "" // Unknown or generic target } \ No newline at end of file From 778a70c2ff8a62578ba0e72b02775a532873dfa3 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 11:29:50 +0800 Subject: [PATCH 08/44] fix: use white color for INFO level failures INFO level failures now display in white instead of blue for better visibility on terminal backgrounds where blue can be hard to distinguish from green. Color scheme: - FAIL ERROR: red - FAIL WARN: yellow - FAIL INFO: white (changed from blue) - PASS: green - SKIP: cyan --- internal/output/text.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/output/text.go b/internal/output/text.go index 4ad77d8..f3bd645 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -121,9 +121,9 @@ func verdictLabelColoredPaddedWithSeverity(verdict string, severity string, widt case rule.VerdictPass: return colorize(padded, ansiGreen) case rule.VerdictFail: - // INFO level failures use blue (informational), not red (error) + // INFO level failures use white (informational), more visible than blue if severity == "info" { - return colorize(padded, ansiBlue) + return colorize(padded, ansiWhite) } if severity == "warning" { return colorize(padded, ansiYellow) @@ -147,6 +147,7 @@ const ( ansiYellow = "\033[33m" ansiBlue = "\033[34m" ansiCyan = "\033[36m" + ansiWhite = "\033[37m" ) func writeRulesTable(w io.Writer, results []rule.Result, passCount, failCount, skipCount int) error { From e634ff1f0ad5fb2365ae48a408f46f4e59d47ace Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 17:17:42 +0800 Subject: [PATCH 09/44] fix: redesign node tree for correct absent/isNull operator semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node tree now only creates parameters node when data exists: - Absent parameters: no node created → `absent` operator returns true - NULL parameters: node with null=true → `isNull` operator returns true - Present parameters: node with namedCurve/other data Changes: - asn1/parser.go: add NamedCurve field for ECDSA curve OID extraction - cert/zcrypto/builder.go: return nil when parameters absent - operator/asn1.go: remove duplicate IsAbsent operator, clarify IsNull - operator/operator.go: remove IsAbsent from operator registry - operator/asn1_test.go: update tests for new semantics - linter/runner.go: fix single policy file loading RFC 4055 (RSA): parameters MUST be NULL → use `isNull` RFC 5758 (ECDSA): parameters MUST be absent → use `absent` RFC 5480 (ECDSA SPKI): parameters MUST present namedCurve → use `present` --- internal/asn1/parser.go | 17 ++++++-- internal/cert/zcrypto/builder.go | 34 ++++++++++++--- internal/linter/runner.go | 27 ++++++++++-- internal/operator/asn1.go | 29 +++---------- internal/operator/asn1_test.go | 73 -------------------------------- internal/operator/operator.go | 1 - 6 files changed, 72 insertions(+), 109 deletions(-) diff --git a/internal/asn1/parser.go b/internal/asn1/parser.go index efeef6e..c49509c 100644 --- a/internal/asn1/parser.go +++ b/internal/asn1/parser.go @@ -7,9 +7,10 @@ import ( // ParamsState represents the state of an AlgorithmIdentifier parameters field. type ParamsState struct { - IsNull bool // parameters is ASN.1 NULL - IsAbsent bool // parameters field is absent - OID string // algorithm OID + IsNull bool // parameters is ASN.1 NULL + IsAbsent bool // parameters field is absent + OID string // algorithm OID + NamedCurve string // namedCurve OID for ECDSA (from parameters field) // RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) PSS *PSSParams @@ -82,6 +83,16 @@ func ParseAlgorithmIDParams(derBytes []byte) ParamsState { return result } + // For ECDSA (id-ecPublicKey), parameters is a namedCurve OID + // ECDSA OIDs: 1.2.840.10045.2.1 (id-ecPublicKey), 1.3.132.1.12 (id-ecDH), 1.3.132.1.13 (id-ecMQV) + if paramsTag == cryptobyte_asn1.OBJECT_IDENTIFIER { + var namedCurve cryptobyte.String + if params.ReadASN1(&namedCurve, cryptobyte_asn1.OBJECT_IDENTIFIER) { + result.NamedCurve = oidString(namedCurve) + } + return result + } + // Parse RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) if result.OID == "1.2.840.113549.1.1.10" { result.PSS = parsePSSParams(params) diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index aa4c1f8..68b4338 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -130,7 +130,10 @@ func buildSignatureAlgorithm(cert *x509.Certificate) *node.Node { if len(cert.SignatureAlgorithmOID) > 0 { n.Children["oid"] = node.New("oid", cert.SignatureAlgorithmOID.String()) } - n.Children["parameters"] = buildAlgorithmIDParams(ParseCertSignatureAlgorithmParams(cert.Raw)) + params := buildAlgorithmIDParams(ParseCertSignatureAlgorithmParams(cert.Raw)) + if params != nil { + n.Children["parameters"] = params + } return n } @@ -140,14 +143,29 @@ func buildTBSSignatureAlgorithm(cert *x509.Certificate) *node.Node { if len(cert.SignatureAlgorithmOID) > 0 { n.Children["oid"] = node.New("oid", cert.SignatureAlgorithmOID.String()) } - n.Children["parameters"] = buildAlgorithmIDParams(ParseTBSCertSignatureParams(cert.RawTBSCertificate)) + params := buildAlgorithmIDParams(ParseTBSCertSignatureParams(cert.RawTBSCertificate)) + if params != nil { + n.Children["parameters"] = params + } return n } func buildAlgorithmIDParams(params asn1.ParamsState) *node.Node { + // If parameters are absent, do NOT create a node. + // This allows the `absent` operator to work correctly. + if params.IsAbsent { + return nil + } + + // If parameters are NULL, create node with null=true. + // This allows the `isNull` operator to work correctly. n := node.New("parameters", nil) n.Children["null"] = node.New("null", params.IsNull) - n.Children["absent"] = node.New("absent", params.IsAbsent) + + // For ECDSA, the NamedCurve is the curve OID (e.g., secp256r1, secp384r1) + if params.NamedCurve != "" { + n.Children["namedCurve"] = node.New("namedCurve", params.NamedCurve) + } if params.PSS != nil { n.Children["pss"] = buildPSSParams(params.PSS) @@ -191,8 +209,9 @@ func buildOAEPParams(oaep *asn1.OAEPParams) *node.Node { func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { n := node.New("algorithm", nil) n.Children["oid"] = node.New("oid", algo.OID) - if algo.Params.OID != "" { - n.Children["parameters"] = buildAlgorithmIDParams(algo.Params) + params := buildAlgorithmIDParams(algo.Params) + if params != nil { + n.Children["parameters"] = params } return n } @@ -212,7 +231,10 @@ func buildSubjectPublicKeyInfo(cert *x509.Certificate) *node.Node { if len(cert.PublicKeyAlgorithmOID) > 0 { algo.Children["oid"] = node.New("oid", cert.PublicKeyAlgorithmOID.String()) } - algo.Children["parameters"] = buildAlgorithmIDParams(ParseSubjectPublicKeyInfoParams(cert.RawSubjectPublicKeyInfo)) + params := buildAlgorithmIDParams(ParseSubjectPublicKeyInfoParams(cert.RawSubjectPublicKeyInfo)) + if params != nil { + algo.Children["parameters"] = params + } n.Children["algorithm"] = algo if cert.PublicKey != nil { diff --git a/internal/linter/runner.go b/internal/linter/runner.go index ff365a9..2d2b531 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -4,6 +4,7 @@ import ( certstd "crypto/x509" "fmt" "io" + "os" "time" "github.com/cavoq/PCL/internal/aia" @@ -23,12 +24,23 @@ import ( func Run(cfg Config, w io.Writer) error { applyDefaults(&cfg) - policies, err := policy.ParseDir(cfg.PolicyPath) + + // Check if path is a directory first + isDir, err := isDirectory(cfg.PolicyPath) if err != nil { - policies = nil + return fmt.Errorf("checking policy path: %w", err) + } + + var policies []policy.Policy + if isDir { + policies, err = policy.ParseDir(cfg.PolicyPath) + if err != nil { + return fmt.Errorf("failed to parse policy directory: %w", err) + } + } else { p, err := policy.ParseFile(cfg.PolicyPath) if err != nil { - return fmt.Errorf("failed to parse policies: %w", err) + return fmt.Errorf("failed to parse policy file: %w", err) } policies = append(policies, p) } @@ -559,4 +571,13 @@ func addFetchedInfoToNode(tree *node.Node, caIssuerFormat aia.Format, crlFormat if crlFormat != "" { tree.Children["crlFormat"] = node.New("crlFormat", string(crlFormat)) } +} + +// isDirectory checks if the given path is a directory. +func isDirectory(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + return false, err + } + return info.IsDir(), nil } \ No newline at end of file diff --git a/internal/operator/asn1.go b/internal/operator/asn1.go index 9afa9a9..11929b2 100644 --- a/internal/operator/asn1.go +++ b/internal/operator/asn1.go @@ -6,12 +6,18 @@ import ( // IsNull checks if the node represents an ASN.1 NULL value. // Used for checking AlgorithmIdentifier parameters per RFC 4055. +// Returns true when: +// - Node exists and has null=true child +// Returns false when: +// - Node is nil (parameters are absent, not NULL) +// - Node exists but null is not true type IsNull struct{} func (IsNull) Name() string { return "isNull" } func (IsNull) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { if n == nil { + // Parameters absent - not the same as NULL return false, nil } @@ -24,28 +30,5 @@ func (IsNull) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error return v, nil } - return false, nil -} - -// IsAbsent checks if the node represents an absent/missing parameter. -// Used for checking AlgorithmIdentifier parameters that should not be absent per RFC 4055. -type IsAbsent struct{} - -func (IsAbsent) Name() string { return "isAbsent" } - -func (IsAbsent) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { - if n == nil { - return true, nil - } - - absentNode, ok := n.Children["absent"] - if !ok { - return false, nil - } - - if v, ok := absentNode.Value.(bool); ok { - return v, nil - } - return false, nil } \ No newline at end of file diff --git a/internal/operator/asn1_test.go b/internal/operator/asn1_test.go index 64c5caf..bb07659 100644 --- a/internal/operator/asn1_test.go +++ b/internal/operator/asn1_test.go @@ -24,7 +24,6 @@ func TestIsNull(t *testing.T) { Value: nil, Children: map[string]*node.Node{ "null": node.New("null", true), - "absent": node.New("absent", false), }, }, expected: true, @@ -36,7 +35,6 @@ func TestIsNull(t *testing.T) { Value: nil, Children: map[string]*node.Node{ "null": node.New("null", false), - "absent": node.New("absent", false), }, }, expected: false, @@ -75,75 +73,4 @@ func TestIsNull(t *testing.T) { } }) } -} - -func TestIsAbsent(t *testing.T) { - tests := []struct { - name string - node *node.Node - expected bool - }{ - { - name: "nil node", - node: nil, - expected: true, - }, - { - name: "parameters with absent=true", - node: &node.Node{ - Name: "parameters", - Value: nil, - Children: map[string]*node.Node{ - "null": node.New("null", false), - "absent": node.New("absent", true), - }, - }, - expected: true, - }, - { - name: "parameters with absent=false", - node: &node.Node{ - Name: "parameters", - Value: nil, - Children: map[string]*node.Node{ - "null": node.New("null", true), - "absent": node.New("absent", false), - }, - }, - expected: false, - }, - { - name: "parameters without absent child", - node: &node.Node{ - Name: "parameters", - Value: nil, - Children: map[string]*node.Node{}, - }, - expected: false, - }, - { - name: "parameters with non-bool absent", - node: &node.Node{ - Name: "parameters", - Value: nil, - Children: map[string]*node.Node{ - "absent": node.New("absent", "true"), - }, - }, - expected: false, - }, - } - - op := IsAbsent{} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := op.Evaluate(tt.node, nil, nil) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if result != tt.expected { - t.Errorf("got %v, want %v", result, tt.expected) - } - }) - } } \ No newline at end of file diff --git a/internal/operator/operator.go b/internal/operator/operator.go index f645962..8feeca5 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -59,5 +59,4 @@ var All = []Operator{ NameConstraintsValid{}, CertificatePolicyValid{}, IsNull{}, - IsAbsent{}, } From df1b5243917ae24bd472e336254dd1f9443173c0 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 17:28:12 +0800 Subject: [PATCH 10/44] fix: apply same node tree semantics to CRL and allow isNull on absent target CRL builder now matches certificate builder behavior: - Parameters node only created when data exists (not when absent) - Allows correct isNull/absent operator semantics Evaluator fix: - isNull operator continues evaluation when target not found - Returns false for absent target (absent is not NULL) - Correctly reports RFC 4055 violations where RSA params should be NULL RFC 4055 Section 5: RSA signatureAlgorithm parameters MUST be NULL - CRL with absent params now correctly FAILS isNull check - Previously would SKIP, missing real compliance violations --- internal/crl/zcrypto/builder.go | 24 +++++++++++++++++++----- internal/rule/evaluator.go | 5 +++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/internal/crl/zcrypto/builder.go b/internal/crl/zcrypto/builder.go index dba219c..c896844 100644 --- a/internal/crl/zcrypto/builder.go +++ b/internal/crl/zcrypto/builder.go @@ -61,7 +61,10 @@ func buildSignatureAlgorithm(crl *x509.RevocationList) *node.Node { n := node.New("signatureAlgorithm", nil) n.Children["algorithm"] = node.New("algorithm", crl.SignatureAlgorithm.String()) n.Children["oid"] = node.New("oid", params.OID) - n.Children["parameters"] = buildAlgorithmIDParams(params) + paramNode := buildAlgorithmIDParams(params) + if paramNode != nil { + n.Children["parameters"] = paramNode + } return n } @@ -70,14 +73,24 @@ func buildTBSSignatureAlgorithm(crl *x509.RevocationList) *node.Node { n := node.New("tbsSignatureAlgorithm", nil) n.Children["algorithm"] = node.New("algorithm", crl.SignatureAlgorithm.String()) n.Children["oid"] = node.New("oid", params.OID) - n.Children["parameters"] = buildAlgorithmIDParams(params) + paramNode := buildAlgorithmIDParams(params) + if paramNode != nil { + n.Children["parameters"] = paramNode + } return n } func buildAlgorithmIDParams(params asn1.ParamsState) *node.Node { + // If parameters are absent, do NOT create a node. + // This allows the `absent` operator to work correctly. + if params.IsAbsent { + return nil + } + + // If parameters are NULL, create node with null=true. + // This allows the `isNull` operator to work correctly. n := node.New("parameters", nil) n.Children["null"] = node.New("null", params.IsNull) - n.Children["absent"] = node.New("absent", params.IsAbsent) if params.PSS != nil { n.Children["pss"] = buildPSSParams(params.PSS) @@ -121,8 +134,9 @@ func buildOAEPParams(oaep *asn1.OAEPParams) *node.Node { func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { n := node.New("algorithm", nil) n.Children["oid"] = node.New("oid", algo.OID) - if algo.Params.OID != "" { - n.Children["parameters"] = buildAlgorithmIDParams(algo.Params) + paramNode := buildAlgorithmIDParams(algo.Params) + if paramNode != nil { + n.Children["parameters"] = paramNode } return n } diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index 188d7ee..8ac2037 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -72,12 +72,13 @@ func Evaluate( n, found := root.Resolve(r.Target) - // For presence/absence operators, continue evaluation even if target not found + // For presence/absence/null operators, continue evaluation even if target not found // present: returns false when target not found (expected behavior) // absent: returns true when target not found (expected behavior) + // isNull: returns false when target not found (absent is not NULL) // For eq/neq operators on keyUsage boolean fields, treat missing as implicit false // For other operators, skip when target not found (e.g., certificate rules when processing CRLs) - if !found && r.Operator != "present" && r.Operator != "absent" { + if !found && r.Operator != "present" && r.Operator != "absent" && r.Operator != "isNull" { // Special handling for eq/neq on keyUsage boolean fields if (r.Operator == "eq" || r.Operator == "neq") && isKeyUsageBooleanField(r.Target) { // Missing keyUsage bit = implicit false From 8f02597b1a810c76227e4ccc0144152f4c1dc870 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 23:47:12 +0800 Subject: [PATCH 11/44] feat: implement RFC 9654 OCSP nonce extension support OCSP Response Parsing: - Parse nonce from responseExtensions (not singleExtensions) - Add nonce node tree: ocsp.nonce.{present, value, length, hexValue} - Add unit tests for nonce parsing OCSP Request Construction: - Add nonce extension to OCSP requests via ASN.1 encoding - Support configurable nonce length (default 32 bytes per RFC 9654) - Support custom nonce value via --ocsp-nonce-value - Support --no-ocsp-nonce to disable nonce - Support --ocsp-hash (sha1/sha256) for CertID hash algorithm CLI Simplification: - Replace multiple auto-* flags with --auto-validate + negative options - Add --no-auto-chain, --no-auto-crl, --no-auto-ocsp - Remove deprecated --auto-ocsp flag Debug Output: - Add OCSP request/response debug output with -vv flag - Show nonce match status between request and response Fix: - Show SEVERITY column for all severity levels (including "error") --- cmd/pcl/main.go | 18 +- internal/linter/config.go | 19 +- internal/linter/runner.go | 145 ++++++++++++++-- internal/ocsp/fetcher.go | 249 ++++++++++++++++++++++++++- internal/ocsp/fetcher_test.go | 70 +++++++- internal/ocsp/ocsp.go | 7 + internal/ocsp/zcrypto/builder.go | 33 +++- internal/ocsp/zcrypto/parser.go | 224 ++++++++++++++++++++++++ internal/ocsp/zcrypto/parser_test.go | 27 +++ internal/output/text.go | 3 +- 10 files changed, 746 insertions(+), 49 deletions(-) create mode 100644 internal/ocsp/zcrypto/parser_test.go diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index 6919e19..36e4e51 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -23,9 +23,6 @@ func newRootCmd(opts *linter.Config) *cobra.Command { if !hasCert && !hasIssuer && opts.CRLPath == "" && opts.OCSPPath == "" { return fmt.Errorf("at least one of --cert, --cert-url, --issuer, --issuer-url, --crl, or --ocsp is required") } - if opts.AutoOCSP && !hasIssuer { - return fmt.Errorf("--auto-ocsp requires --issuer or --issuer-url") - } return linter.Run(*opts, cmd.OutOrStdout()) }, } @@ -39,7 +36,6 @@ func newRootCmd(opts *linter.Config) *cobra.Command { root.Flags().StringSliceVar(&opts.IssuerURLs, "issuer-url", nil, "Issuer certificate URL (repeatable)") root.Flags().StringVar(&opts.CRLPath, "crl", "", "Path to CRL file or directory (PEM/DER)") root.Flags().StringVar(&opts.OCSPPath, "ocsp", "", "Path to OCSP response file or directory (DER/PEM)") - root.Flags().BoolVar(&opts.AutoOCSP, "auto-ocsp", false, "Automatically fetch OCSP response from certificate's AIA extension (requires issuer)") root.Flags().DurationVar(&opts.OCSPTimeout, "ocsp-url-timeout", 5*time.Second, "OCSP request timeout (e.g. 5s, 10s)") root.Flags().StringVar(&opts.OutputFmt, "output", "text", "Output format: text, json, or yaml") root.Flags().CountVarP(&opts.Verbosity, "verbose", "v", "Increase output detail: -v shows passed, -vv includes skipped") @@ -47,11 +43,19 @@ func newRootCmd(opts *linter.Config) *cobra.Command { // Auto-validate mode flags root.Flags().BoolVar(&opts.AutoValidate, "auto-validate", false, "Enable automatic PKI resource fetching (OCSP, CRL, chain climbing)") - root.Flags().BoolVar(&opts.AutoValidateOCSP, "auto-ocsp-chain", true, "Fetch OCSP for all certificates in chain (only with --auto-validate)") - root.Flags().BoolVar(&opts.AutoValidateCRL, "auto-crl", true, "Fetch CRLs from CRL Distribution Points (only with --auto-validate)") - root.Flags().BoolVar(&opts.AutoValidateChain, "auto-chain", true, "Climb chain via CA Issuers URLs (only with --auto-validate)") + root.Flags().BoolVar(&opts.NoAutoChain, "no-auto-chain", false, "Disable chain climbing via CA Issuers URLs (only with --auto-validate)") + root.Flags().BoolVar(&opts.NoAutoCRL, "no-auto-crl", false, "Disable CRL fetching from CRL Distribution Points (only with --auto-validate)") + root.Flags().BoolVar(&opts.NoAutoOCSP, "no-auto-ocsp", false, "Disable OCSP fetching for all certificates in chain (only with --auto-validate)") root.Flags().IntVar(&opts.MaxChainDepth, "max-chain-depth", 10, "Maximum chain depth for climbing (only with --auto-validate)") + // OCSP nonce options (RFC 9654) + root.Flags().IntVar(&opts.OCSPNonceLength, "ocsp-nonce-length", 32, "Nonce length in bytes for OCSP requests (default 32, per RFC 9654)") + root.Flags().StringVar(&opts.OCSPNonceValue, "ocsp-nonce-value", "", "Custom nonce value in hex format (e.g. 'aabbcc...')") + root.Flags().BoolVar(&opts.NoOCSPNonce, "no-ocsp-nonce", false, "Disable nonce in OCSP requests") + + // OCSP request hash algorithm (RFC 5019 vs modern) + root.Flags().StringVar(&opts.OCSPHashAlgorithm, "ocsp-hash", "sha256", "Hash algorithm for OCSP CertID: 'sha1' (RFC 5019) or 'sha256' (default, modern)") + return root } diff --git a/internal/linter/config.go b/internal/linter/config.go index dcea95f..ea45ff9 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -13,16 +13,23 @@ type Config struct { IssuerURLs []string CRLPath string OCSPPath string - AutoOCSP bool OCSPTimeout time.Duration OutputFmt string Verbosity int ShowMeta bool // Auto-validate mode options - AutoValidate bool // Enable automatic PKI resource fetching - AutoValidateOCSP bool // Fetch OCSP from AIA (default true when AutoValidate) - AutoValidateCRL bool // Fetch CRL from CRL DP (default true when AutoValidate) - AutoValidateChain bool // Climb chain via CA Issuers URLs (default true when AutoValidate) - MaxChainDepth int // Maximum chain depth for climbing (default 10) + AutoValidate bool // Enable automatic PKI resource fetching (OCSP, CRL, chain climbing) + NoAutoChain bool // Disable chain climbing via CA Issuers URLs + NoAutoCRL bool // Disable CRL fetching from CRL Distribution Points + NoAutoOCSP bool // Disable OCSP fetching for all certificates in chain + MaxChainDepth int // Maximum chain depth for climbing (default 10) + + // OCSP nonce options (RFC 9654) + OCSPNonceLength int // Length of nonce to generate (default 32, per RFC 9654) + OCSPNonceValue string // Custom nonce value in hex format (optional) + NoOCSPNonce bool // Disable nonce in OCSP requests + + // OCSP request hash algorithm + OCSPHashAlgorithm string // Hash algorithm for CertID: "sha1" (RFC 5019) or "sha256" (default, modern) } diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 2d2b531..e7a5e9a 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -110,7 +110,7 @@ func Run(cfg Config, w io.Writer) error { // Auto-validate: climb chain via CA Issuers URLs BEFORE BuildChain // This fetches missing intermediates to complete the chain - if cfg.AutoValidate && cfg.AutoValidateChain { + if cfg.AutoValidate && !cfg.NoAutoChain { // Start from leaf certificates and climb toward root // We need to climb from each leaf to find intermediates var climbedCerts []*cert.Info @@ -133,14 +133,17 @@ func Run(cfg Config, w io.Writer) error { return fmt.Errorf("failed to build chain: %w", err) } + // Build nonce options from config + nonceOpts := buildNonceOptions(cfg) + // Auto-validate: fetch CRLs from CRL Distribution Points - if cfg.AutoValidate && cfg.AutoValidateCRL { + if cfg.AutoValidate && !cfg.NoAutoCRL { autoCRLs := fetchAutoCRL(chain, cfg.OCSPTimeout, w) crls = append(crls, autoCRLs...) } // Auto-validate: fetch OCSP for all certificates in chain - if cfg.AutoValidate && cfg.AutoValidateOCSP { + if cfg.AutoValidate && !cfg.NoAutoOCSP { for i := 0; i < len(chain)-1; i++ { c := chain[i] if c.Cert == nil || len(c.Cert.OCSPServer) == 0 { @@ -148,24 +151,21 @@ func Run(cfg Config, w io.Writer) error { } // Build mini chain for OCSP request: [cert, issuer] miniChain := []*cert.Info{c, chain[i+1]} - autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout) + autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout, nonceOpts) if err != nil { fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) continue } + // Debug: print OCSP response details when verbosity >= 2 + if cfg.Verbosity >= 2 && len(autoOCSPs) > 0 { + for _, ocspInfo := range autoOCSPs { + printOCSPResponseDebug(w, ocspInfo, nonceOpts) + } + } ocsps = append(ocsps, autoOCSPs...) } } - // Auto-fetch OCSP if enabled and chain has issuer (legacy mode) - if cfg.AutoOCSP && len(chain) >= 2 { - autoOCSPs, err := fetchAutoOCSP(chain, cfg.OCSPTimeout) - if err != nil { - // Log warning but continue - OCSP fetch failure shouldn't stop cert validation - fmt.Fprintf(w, "Warning: auto OCSP fetch failed: %v\n", err) - } - ocsps = append(ocsps, autoOCSPs...) - } for _, c := range chain { tree := certzcrypto.BuildTree(c.Cert) @@ -382,7 +382,7 @@ func applyDefaults(cfg *Config) { // fetchAutoOCSP automatically fetches OCSP responses for certificates in the chain. // For leaf certificates, uses the OCSP URL from AIA extension and issuer from chain. -func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration) ([]*ocsp.Info, error) { +func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.NonceOptions) ([]*ocsp.Info, error) { if len(chain) < 2 { return nil, fmt.Errorf("chain must have at least 2 certificates for OCSP request") } @@ -407,19 +407,30 @@ func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration) ([]*ocsp.Info, err } // Fetch OCSP for leaf certificate - resp, url, err := ocsp.FetchOCSPFromChain(stdChain, timeout) + fetchResult, url, err := ocsp.FetchOCSPFromChainWithInfo(stdChain, timeout, nonceOpts) if err != nil { return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) } - if resp == nil { + if fetchResult == nil { // No OCSP URL in certificate, not an error return nil, nil } - results = append(results, &ocsp.Info{ - Response: resp, + info := &ocsp.Info{ + Response: fetchResult.Response, FilePath: url, // Use URL as "file path" for auto-fetched responses - }) + } + + // Populate request debug info + if fetchResult.RequestInfo != nil { + info.RequestNonce = fetchResult.RequestInfo.Nonce + info.RequestNonceHex = fetchResult.RequestInfo.NonceHex + info.RequestNonceLen = fetchResult.RequestInfo.NonceLen + info.RequestRawLen = fetchResult.RequestInfo.RequestLen + info.RequestHashAlgorithm = fetchResult.RequestInfo.HashAlgorithm + } + + results = append(results, info) return results, nil } @@ -580,4 +591,100 @@ func isDirectory(path string) (bool, error) { return false, err } return info.IsDir(), nil +} + +// buildNonceOptions creates nonce options from config for OCSP requests (RFC 9654). +func buildNonceOptions(cfg Config) *ocsp.NonceOptions { + return &ocsp.NonceOptions{ + Length: cfg.OCSPNonceLength, + Value: cfg.OCSPNonceValue, + Disabled: cfg.NoOCSPNonce, + Hash: cfg.OCSPHashAlgorithm, + } +} + +// printOCSPResponseDebug prints OCSP response details for debugging. +func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.NonceOptions) { + if ocspInfo == nil || ocspInfo.Response == nil { + return + } + resp := ocspInfo.Response + + fmt.Fprintf(w, "\n[OCSP Debug]\n") + fmt.Fprintf(w, " URL: %s\n", ocspInfo.FilePath) + + // Print request info + fmt.Fprintf(w, " Request:\n") + if ocspInfo.RequestRawLen > 0 { + fmt.Fprintf(w, " Length: %d bytes\n", ocspInfo.RequestRawLen) + } else { + fmt.Fprintf(w, " Length: (unknown)\n") + } + + // Print hash algorithm used for CertID + if ocspInfo.RequestHashAlgorithm != "" { + fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", ocspInfo.RequestHashAlgorithm) + } else { + fmt.Fprintf(w, " CertID Hash Algorithm: SHA256 (default)\n") + } + + // Print nonce request info + if ocspInfo.RequestNonceLen > 0 { + fmt.Fprintf(w, " Nonce Length: %d bytes\n", ocspInfo.RequestNonceLen) + fmt.Fprintf(w, " Nonce (hex): %s\n", ocspInfo.RequestNonceHex) + } else if nonceOpts != nil && nonceOpts.Disabled { + fmt.Fprintf(w, " Nonce: disabled\n") + } else { + fmt.Fprintf(w, " Nonce: (not requested)\n") + } + + // Print response info + var statusStr string + switch resp.Status { + case 0: + statusStr = "Good" + case 1: + statusStr = "Revoked" + case 2: + statusStr = "Unknown" + default: + statusStr = fmt.Sprintf("Unknown(%d)", resp.Status) + } + fmt.Fprintf(w, " Response:\n") + fmt.Fprintf(w, " Status: %s\n", statusStr) + fmt.Fprintf(w, " ProducedAt: %s\n", resp.ProducedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(w, " ThisUpdate: %s\n", resp.ThisUpdate.Format("2006-01-02 15:04:05")) + if !resp.NextUpdate.IsZero() { + fmt.Fprintf(w, " NextUpdate: %s\n", resp.NextUpdate.Format("2006-01-02 15:04:05")) + } else { + fmt.Fprintf(w, " NextUpdate: (not set)\n") + } + if !resp.RevokedAt.IsZero() { + fmt.Fprintf(w, " RevokedAt: %s\n", resp.RevokedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(w, " RevocationReason: %d\n", resp.RevocationReason) + } + fmt.Fprintf(w, " SerialNumber: %s\n", resp.SerialNumber.String()) + fmt.Fprintf(w, " SignatureAlgorithm: %s\n", resp.SignatureAlgorithm.String()) + + // Parse nonce from raw response + nonceState := ocspzcrypto.ParseNonceFromRaw(resp.Raw) + fmt.Fprintf(w, " Response Nonce:\n") + if nonceState.Present { + fmt.Fprintf(w, " Present: true\n") + fmt.Fprintf(w, " Length: %d bytes\n", nonceState.Length) + fmt.Fprintf(w, " Value (hex): %s\n", nonceState.HexValue) + // Check if nonce matches request + if ocspInfo.RequestNonceLen > 0 && nonceState.Length == ocspInfo.RequestNonceLen { + if nonceState.HexValue == ocspInfo.RequestNonceHex { + fmt.Fprintf(w, " Match: YES (echoed correctly)\n") + } else { + fmt.Fprintf(w, " Match: NO (different value)\n") + } + } else if ocspInfo.RequestNonceLen > 0 && nonceState.Length != ocspInfo.RequestNonceLen { + fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", ocspInfo.RequestNonceLen, nonceState.Length) + } + } else { + fmt.Fprintf(w, " Present: false\n") + } + fmt.Fprintf(w, "\n") } \ No newline at end of file diff --git a/internal/ocsp/fetcher.go b/internal/ocsp/fetcher.go index 0f04a18..bcfa10b 100644 --- a/internal/ocsp/fetcher.go +++ b/internal/ocsp/fetcher.go @@ -3,7 +3,9 @@ package ocsp import ( "bytes" "crypto" + "crypto/rand" "crypto/x509" + "encoding/hex" "fmt" "io" "net/http" @@ -12,6 +14,95 @@ import ( "golang.org/x/crypto/ocsp" ) +// Nonce OID: id-pkix-ocsp-nonce (1.3.6.1.5.5.7.48.1.2) +const nonceOID = "1.3.6.1.5.5.7.48.1.2" + +// NonceOptions configures nonce in OCSP requests (RFC 9654). +type NonceOptions struct { + Length int // Length of nonce to generate (default 32, per RFC 9654) + Value string // Custom nonce value in hex format (optional) + Disabled bool // Disable nonce in requests + Hash string // Hash algorithm for CertID: "sha1" or "sha256" (default) +} + +// encodeOctetString wraps content in OCTET STRING tag (04). +func encodeOctetString(content []byte) []byte { + return encodeTagged(0x04, content) +} + +// encodeContextTagged wraps content in context-specific tag. +func encodeContextTagged(tag int, content []byte) []byte { + return encodeTagged(byte(0xA0|tag), content) // context-specific, constructed +} + +// encodeTagged wraps content with tag and length. +func encodeTagged(tag byte, content []byte) []byte { + length := len(content) + var result []byte + result = append(result, tag) + if length < 128 { + result = append(result, byte(length)) + } else { + // Long form length encoding + lenBytes := encodeLengthBytes(length) + result = append(result, byte(0x80|len(lenBytes))) + result = append(result, lenBytes...) + } + result = append(result, content...) + return result +} + +// encodeLengthBytes encodes length as bytes for long form. +func encodeLengthBytes(length int) []byte { + var result []byte + for length > 0 { + result = append([]byte{byte(length & 0xFF)}, result...) + length >>= 8 + } + return result +} + +// encodeOID encodes an OID string like "1.3.6.1.5.5.7.48.1.2" to DER bytes. +func encodeOID(oid string) []byte { + // Pre-encoded nonce OID: 06 09 2B 06 01 05 05 07 30 01 02 + // OID 1.3.6.1.5.5.7.48.1.2 + return []byte{0x06, 0x09, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01, 0x02} +} + +// encodeSequence wraps content in SEQUENCE tag (30). +func encodeSequence(content []byte) []byte { + return encodeTagged(0x30, content) +} + +// generateNonce generates a random nonce of specified length. +func generateNonce(length int) ([]byte, error) { + nonce := make([]byte, length) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + return nonce, nil +} + +// parseNonceHex parses a hex string to bytes. +func parseNonceHex(hexValue string) ([]byte, error) { + return hex.DecodeString(hexValue) +} + +// FetchResult contains OCSP response and request debug info. +type FetchResult struct { + Response *ocsp.Response + RequestInfo *RequestInfo +} + +// RequestInfo contains OCSP request debug information. +type RequestInfo struct { + Nonce []byte // Nonce sent in request (nil if no nonce) + NonceHex string // Hex representation of nonce + NonceLen int // Length of nonce in request (0 if no nonce) + RequestLen int // Length of raw OCSP request bytes + HashAlgorithm string // Hash algorithm used for CertID (e.g., "SHA256") +} + // GetOCSPURLFromCert extracts OCSP URL from certificate's AIA extension. // Returns empty string if no OCSP URL is present. func GetOCSPURLFromCert(cert *x509.Certificate) string { @@ -25,7 +116,8 @@ func GetOCSPURLFromCert(cert *x509.Certificate) string { // Requires issuer certificate to compute IssuerNameHash and IssuerKeyHash per RFC 6960. // The response is parsed but signature is not verified (nil issuer passed to ParseResponse). // Signature verification should be done by operators (ocspValid operator). -func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration) (*ocsp.Response, error) { +// nonceOpts configures nonce extension in request (RFC 9654) and hash algorithm for CertID. +func FetchOCSPWithInfo(cert, issuer *x509.Certificate, url string, timeout time.Duration, nonceOpts *NonceOptions) (*FetchResult, error) { if cert == nil { return nil, fmt.Errorf("certificate is required") } @@ -36,14 +128,72 @@ func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration return nil, fmt.Errorf("OCSP URL is required") } + // Determine hash algorithm for CertID + var hashAlgorithm crypto.Hash + var hashName string + if nonceOpts != nil && nonceOpts.Hash == "sha1" { + hashAlgorithm = crypto.SHA1 + hashName = "SHA1" + } else { + hashAlgorithm = crypto.SHA256 // Default, modern and more secure + hashName = "SHA256" + } + // Create OCSP request req, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{ - Hash: crypto.SHA256, // Use SHA256 for issuer hash + Hash: hashAlgorithm, }) if err != nil { return nil, fmt.Errorf("failed to create OCSP request: %w", err) } + // Track request info + reqInfo := &RequestInfo{ + RequestLen: len(req), + HashAlgorithm: hashName, + } + + // Add nonce extension if configured + if nonceOpts != nil && !nonceOpts.Disabled { + var nonce []byte + if nonceOpts.Value != "" { + // Use custom nonce value + nonce, err = parseNonceHex(nonceOpts.Value) + if err != nil { + return nil, fmt.Errorf("invalid nonce hex value: %w", err) + } + } else { + // Generate random nonce + length := nonceOpts.Length + if length <= 0 { + length = 32 // Default per RFC 9654 + } + if length < 1 || length > 128 { + return nil, fmt.Errorf("nonce length must be 1-128 bytes (RFC 9654)") + } + nonce, err = generateNonce(length) + if err != nil { + return nil, err + } + } + + // The request from ocsp.CreateRequest is the full OCSPRequest bytes. + // We need to extract TBSRequest and add nonce extension. + // OCSPRequest ::= SEQUENCE { tbsRequest TBSRequest, optionalSignature [0] Signature OPTIONAL } + // The request bytes are the full OCSPRequest SEQUENCE. + // We decode the SEQUENCE to get TBSRequest content. + req, err = addNonceToOCSPRequest(req, nonce) + if err != nil { + return nil, fmt.Errorf("failed to add nonce extension: %w", err) + } + + // Update request info with nonce details + reqInfo.Nonce = nonce + reqInfo.NonceHex = hex.EncodeToString(nonce) + reqInfo.NonceLen = len(nonce) + reqInfo.RequestLen = len(req) + } + // Send HTTP POST request client := &http.Client{ Timeout: timeout, @@ -77,13 +227,87 @@ func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration return nil, fmt.Errorf("failed to parse OCSP response: %w", err) } - return resp, nil + return &FetchResult{ + Response: resp, + RequestInfo: reqInfo, + }, nil +} + +// FetchOCSP is the legacy function that returns just the response. +// Deprecated: Use FetchOCSPWithInfo for debug info support. +func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration, nonceOpts *NonceOptions) (*ocsp.Response, error) { + result, err := FetchOCSPWithInfo(cert, issuer, url, timeout, nonceOpts) + if err != nil { + return nil, err + } + return result.Response, nil +} + +// addNonceToOCSPRequest adds nonce extension to an OCSP request. +// The OCSPRequest is SEQUENCE { TBSRequest, [0] optionalSignature } +// We extract TBSRequest content, add nonce extension, and rebuild with correct length. +func addNonceToOCSPRequest(ocspRequest []byte, nonce []byte) ([]byte, error) { + // Decode OCSPRequest SEQUENCE + if len(ocspRequest) < 2 || ocspRequest[0] != 0x30 { + return nil, fmt.Errorf("invalid OCSP request: expected SEQUENCE tag") + } + + // Parse OCSPRequest SEQUENCE length + ocspLen, contentStart := parseDERLength(ocspRequest, 1) + if contentStart+ocspLen > len(ocspRequest) { + return nil, fmt.Errorf("invalid OCSP request: length mismatch") + } + + // TBSRequest is the first element in OCSPRequest + tbsBytes := ocspRequest[contentStart:contentStart+ocspLen] + + // Verify TBSRequest is SEQUENCE + if len(tbsBytes) < 2 || tbsBytes[0] != 0x30 { + return nil, fmt.Errorf("invalid TBSRequest: expected SEQUENCE tag") + } + + // Extract TBSRequest content (after tag and length) + tbsLen, tbsContentStart := parseDERLength(tbsBytes, 1) + tbsContent := tbsBytes[tbsContentStart:tbsContentStart+tbsLen] + + // Build nonce extension + nonceOctetString := encodeOctetString(nonce) + extensionContent := append(encodeOID(nonceOID), nonceOctetString...) + extension := encodeSequence(extensionContent) + extensions := encodeSequence(extension) + requestExtensions := encodeContextTagged(2, extensions) + + // Append nonce extension to TBSRequest content + newTbsContent := append(tbsContent, requestExtensions...) + + // Rebuild TBSRequest with correct length + newTbsRequest := encodeSequence(newTbsContent) + + // Rebuild OCSPRequest as SEQUENCE { TBSRequest } + return encodeSequence(newTbsRequest), nil +} + +// parseDERLength parses DER length encoding starting at pos. +// Returns the length value and the start position of content. +func parseDERLength(data []byte, pos int) (int, int) { + if data[pos] < 128 { + // Short form: length in single byte + return int(data[pos]), pos + 1 + } + // Long form: length in following bytes + lenBytes := int(data[pos] & 0x7F) + length := 0 + for i := 0; i < lenBytes; i++ { + length = (length << 8) | int(data[pos+1+i]) + } + return length, pos + 1 + lenBytes } // FetchOCSPFromChain automatically fetches OCSP response for the leaf certificate. // Uses the first OCSP URL from leaf cert's AIA extension and the issuer from chain. // Returns nil if no OCSP URL is present or chain is insufficient. -func FetchOCSPFromChain(chain []*x509.Certificate, timeout time.Duration) (*ocsp.Response, string, error) { +// nonceOpts configures nonce extension in request (RFC 9654). +func FetchOCSPFromChainWithInfo(chain []*x509.Certificate, timeout time.Duration, nonceOpts *NonceOptions) (*FetchResult, string, error) { if len(chain) < 2 { return nil, "", fmt.Errorf("chain must have at least 2 certificates (leaf + issuer)") } @@ -96,10 +320,23 @@ func FetchOCSPFromChain(chain []*x509.Certificate, timeout time.Duration) (*ocsp return nil, "", nil // No OCSP URL, not an error } - resp, err := FetchOCSP(leaf, issuer, url, timeout) + result, err := FetchOCSPWithInfo(leaf, issuer, url, timeout, nonceOpts) if err != nil { return nil, url, err } - return resp, url, nil + return result, url, nil +} + +// FetchOCSPFromChain is the legacy function. +// Deprecated: Use FetchOCSPFromChainWithInfo for debug info support. +func FetchOCSPFromChain(chain []*x509.Certificate, timeout time.Duration, nonceOpts *NonceOptions) (*ocsp.Response, string, error) { + result, url, err := FetchOCSPFromChainWithInfo(chain, timeout, nonceOpts) + if err != nil { + return nil, url, err + } + if result == nil { + return nil, url, nil + } + return result.Response, url, nil } \ No newline at end of file diff --git a/internal/ocsp/fetcher_test.go b/internal/ocsp/fetcher_test.go index 1ffe1bf..b1c1a6b 100644 --- a/internal/ocsp/fetcher_test.go +++ b/internal/ocsp/fetcher_test.go @@ -1,6 +1,7 @@ package ocsp import ( + "bytes" "crypto/x509" "testing" ) @@ -54,21 +55,21 @@ func TestGetOCSPURLFromCert_MultipleOCSPServers(t *testing.T) { } func TestFetchOCSP_NilCert(t *testing.T) { - _, err := FetchOCSP(nil, &x509.Certificate{}, "http://example.com", 5) + _, err := FetchOCSP(nil, &x509.Certificate{}, "http://example.com", 5, nil) if err == nil { t.Error("Expected error for nil cert") } } func TestFetchOCSP_NilIssuer(t *testing.T) { - _, err := FetchOCSP(&x509.Certificate{}, nil, "http://example.com", 5) + _, err := FetchOCSP(&x509.Certificate{}, nil, "http://example.com", 5, nil) if err == nil { t.Error("Expected error for nil issuer") } } func TestFetchOCSP_EmptyURL(t *testing.T) { - _, err := FetchOCSP(&x509.Certificate{}, &x509.Certificate{}, "", 5) + _, err := FetchOCSP(&x509.Certificate{}, &x509.Certificate{}, "", 5, nil) if err == nil { t.Error("Expected error for empty URL") } @@ -76,7 +77,7 @@ func TestFetchOCSP_EmptyURL(t *testing.T) { func TestFetchOCSPFromChain_TooShort(t *testing.T) { chain := []*x509.Certificate{&x509.Certificate{}} - _, _, err := FetchOCSPFromChain(chain, 5) + _, _, err := FetchOCSPFromChain(chain, 5, nil) if err == nil { t.Error("Expected error for chain with less than 2 certificates") } @@ -84,7 +85,7 @@ func TestFetchOCSPFromChain_TooShort(t *testing.T) { func TestFetchOCSPFromChain_EmptyChain(t *testing.T) { chain := []*x509.Certificate{} - _, _, err := FetchOCSPFromChain(chain, 5) + _, _, err := FetchOCSPFromChain(chain, 5, nil) if err == nil { t.Error("Expected error for empty chain") } @@ -95,7 +96,7 @@ func TestFetchOCSPFromChain_NoOCSPURL(t *testing.T) { &x509.Certificate{OCSPServer: nil}, &x509.Certificate{}, } - resp, url, err := FetchOCSPFromChain(chain, 5) + resp, url, err := FetchOCSPFromChain(chain, 5, nil) if err != nil { t.Errorf("Expected no error for cert without OCSP URL, got %v", err) } @@ -105,4 +106,61 @@ func TestFetchOCSPFromChain_NoOCSPURL(t *testing.T) { if url != "" { t.Errorf("Expected empty URL for cert without OCSP URL, got %s", url) } +} + +func TestNonceOptions_GenerateNonce(t *testing.T) { + nonce, err := generateNonce(32) + if err != nil { + t.Errorf("Failed to generate nonce: %v", err) + } + if len(nonce) != 32 { + t.Errorf("Expected nonce length 32, got %d", len(nonce)) + } +} + +func TestNonceOptions_ParseNonceHex(t *testing.T) { + hexValue := "aabbccdd12345678" + nonce, err := parseNonceHex(hexValue) + if err != nil { + t.Errorf("Failed to parse nonce hex: %v", err) + } + expected := []byte{0xaa, 0xbb, 0xcc, 0xdd, 0x12, 0x34, 0x56, 0x78} + if !bytes.Equal(nonce, expected) { + t.Errorf("Expected %v, got %v", expected, nonce) + } +} + +func TestAddNonceExtension(t *testing.T) { + // Create a minimal OCSPRequest for testing + // OCSPRequest ::= SEQUENCE { TBSRequest } + // TBSRequest ::= SEQUENCE { requestList } + // requestList ::= SEQUENCE OF Request + + // Minimal TBSRequest content (just requestList) + requestList := encodeSequence(encodeSequence([]byte{})) + tbsRequest := encodeSequence(requestList) + ocspRequest := encodeSequence(tbsRequest) + + nonce := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + + result, err := addNonceToOCSPRequest(ocspRequest, nonce) + if err != nil { + t.Errorf("Failed to add nonce extension: %v", err) + } + + // Verify the result is valid DER + if len(result) < len(ocspRequest)+10 { + t.Errorf("Result too short, nonce extension may not be added properly") + } + + // Check that result starts with SEQUENCE tag (0x30) + if result[0] != 0x30 { + t.Errorf("Expected SEQUENCE tag (0x30), got 0x%02x", result[0]) + } + + // Verify it can be parsed back + _, contentStart := parseDERLength(result, 1) + if contentStart >= len(result) { + t.Errorf("Invalid result structure") + } } \ No newline at end of file diff --git a/internal/ocsp/ocsp.go b/internal/ocsp/ocsp.go index beddf4b..f36f42a 100644 --- a/internal/ocsp/ocsp.go +++ b/internal/ocsp/ocsp.go @@ -16,6 +16,13 @@ type Info struct { Response *ocsp.Response FilePath string Hash string + + // Request debug info (populated when auto-fetching) + RequestNonce []byte // Nonce sent in request + RequestNonceHex string // Hex representation of nonce + RequestNonceLen int // Length of nonce in request + RequestRawLen int // Length of raw OCSP request bytes + RequestHashAlgorithm string // Hash algorithm used for CertID (e.g., "SHA256") } func ParseOCSP(data []byte) (*ocsp.Response, error) { diff --git a/internal/ocsp/zcrypto/builder.go b/internal/ocsp/zcrypto/builder.go index 62cdd8d..655c404 100644 --- a/internal/ocsp/zcrypto/builder.go +++ b/internal/ocsp/zcrypto/builder.go @@ -69,6 +69,20 @@ func buildOCSP(resp *ocsp.Response) *node.Node { root.Children["extensions"] = buildExtensions(resp.Extensions) } + // Nonce extension (RFC 9654) + // Parse nonce from responseExtensions (inside TBSResponseData), NOT from singleExtensions. + // The nonce is in responseExtensions, which are NOT exposed by golang.org/x/crypto/ocsp. + // We parse it directly from the raw OCSP response. + nonce := ParseNonceFromRaw(resp.Raw) + nonceNode := node.New("nonce", nil) + nonceNode.Children["present"] = node.New("present", nonce.Present) + if nonce.Present { + nonceNode.Children["value"] = node.New("value", nonce.Value) + nonceNode.Children["length"] = node.New("length", nonce.Length) + nonceNode.Children["hexValue"] = node.New("hexValue", nonce.HexValue) + } + root.Children["nonce"] = nonceNode + return root } @@ -90,14 +104,24 @@ func buildSignatureAlgorithm(algo x509.SignatureAlgorithm, params asn1.ParamsSta n := node.New("signatureAlgorithm", nil) n.Children["algorithm"] = node.New("algorithm", algo.String()) n.Children["oid"] = node.New("oid", params.OID) - n.Children["parameters"] = buildAlgorithmIDParams(params) + paramNode := buildAlgorithmIDParams(params) + if paramNode != nil { + n.Children["parameters"] = paramNode + } return n } func buildAlgorithmIDParams(params asn1.ParamsState) *node.Node { + // If parameters are absent, do NOT create a node. + // This allows the `absent` operator to work correctly. + if params.IsAbsent { + return nil + } + + // If parameters are NULL, create node with null=true. + // This allows the `isNull` operator to work correctly. n := node.New("parameters", nil) n.Children["null"] = node.New("null", params.IsNull) - n.Children["absent"] = node.New("absent", params.IsAbsent) if params.PSS != nil { n.Children["pss"] = buildPSSParams(params.PSS) @@ -124,8 +148,9 @@ func buildPSSParams(pss *asn1.PSSParams) *node.Node { func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { n := node.New("algorithm", nil) n.Children["oid"] = node.New("oid", algo.OID) - if algo.Params.OID != "" { - n.Children["parameters"] = buildAlgorithmIDParams(algo.Params) + paramNode := buildAlgorithmIDParams(algo.Params) + if paramNode != nil { + n.Children["parameters"] = paramNode } return n } diff --git a/internal/ocsp/zcrypto/parser.go b/internal/ocsp/zcrypto/parser.go index e4882b3..8b86e67 100644 --- a/internal/ocsp/zcrypto/parser.go +++ b/internal/ocsp/zcrypto/parser.go @@ -1,12 +1,236 @@ package zcrypto import ( + "encoding/hex" + "golang.org/x/crypto/cryptobyte" cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" "github.com/cavoq/PCL/internal/asn1" ) +// NonceState represents the parsed nonce extension. +type NonceState struct { + Present bool + Value []byte + Length int + HexValue string +} + +// Nonce OID: id-pkix-ocsp-nonce (1.3.6.1.5.5.7.48.1.2) +const nonceOID = "1.3.6.1.5.5.7.48.1.2" + +// ParseNonceFromRaw extracts the nonce from OCSP responseExtensions. +// The nonce is in responseExtensions (inside TBSResponseData), NOT in singleExtensions. +// Returns NonceState with Present=false if nonce not found. +func ParseNonceFromRaw(rawOCSP []byte) NonceState { + result := NonceState{Present: false} + + input := cryptobyte.String(rawOCSP) + + // Read OCSPResponse SEQUENCE + var ocspResp cryptobyte.String + if !input.ReadASN1(&ocspResp, cryptobyte_asn1.SEQUENCE) { + return result + } + + // Read ResponseStatus (ENUMERATED) + var status cryptobyte.String + if !ocspResp.ReadASN1(&status, cryptobyte_asn1.ENUM) { + return result + } + + // Read ResponseBytes (context-specific [0] EXPLICIT) + var responseBytesOuter cryptobyte.String + if !ocspResp.ReadASN1(&responseBytesOuter, cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) { + return result + } + + // Inside the [0] wrapper, read ResponseBytes SEQUENCE + var responseBytes cryptobyte.String + if !responseBytesOuter.ReadASN1(&responseBytes, cryptobyte_asn1.SEQUENCE) { + return result + } + + // Skip responseType OID + var responseType cryptobyte.String + if !responseBytes.ReadASN1(&responseType, cryptobyte_asn1.OBJECT_IDENTIFIER) { + return result + } + + // Read response OCTET STRING (contains BasicOCSPResponse) + var responseOctet cryptobyte.String + if !responseBytes.ReadASN1(&responseOctet, cryptobyte_asn1.OCTET_STRING) { + return result + } + + // Parse BasicOCSPResponse from OCTET STRING + var basicResp cryptobyte.String + if !responseOctet.ReadASN1(&basicResp, cryptobyte_asn1.SEQUENCE) { + return result + } + + // Parse TBSResponseData SEQUENCE + var tbsResp cryptobyte.String + if !basicResp.ReadASN1(&tbsResp, cryptobyte_asn1.SEQUENCE) { + return result + } + + // Now we need to parse TBSResponseData to find responseExtensions [1] + // TBSResponseData structure: + // version [0] EXPLICIT OPTIONAL (INTEGER) + // responderID CHOICE + // producedAt GeneralizedTime + // responses SEQUENCE OF SingleResponse + // responseExtensions [1] EXPLICIT OPTIONAL + + // Skip version [0] if present + tbsResp.SkipOptionalASN1(cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) + + // Skip responderID (either byName [1] or byKey [2]) + var responderIDTag cryptobyte_asn1.Tag + var responderID cryptobyte.String + if !tbsResp.ReadAnyASN1(&responderID, &responderIDTag) { + return result + } + + // Skip producedAt (GeneralizedTime) + var producedAt cryptobyte.String + if !tbsResp.ReadASN1(&producedAt, cryptobyte_asn1.GeneralizedTime) { + return result + } + + // Skip responses SEQUENCE OF SingleResponse + var responses cryptobyte.String + if !tbsResp.ReadASN1(&responses, cryptobyte_asn1.SEQUENCE) { + return result + } + + // Now look for responseExtensions [1] EXPLICIT + var extensionsOuter cryptobyte.String + if !tbsResp.ReadASN1(&extensionsOuter, cryptobyte_asn1.Tag(1).ContextSpecific().Constructed()) { + // No responseExtensions present + return result + } + + // Inside [1] wrapper, read extensions SEQUENCE + var extensions cryptobyte.String + if !extensionsOuter.ReadASN1(&extensions, cryptobyte_asn1.SEQUENCE) { + return result + } + + // Iterate through extensions looking for nonce OID + for !extensions.Empty() { + var ext cryptobyte.String + if !extensions.ReadASN1(&ext, cryptobyte_asn1.SEQUENCE) { + break + } + + // Read OID + var oid cryptobyte.String + if !ext.ReadASN1(&oid, cryptobyte_asn1.OBJECT_IDENTIFIER) { + break + } + + oidStr := oidString(oid) + if oidStr != nonceOID { + continue + } + + // Found nonce extension + // Skip critical flag (optional BOOLEAN) + ext.SkipOptionalASN1(cryptobyte_asn1.BOOLEAN) + + // Read extnValue (OCTET STRING containing the nonce) + var extnValue cryptobyte.String + if !ext.ReadASN1(&extnValue, cryptobyte_asn1.OCTET_STRING) { + break + } + + // The nonce itself is an OCTET STRING inside extnValue + var nonceValue cryptobyte.String + if extnValue.ReadASN1(&nonceValue, cryptobyte_asn1.OCTET_STRING) { + result.Present = true + result.Value = []byte(nonceValue) + result.Length = len(result.Value) + result.HexValue = hex.EncodeToString(result.Value) + } else { + // Fallback: treat extnValue as the nonce directly + result.Present = true + result.Value = []byte(extnValue) + result.Length = len(result.Value) + result.HexValue = hex.EncodeToString(result.Value) + } + + return result + } + + return result +} + +// oidString converts cryptobyte.String OID to dotted string format. +func oidString(oid cryptobyte.String) string { + var components []int + + // First two components are encoded specially: first*40 + second + var firstByte uint8 + if !oid.ReadUint8(&firstByte) { + return "" + } + components = append(components, int(firstByte)/40) + components = append(components, int(firstByte)%40) + + // Remaining components use base128 encoding + for !oid.Empty() { + var val int + if !readBase128Int(&oid, &val) { + break + } + components = append(components, val) + } + + // Build dotted string + result := "" + for i, comp := range components { + if i > 0 { + result += "." + } + result += intToString(comp) + } + return result +} + +// readBase128Int reads a base128-encoded integer from cryptobyte.String. +func readBase128Int(s *cryptobyte.String, out *int) bool { + var val int + var b uint8 + for { + if !s.ReadUint8(&b) { + return false + } + val <<= 7 + val |= int(b & 0x7f) + if b&0x80 == 0 { + break + } + } + *out = val + return true +} + +// intToString converts int to string without importing strconv. +func intToString(n int) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + // ParseOCSPSignatureAlgorithmParams parses the signatureAlgorithm // from an OCSP response and returns the parameters state. // OCSP structure: OCSPResponse -> responseBytes -> BasicOCSPResponse -> signatureAlgorithm diff --git a/internal/ocsp/zcrypto/parser_test.go b/internal/ocsp/zcrypto/parser_test.go new file mode 100644 index 0000000..87a3d6b --- /dev/null +++ b/internal/ocsp/zcrypto/parser_test.go @@ -0,0 +1,27 @@ +package zcrypto + +import ( + "testing" +) + +func TestParseNonceFromRaw(t *testing.T) { + // Test with empty input + result := ParseNonceFromRaw([]byte{}) + if result.Present { + t.Error("Expected nonce.Present=false for empty input") + } + + // Test with invalid ASN.1 + result = ParseNonceFromRaw([]byte{0x00, 0x01, 0x02}) + if result.Present { + t.Error("Expected nonce.Present=false for invalid ASN.1") + } +} + +func TestNonceOIDString(t *testing.T) { + // Test OID string conversion + expected := "1.3.6.1.5.5.7.48.1.2" + if nonceOID != expected { + t.Errorf("Expected nonceOID=%s, got %s", expected, nonceOID) + } +} \ No newline at end of file diff --git a/internal/output/text.go b/internal/output/text.go index f3bd645..15c72e6 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -168,7 +168,8 @@ func writeRulesTable(w io.Writer, results []rule.Result, passCount, failCount, s if rr.Reference != "" { showReference = true } - if rr.Severity == "warning" || rr.Severity == "info" { + // Show severity column if any rule has a severity defined (not empty) + if rr.Severity != "" { showSeverity = true } } From 263bd31b7bd0f4b5a0c306700f21dd86e1421d76 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Wed, 29 Apr 2026 23:53:16 +0800 Subject: [PATCH 12/44] docs: update README with RFC 9654 nonce support and new policies --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 43d2479..07cad18 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,37 @@ This mode: Options for granular control: ```bash +# Limit chain depth pcl --policy --cert leaf.pem --auto-validate --max-chain-depth 5 + +# Disable specific auto-fetch features +pcl --policy --cert leaf.pem --auto-validate --no-auto-chain pcl --policy --cert leaf.pem --auto-validate --no-auto-crl pcl --policy --cert leaf.pem --auto-validate --no-auto-ocsp + +# OCSP nonce configuration (RFC 9654) +pcl --policy --cert leaf.pem --auto-validate --ocsp-nonce-length 32 +pcl --policy --cert leaf.pem --auto-validate --ocsp-nonce-value aabbcc... +pcl --policy --cert leaf.pem --auto-validate --no-ocsp-nonce + +# OCSP CertID hash algorithm (RFC 5019 vs modern) +pcl --policy --cert leaf.pem --auto-validate --ocsp-hash sha1 # RFC 5019 +pcl --policy --cert leaf.pem --auto-validate --ocsp-hash sha256 # Modern (default) ``` +### Debug OCSP Requests + +Use `-vv` to see detailed OCSP request/response information: +```bash +pcl --policy policies/RFC9654.yaml --cert leaf.pem --auto-validate -vv +``` + +Output includes: +- Request nonce length and hex value +- CertID hash algorithm (SHA1/SHA256) +- Response nonce and match status +- OCSP response timing details + ### Fetch TLS Certificates from URLs Fetch certificate chains from HTTPS endpoints: @@ -169,7 +195,15 @@ rules: - [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) - Internet X.509 Public Key Infrastructure Certificate and CRL Profile - [RFC 4055](https://datatracker.ietf.org/doc/html/rfc4055) - Additional Algorithms and Identifiers for RSA Cryptography +- [RFC 5480](https://datatracker.ietf.org/doc/html/rfc5480) - Elliptic Curve Cryptography SubjectPublicKeyInfo Format +- [RFC 5758](https://datatracker.ietf.org/doc/html/rfc5758) - DSA and ECDSA with SHA2 +- [RFC 5759](https://datatracker.ietf.org/doc/html/rfc5759) - Suite B Certificate and CRL Profile - [RFC 6960](https://datatracker.ietf.org/doc/html/rfc6960) - Online Certificate Status Protocol (OCSP) +- [RFC 8813](https://datatracker.ietf.org/doc/html/rfc8813) - Updates to RFC 5480 +- [RFC 5019](https://datatracker.ietf.org/doc/html/rfc5019) - Lightweight OCSP Profile for High-Volume Environments +- [RFC 9608](https://datatracker.ietf.org/doc/html/rfc9608) - No Revocation Available for X.509 Certificates +- [RFC 9654](https://datatracker.ietf.org/doc/html/rfc9654) - OCSP Nonce Extension +- CA/Browser Forum Baseline Requirements ## ➕ Supported Operators @@ -425,7 +459,13 @@ ocsp ├── thisUpdate # time.Time ├── nextUpdate # time.Time ├── revocationReason # Integer (if revoked) -└── signatureAlgorithm # Same as certificate +├── signatureAlgorithm # Same as certificate +├── nonce # RFC 9654 nonce extension +│ ├── present # Boolean +│ ├── value # []byte (raw nonce) +│ ├── length # Integer (bytes) +│ └── hexValue # String (hex representation) +└── responderID # Responder identification ``` ## 🔧 Development From e87739c9c272ae93b155890cdb221a2bf85e2112 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 00:07:21 +0800 Subject: [PATCH 13/44] feat: enhance certificate type detection and add validation operators Certificate Type Detection: - Check BasicConstraints at position 0 to correctly identify CA certificates - Add "root" and "intermediate" as built-in certTypes in filter - Support appliesTo: [root], [intermediate], [leaf] in policy rules New Operators: - every: generic array iteration with sub-path and check operator - dateDiff: date difference validation with maxDays/maxMonths limits - time operators: UTCTime/GeneralizedTime format validation - encoding operators: IA5String/PrintableString/UTF8String validation - unique operators: UniqueValues/UniqueChildren for AIA/CRL DP - component operators: ComponentMaxLength/ComponentMinLength/ComponentRegex - subject operators: NoDuplicateAttributes for DN validation - utf8 operators: UTF8NoBOM/ContainsBOM ASN.1 Support: - Add internal/asn1/encoding.go for time and encoding validation - Parse validity encoding and subject DN encoding in parser - Build encoding info in node tree for time fields CRL Enhancements: - Add BuildTreeWithChain for CRL type detection (isCACRL field) - Support CRL type differentiation (subscriber vs CA CRL) --- cmd/pcl/main.go | 4 +- internal/asn1/encoding.go | 259 +++++++++++++++ internal/cert/cert.go | 8 + internal/cert/chain_test.go | 5 +- internal/cert/zcrypto/builder.go | 89 ++++- internal/cert/zcrypto/parser.go | 321 ++++++++++++++++++ internal/cert/zcrypto/sct_test.go | 92 ++++++ internal/crl/zcrypto/builder.go | 53 ++- internal/linter/config.go | 2 +- internal/linter/filter.go | 27 +- internal/linter/runner.go | 47 ++- internal/operator/compare.go | 4 + internal/operator/component.go | 265 +++++++++++++++ internal/operator/component_test.go | 253 ++++++++++++++ internal/operator/context.go | 28 ++ internal/operator/crl.go | 53 ++- internal/operator/date.go | 141 ++++++++ internal/operator/date_test.go | 173 ++++++++++ internal/operator/encoding.go | 213 ++++++++++++ internal/operator/encoding_test.go | 474 +++++++++++++++++++++++++++ internal/operator/equality.go | 36 +- internal/operator/every.go | 205 ++++++++++++ internal/operator/every_test.go | 323 ++++++++++++++++++ internal/operator/membership.go | 36 +- internal/operator/membership_test.go | 19 +- internal/operator/operator.go | 29 ++ internal/operator/regex.go | 15 + internal/operator/subject.go | 98 ++++++ internal/operator/subject_test.go | 185 +++++++++++ internal/operator/time.go | 172 ++++++++++ internal/operator/time_test.go | 332 +++++++++++++++++++ internal/operator/unique.go | 70 ++++ internal/operator/unique_test.go | 124 +++++++ internal/operator/utf8.go | 55 ++++ internal/operator/utf8_test.go | 144 ++++++++ internal/zcrypto/helpers.go | 27 ++ 36 files changed, 4298 insertions(+), 83 deletions(-) create mode 100644 internal/asn1/encoding.go create mode 100644 internal/cert/zcrypto/sct_test.go create mode 100644 internal/operator/component.go create mode 100644 internal/operator/component_test.go create mode 100644 internal/operator/encoding.go create mode 100644 internal/operator/encoding_test.go create mode 100644 internal/operator/every.go create mode 100644 internal/operator/every_test.go create mode 100644 internal/operator/subject.go create mode 100644 internal/operator/subject_test.go create mode 100644 internal/operator/time.go create mode 100644 internal/operator/time_test.go create mode 100644 internal/operator/unique.go create mode 100644 internal/operator/unique_test.go create mode 100644 internal/operator/utf8.go create mode 100644 internal/operator/utf8_test.go diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index 36e4e51..1d39c52 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -15,7 +15,7 @@ func newRootCmd(opts *linter.Config) *cobra.Command { Use: "pcl", Short: "Policy-based X.509 certificate linter", RunE: func(cmd *cobra.Command, args []string) error { - if opts.PolicyPath == "" { + if len(opts.PolicyPaths) == 0 { return fmt.Errorf("--policy is required") } hasCert := opts.CertPath != "" || len(opts.CertURLs) > 0 @@ -27,7 +27,7 @@ func newRootCmd(opts *linter.Config) *cobra.Command { }, } - root.Flags().StringVar(&opts.PolicyPath, "policy", "", "Path to policy YAML file or directory") + root.Flags().StringSliceVar(&opts.PolicyPaths, "policy", nil, "Path to policy YAML file or directory (repeatable)") root.Flags().StringVar(&opts.CertPath, "cert", "", "Path to certificate file or directory (PEM/DER)") root.Flags().StringSliceVar(&opts.CertURLs, "cert-url", nil, "Certificate URL (repeatable)") root.Flags().DurationVar(&opts.CertTimeout, "cert-url-timeout", 10*time.Second, "Certificate URL timeout (e.g. 10s, 1m)") diff --git a/internal/asn1/encoding.go b/internal/asn1/encoding.go new file mode 100644 index 0000000..2d54403 --- /dev/null +++ b/internal/asn1/encoding.go @@ -0,0 +1,259 @@ +package asn1 + +import ( + stdasn1 "encoding/asn1" + "fmt" + "strings" + "time" +) + +// TimeFormatInfo contains information about ASN.1 time encoding. +type TimeFormatInfo struct { + Tag int // ASN.1 tag: 23 for UTCTime, 24 for GeneralizedTime + Format string // Time format string + RawBytes []byte // Raw DER bytes of the time value + RawString string // Raw string representation from DER + IsUTC bool // true for UTCTime, false for GeneralizedTime + HasSeconds bool // whether seconds are present + HasFraction bool // whether fractional seconds are present + HasZulu bool // whether 'Z' suffix is present (required by RFC 5280) +} + +// ParseUTCTime parses UTCTime DER bytes and returns format info. +// UTCTime format: YYMMDDHHMMSSZ (RFC 5280 requires Z suffix) +// Tag: 23 (0x17) +func ParseUTCTime(derBytes []byte) (*TimeFormatInfo, error) { + info := &TimeFormatInfo{ + Tag: 23, + IsUTC: true, + RawBytes: derBytes, + } + + // Decode the raw string from DER + // DER encoding: tag (1 byte) + length + value + if len(derBytes) < 2 { + return nil, fmt.Errorf("invalid UTCTime: too short") + } + + tag := int(derBytes[0]) + if tag != 23 { + return nil, fmt.Errorf("invalid UTCTime tag: expected 23, got %d", tag) + } + + length := int(derBytes[1]) + if len(derBytes) < 2+length { + return nil, fmt.Errorf("invalid UTCTime: length mismatch") + } + + valueBytes := derBytes[2:2+length] + info.RawString = string(valueBytes) + + // Parse format characteristics + // Valid formats per RFC 5280: + // - YYMMDDHHMMSSZ (13 chars, must have Z) + // Seconds are required per RFC 5280 Section 4.1.2.5.1 + info.HasZulu = strings.HasSuffix(info.RawString, "Z") + info.HasSeconds = len(info.RawString) >= 12 // YYMMDDHHMMSS has 12 chars before Z + + // Parse the actual time to validate + var t time.Time + // Go's asn1 package can parse UTCTime + rest, err := stdasn1.UnmarshalWithParams(derBytes, &t, "utctime") + if err != nil { + return nil, fmt.Errorf("failed to parse UTCTime: %w", err) + } + if len(rest) > 0 { + return nil, fmt.Errorf("trailing data in UTCTime") + } + + return info, nil +} + +// ParseGeneralizedTime parses GeneralizedTime DER bytes and returns format info. +// GeneralizedTime format: YYYYMMDDHHMMSSZ or YYYYMMDDHHMMSS.fffZ +// Tag: 24 (0x18) +func ParseGeneralizedTime(derBytes []byte) (*TimeFormatInfo, error) { + info := &TimeFormatInfo{ + Tag: 24, + IsUTC: false, + RawBytes: derBytes, + HasSeconds: true, // GeneralizedTime always has seconds + } + + // Decode the raw string from DER + if len(derBytes) < 2 { + return nil, fmt.Errorf("invalid GeneralizedTime: too short") + } + + tag := int(derBytes[0]) + if tag != 24 { + return nil, fmt.Errorf("invalid GeneralizedTime tag: expected 24, got %d", tag) + } + + length := int(derBytes[1]) + if len(derBytes) < 2+length { + return nil, fmt.Errorf("invalid GeneralizedTime: length mismatch") + } + + valueBytes := derBytes[2:2+length] + info.RawString = string(valueBytes) + + // Parse format characteristics + info.HasZulu = strings.HasSuffix(info.RawString, "Z") + info.HasFraction = strings.Contains(info.RawString, ".") + + // Parse the actual time to validate + var t time.Time + // Go's asn1 package can parse GeneralizedTime + rest, err := stdasn1.UnmarshalWithParams(derBytes, &t, "generalized") + if err != nil { + return nil, fmt.Errorf("failed to parse GeneralizedTime: %w", err) + } + if len(rest) > 0 { + return nil, fmt.Errorf("trailing data in GeneralizedTime") + } + + return info, nil +} + +// EncodingType represents ASN.1 string encoding types. +type EncodingType int + +const ( + EncodingUnknown EncodingType = iota + EncodingIA5String + EncodingPrintableString + EncodingUTF8String + EncodingBMPString + EncodingUniversalString +) + +// EncodingInfo contains information about ASN.1 string encoding. +type EncodingInfo struct { + Type EncodingType + TagName string + RawBytes []byte + StringValue string + ValidChars bool // whether all characters are valid for the encoding type + InvalidChars []byte // characters that violate encoding rules +} + +// ValidateIA5String validates that a byte sequence conforms to IA5String encoding. +// IA5String is equivalent to ASCII (0x00-0x7F). +func ValidateIA5String(derBytes []byte) (*EncodingInfo, error) { + info := &EncodingInfo{ + Type: EncodingIA5String, + TagName: "IA5String", + RawBytes: derBytes, + ValidChars: true, + } + + // DER encoding: tag (1 byte) + length + value + if len(derBytes) < 2 { + return nil, fmt.Errorf("invalid IA5String: too short") + } + + tag := int(derBytes[0]) + if tag != 22 { // IA5String tag + return nil, fmt.Errorf("invalid IA5String tag: expected 22, got %d", tag) + } + + length := int(derBytes[1]) + if len(derBytes) < 2+length { + return nil, fmt.Errorf("invalid IA5String: length mismatch") + } + + valueBytes := derBytes[2:2+length] + info.StringValue = string(valueBytes) + + // Check each character is in ASCII range (0x00-0x7F) + for _, b := range valueBytes { + if b > 0x7F { + info.ValidChars = false + info.InvalidChars = append(info.InvalidChars, b) + } + } + + return info, nil +} + +// ValidatePrintableString validates that a byte sequence conforms to PrintableString encoding. +// PrintableString allows: A-Z, a-z, 0-9, space, '(),./:=?- and special chars +// Per RFC 5280 Appendix A.1: PrintableString character set +func ValidatePrintableString(derBytes []byte) (*EncodingInfo, error) { + info := &EncodingInfo{ + Type: EncodingPrintableString, + TagName: "PrintableString", + RawBytes: derBytes, + ValidChars: true, + } + + // DER encoding: tag (1 byte) + length + value + if len(derBytes) < 2 { + return nil, fmt.Errorf("invalid PrintableString: too short") + } + + tag := int(derBytes[0]) + if tag != 19 { // PrintableString tag + return nil, fmt.Errorf("invalid PrintableString tag: expected 19, got %d", tag) + } + + length := int(derBytes[1]) + if len(derBytes) < 2+length { + return nil, fmt.Errorf("invalid PrintableString: length mismatch") + } + + valueBytes := derBytes[2:2+length] + info.StringValue = string(valueBytes) + + // PrintableString valid characters per ASN.1: + // A-Z, a-z, 0-9, space, apostrophe, (, ), +, comma, -, ., /, :, =, ? + for _, b := range valueBytes { + if !isPrintableStringChar(b) { + info.ValidChars = false + info.InvalidChars = append(info.InvalidChars, b) + } + } + + return info, nil +} + +// isPrintableStringChar checks if a byte is valid in PrintableString. +func isPrintableStringChar(b byte) bool { + // Upper case letters + if b >= 'A' && b <= 'Z' { + return true + } + // Lower case letters + if b >= 'a' && b <= 'z' { + return true + } + // Digits + if b >= '0' && b <= '9' { + return true + } + // Special characters allowed in PrintableString + switch b { + case ' ', '\'', '(', ')', '+', ',', '-', '.', '/', ':', '=', '?', '&', '[', ']', '#', '@', '!', '"', '%', '*', ';', '<', '>', '_', '\\', '{', '}', '|', '~', '^': + return true + } + return false +} + +// GetEncodingType returns the encoding type from ASN.1 tag. +func GetEncodingType(tag int) EncodingType { + switch tag { + case 22: + return EncodingIA5String + case 19: + return EncodingPrintableString + case 12: + return EncodingUTF8String + case 30: + return EncodingBMPString + case 28: + return EncodingUniversalString + default: + return EncodingUnknown + } +} \ No newline at end of file diff --git a/internal/cert/cert.go b/internal/cert/cert.go index c425917..0bba888 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -41,9 +41,17 @@ func IsSelfSigned(cert *x509.Certificate) bool { } func GetCertType(cert *x509.Certificate, position, chainLen int) string { + // At position 0, check BasicConstraints to determine if it's actually a CA if position == 0 { + if cert.BasicConstraintsValid && cert.IsCA { + if IsSelfSigned(cert) { + return "root" + } + return "intermediate" + } return "leaf" } + // At other positions, check if it's root or intermediate if position == chainLen-1 && IsSelfSigned(cert) { return "root" } diff --git a/internal/cert/chain_test.go b/internal/cert/chain_test.go index 8104f01..40bfc81 100644 --- a/internal/cert/chain_test.go +++ b/internal/cert/chain_test.go @@ -72,8 +72,9 @@ func TestGetCertFiles_Directory(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if len(files) != 3 { - t.Errorf("expected 3 files, got %d", len(files)) + // Minimum 4 original files, plus generated test certificates + if len(files) < 4 { + t.Errorf("expected at least 4 files, got %d", len(files)) } } diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 68b4338..7c2b2ed 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -2,10 +2,14 @@ package zcrypto import ( "crypto/ecdsa" + "crypto/ed25519" "crypto/rsa" + "encoding/hex" "fmt" + "time" "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/ct" "github.com/cavoq/PCL/internal/asn1" "github.com/cavoq/PCL/internal/node" @@ -218,8 +222,33 @@ func buildNestedAlgorithmID(algo asn1.AlgorithmIdentifier) *node.Node { func buildValidity(cert *x509.Certificate) *node.Node { n := node.New("validity", nil) - n.Children["notBefore"] = node.New("notBefore", cert.NotBefore) - n.Children["notAfter"] = node.New("notAfter", cert.NotAfter) + + notBeforeNode := node.New("notBefore", cert.NotBefore) + notAfterNode := node.New("notAfter", cert.NotAfter) + + // Parse time encoding info from TBSCertificate + if len(cert.RawTBSCertificate) > 0 { + encodingInfo, err := ParseValidityEncoding(cert.RawTBSCertificate) + if err == nil && encodingInfo != nil { + if encodingInfo.NotBefore != nil { + notBeforeNode.Children["encoding"] = node.New("encoding", encodingInfo.NotBefore.Tag) + notBeforeNode.Children["format"] = node.New("format", encodingInfo.NotBefore.RawString) + notBeforeNode.Children["isUTC"] = node.New("isUTC", encodingInfo.NotBefore.IsUTC) + notBeforeNode.Children["hasSeconds"] = node.New("hasSeconds", encodingInfo.NotBefore.HasSeconds) + notBeforeNode.Children["hasZulu"] = node.New("hasZulu", encodingInfo.NotBefore.HasZulu) + } + if encodingInfo.NotAfter != nil { + notAfterNode.Children["encoding"] = node.New("encoding", encodingInfo.NotAfter.Tag) + notAfterNode.Children["format"] = node.New("format", encodingInfo.NotAfter.RawString) + notAfterNode.Children["isUTC"] = node.New("isUTC", encodingInfo.NotAfter.IsUTC) + notAfterNode.Children["hasSeconds"] = node.New("hasSeconds", encodingInfo.NotAfter.HasSeconds) + notAfterNode.Children["hasZulu"] = node.New("hasZulu", encodingInfo.NotAfter.HasZulu) + } + } + } + + n.Children["notBefore"] = notBeforeNode + n.Children["notAfter"] = notAfterNode return n } @@ -243,6 +272,8 @@ func buildSubjectPublicKeyInfo(cert *x509.Certificate) *node.Node { n.Children["publicKey"] = buildRSAKey(key) case *ecdsa.PublicKey: n.Children["publicKey"] = buildECDSAKey(key) + case ed25519.PublicKey: + n.Children["publicKey"] = buildEd25519Key(key) default: n.Children["publicKey"] = node.New("publicKey", cert.PublicKey) } @@ -380,10 +411,60 @@ func buildECDSAKey(key *ecdsa.PublicKey) *node.Node { return n } +func buildEd25519Key(key ed25519.PublicKey) *node.Node { + n := node.New("publicKey", nil) + n.Children["keySize"] = node.New("keySize", len(key)*8) // Ed25519 key size in bits + return n +} + func buildSCT(sct interface{}, index int) *node.Node { n := node.New(fmt.Sprintf("%d", index), nil) - // SCT contains: LogID, Timestamp, Extensions, Signature - // We store basic presence info; detailed validation in operators n.Children["present"] = node.New("present", true) + + // Try to cast to ct.SignedCertificateTimestamp + ctSCT, ok := sct.(*ct.SignedCertificateTimestamp) + if !ok { + // Fallback for unknown SCT type + return n + } + + // Version (V1=0 per RFC 6962/9162) + n.Children["version"] = node.New("version", int(ctSCT.SCTVersion)) + n.Children["versionString"] = node.New("versionString", ctSCT.SCTVersion.String()) + + // LogID - 32 bytes SHA-256 hash of log's public key + if len(ctSCT.LogID) == 32 { + n.Children["logID"] = node.New("logID", ctSCT.LogID[:]) + n.Children["logIDHex"] = node.New("logIDHex", hex.EncodeToString(ctSCT.LogID[:])) + } + + // Timestamp - milliseconds since Unix epoch + n.Children["timestamp"] = node.New("timestamp", ctSCT.Timestamp) + // Convert to time.Time for easier validation + timestampTime := time.Unix(0, int64(ctSCT.Timestamp)*int64(time.Millisecond)) + n.Children["timestampTime"] = node.New("timestampTime", timestampTime) + + // Extensions - optional + if len(ctSCT.Extensions) > 0 { + n.Children["extensions"] = node.New("extensions", ctSCT.Extensions) + n.Children["extensionsLen"] = node.New("extensionsLen", len(ctSCT.Extensions)) + } else { + n.Children["extensionsLen"] = node.New("extensionsLen", 0) + } + + // Signature - DigitallySigned structure + sigNode := node.New("signature", nil) + sigNode.Children["hashAlgorithm"] = node.New("hashAlgorithm", ctSCT.Signature.HashAlgorithm.String()) + sigNode.Children["hashAlgorithmValue"] = node.New("hashAlgorithmValue", int(ctSCT.Signature.HashAlgorithm)) + sigNode.Children["signatureAlgorithm"] = node.New("signatureAlgorithm", ctSCT.Signature.SignatureAlgorithm.String()) + sigNode.Children["signatureAlgorithmValue"] = node.New("signatureAlgorithmValue", int(ctSCT.Signature.SignatureAlgorithm)) + sigNode.Children["signatureValue"] = node.New("signatureValue", ctSCT.Signature.Signature) + sigNode.Children["signatureValueHex"] = node.New("signatureValueHex", hex.EncodeToString(ctSCT.Signature.Signature)) + n.Children["signature"] = sigNode + + // Combined signature algorithm string (e.g., "SHA256-ECDSA") + sigAlgStr := fmt.Sprintf("%s-%s", ctSCT.Signature.HashAlgorithm.String(), ctSCT.Signature.SignatureAlgorithm.String()) + n.Children["signatureAlgorithmString"] = node.New("signatureAlgorithmString", sigAlgStr) + return n } diff --git a/internal/cert/zcrypto/parser.go b/internal/cert/zcrypto/parser.go index eb5c8fc..cfc0f81 100644 --- a/internal/cert/zcrypto/parser.go +++ b/internal/cert/zcrypto/parser.go @@ -1,12 +1,170 @@ package zcrypto import ( + stdasn1 "encoding/asn1" + "fmt" + "time" + "golang.org/x/crypto/cryptobyte" cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" "github.com/cavoq/PCL/internal/asn1" ) +// TimeEncodingInfo contains ASN.1 time encoding details. +type TimeEncodingInfo struct { + NotBefore *asn1.TimeFormatInfo + NotAfter *asn1.TimeFormatInfo +} + +// ParseValidityEncoding parses the validity period from TBSCertificate +// and returns the ASN.1 encoding information. +func ParseValidityEncoding(rawTBSCertificate []byte) (*TimeEncodingInfo, error) { + input := cryptobyte.String(rawTBSCertificate) + + var tbsCert cryptobyte.String + if !input.ReadASN1(&tbsCert, cryptobyte_asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read TBSCertificate") + } + + // Skip version (optional, context-specific tag 0) + tbsCert.SkipOptionalASN1(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) + + // Skip serialNumber (INTEGER) + tbsCert.SkipASN1(cryptobyte_asn1.INTEGER) + + // Skip signature AlgorithmIdentifier + tbsCert.SkipASN1(cryptobyte_asn1.SEQUENCE) + + // Skip issuer Name + tbsCert.SkipASN1(cryptobyte_asn1.SEQUENCE) + + // Read validity (SEQUENCE containing notBefore and notAfter) + var validity cryptobyte.String + if !tbsCert.ReadASN1(&validity, cryptobyte_asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read validity") + } + + info := &TimeEncodingInfo{} + + // Read notBefore (UTCTime or GeneralizedTime) + var notBeforeDER cryptobyte.String + var notBeforeTag cryptobyte_asn1.Tag + if !validity.ReadAnyASN1Element(¬BeforeDER, ¬BeforeTag) { + return nil, fmt.Errorf("failed to read notBefore") + } + + notBeforeBytes := []byte(notBeforeDER) + switch int(notBeforeTag) { + case 23: // UtCTime + info.NotBefore, _ = asn1.ParseUTCTime(notBeforeBytes) + case 24: // GeneralizedTime + info.NotBefore, _ = asn1.ParseGeneralizedTime(notBeforeBytes) + } + + // Read notAfter (UTCTime or GeneralizedTime) + var notAfterDER cryptobyte.String + var notAfterTag cryptobyte_asn1.Tag + if !validity.ReadAnyASN1Element(¬AfterDER, ¬AfterTag) { + return nil, fmt.Errorf("failed to read notAfter") + } + + notAfterBytes := []byte(notAfterDER) + switch int(notAfterTag) { + case 23: // UTCTime + info.NotAfter, _ = asn1.ParseUTCTime(notAfterBytes) + case 24: // GeneralizedTime + info.NotAfter, _ = asn1.ParseGeneralizedTime(notAfterBytes) + } + + return info, nil +} + +// SubjectDNEncodingInfo contains encoding details for Subject DN attributes. +type SubjectDNEncodingInfo struct { + Attributes map[string]*asn1.EncodingInfo +} + +// ParseSubjectDNEncoding parses Subject DN and returns encoding info for each attribute. +func ParseSubjectDNEncoding(rawSubject []byte) (*SubjectDNEncodingInfo, error) { + input := cryptobyte.String(rawSubject) + + var name cryptobyte.String + if !input.ReadASN1(&name, cryptobyte_asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read Subject DN") + } + + info := &SubjectDNEncodingInfo{ + Attributes: make(map[string]*asn1.EncodingInfo), + } + + // Iterate through RelativeDistinguishedName components + for !name.Empty() { + var rdn cryptobyte.String + if !name.ReadASN1(&rdn, cryptobyte_asn1.SET) { + break + } + + // Iterate through AttributeTypeAndValue + for !rdn.Empty() { + var atv cryptobyte.String + if !rdn.ReadASN1(&atv, cryptobyte_asn1.SEQUENCE) { + break + } + + // Read OID + var oid cryptobyte.String + if !atv.ReadASN1(&oid, cryptobyte_asn1.OBJECT_IDENTIFIER) { + break + } + oidStr := oidString(oid) + + // Read value and its encoding tag + var valueDER cryptobyte.String + var valueTag cryptobyte_asn1.Tag + if !atv.ReadAnyASN1Element(&valueDER, &valueTag) { + break + } + + // Store encoding info based on tag type + valueBytes := []byte(valueDER) + encInfo, _ := parseAttributeValueEncoding(int(valueTag), valueBytes, oidStr) + if encInfo != nil { + info.Attributes[oidStr] = encInfo + } + } + } + + return info, nil +} + +// parseAttributeValueEncoding parses encoding info for a DN attribute value. +func parseAttributeValueEncoding(tag int, derBytes []byte, oid string) (*asn1.EncodingInfo, error) { + switch tag { + case 22: // IA5String + return asn1.ValidateIA5String(derBytes) + case 19: // PrintableString + return asn1.ValidatePrintableString(derBytes) + case 12: // UTF8String + // UTF8String is always valid for modern certificates + return &asn1.EncodingInfo{ + Type: asn1.EncodingUTF8String, + TagName: "UTF8String", + RawBytes: derBytes, + ValidChars: true, + }, nil + case 30: // BMPString + return &asn1.EncodingInfo{ + Type: asn1.EncodingBMPString, + TagName: "BMPString", + RawBytes: derBytes, + ValidChars: true, + }, nil + default: + return nil, fmt.Errorf("unknown encoding tag %d for OID %s", tag, oid) + } +} + // ParseTBSCertSignatureParams parses the signature AlgorithmIdentifier // from TBSCertificate and returns the parameters state. func ParseTBSCertSignatureParams(rawTBSCertificate []byte) asn1.ParamsState { @@ -81,4 +239,167 @@ func ParseSubjectPublicKeyInfoParams(rawSubjectPublicKeyInfo []byte) asn1.Params } return asn1.ParseAlgorithmIDParams(algoID) +} + +// oidString converts a cryptobyte OID to standard string format (e.g., "2.5.4.3") +func oidString(oid cryptobyte.String) string { + var components []int + + // First two components are encoded in first byte + var first byte + if !oid.ReadUint8(&first) { + return "" + } + components = append(components, int(first/40), int(first%40)) + + // Read remaining components (variable length encoding) + for !oid.Empty() { + var val int + if !readOIDComponent(&oid, &val) { + break + } + components = append(components, val) + } + + // Build string representation + result := "" + for i, c := range components { + if i > 0 { + result += "." + } + result += intToStr(c) + } + return result +} + +func readOIDComponent(oid *cryptobyte.String, val *int) bool { + var v int + for { + var b byte + if !oid.ReadUint8(&b) { + return false + } + v = (v << 7) | int(b&0x7f) + if b&0x80 == 0 { + break + } + } + *val = v + return true +} + +func intToStr(n int) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append(digits, byte('0'+n%10)) + n /= 10 + } + // Reverse digits + for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { + digits[i], digits[j] = digits[j], digits[i] + } + return string(digits) +} + +// TimeToGeneralizedTime converts a time.Time to ASN.1 GeneralizedTime DER bytes. +// This is used for testing and validation. +func TimeToGeneralizedTime(t time.Time) []byte { + // GeneralizedTime format: YYYYMMDDHHMMSSZ + str := t.Format("20060102150405") + "Z" + return encodeASN1String(24, str) +} + +// TimeToUTCTime converts a time.Time to ASN.1 UTCTime DER bytes. +// This is used for testing and validation. +func TimeToUTCTime(t time.Time) []byte { + // UTCTime format: YYMMDDHHMMSSZ + str := t.Format("060102150405") + "Z" + return encodeASN1String(23, str) +} + +// encodeASN1String creates a DER-encoded ASN.1 string. +func encodeASN1String(tag int, value string) []byte { + length := len(value) + result := []byte{byte(tag), byte(length)} + result = append(result, []byte(value)...) + return result +} + +// ParseRawCertificateTimes parses validity times from a raw certificate DER. +// Returns the parsed time values and their encoding formats. +func ParseRawCertificateTimes(rawCert []byte) (notBefore, notAfter time.Time, notBeforeTag, notAfterTag int, err error) { + input := cryptobyte.String(rawCert) + + var cert cryptobyte.String + if !input.ReadASN1(&cert, cryptobyte_asn1.SEQUENCE) { + return time.Time{}, time.Time{}, 0, 0, fmt.Errorf("failed to read certificate") + } + + // Read TBSCertificate + var tbs cryptobyte.String + if !cert.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { + return time.Time{}, time.Time{}, 0, 0, fmt.Errorf("failed to read TBSCertificate") + } + + // Skip version + tbs.SkipOptionalASN1(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) + // Skip serialNumber + tbs.SkipASN1(cryptobyte_asn1.INTEGER) + // Skip signature algorithm + tbs.SkipASN1(cryptobyte_asn1.SEQUENCE) + // Skip issuer + tbs.SkipASN1(cryptobyte_asn1.SEQUENCE) + + // Read validity + var validity cryptobyte.String + if !tbs.ReadASN1(&validity, cryptobyte_asn1.SEQUENCE) { + return time.Time{}, time.Time{}, 0, 0, fmt.Errorf("failed to read validity") + } + + // Parse notBefore + var nb cryptobyte.String + var nbTag cryptobyte_asn1.Tag + if !validity.ReadAnyASN1(&nb, &nbTag) { + return time.Time{}, time.Time{}, 0, 0, fmt.Errorf("failed to read notBefore") + } + notBeforeTag = int(nbTag) + + // Parse the time value + if notBeforeTag == 23 { + var utcTime string + if _, err := stdasn1.Unmarshal([]byte(nb), &utcTime); err == nil { + // Parse YYMMDDHHMMSSZ + notBefore, _ = time.Parse("060102150405Z", utcTime) + } + } else if notBeforeTag == 24 { + var genTime string + if _, err := stdasn1.Unmarshal([]byte(nb), &genTime); err == nil { + notBefore, _ = time.Parse("20060102150405Z", genTime) + } + } + + // Parse notAfter + var na cryptobyte.String + var naTag cryptobyte_asn1.Tag + if !validity.ReadAnyASN1(&na, &naTag) { + return time.Time{}, time.Time{}, 0, 0, fmt.Errorf("failed to read notAfter") + } + notAfterTag = int(naTag) + + if notAfterTag == 23 { + var utcTime string + if _, err := stdasn1.Unmarshal([]byte(na), &utcTime); err == nil { + notAfter, _ = time.Parse("060102150405Z", utcTime) + } + } else if notAfterTag == 24 { + var genTime string + if _, err := stdasn1.Unmarshal([]byte(na), &genTime); err == nil { + notAfter, _ = time.Parse("20060102150405Z", genTime) + } + } + + return notBefore, notAfter, notBeforeTag, notAfterTag, nil } \ No newline at end of file diff --git a/internal/cert/zcrypto/sct_test.go b/internal/cert/zcrypto/sct_test.go new file mode 100644 index 0000000..3cbf904 --- /dev/null +++ b/internal/cert/zcrypto/sct_test.go @@ -0,0 +1,92 @@ +package zcrypto + +import ( + "encoding/pem" + "os" + "testing" + + "github.com/zmap/zcrypto/x509" +) + +func TestBuildSCT(t *testing.T) { + // Read Let's Encrypt certificate + data, err := os.ReadFile("../../../tests/certs/letsencrypt.pem") + if err != nil { + t.Skipf("Error reading cert: %v", err) + return + } + + // Parse PEM + block, _ := pem.Decode(data) + if block == nil { + t.Fatal("Failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("Error parsing cert: %v", err) + } + + t.Logf("SCT count in cert: %d", len(cert.SignedCertificateTimestampList)) + + // Build node tree + tree := BuildTree(cert) + + // Get SCT node + sctNode := tree.Children["signedCertificateTimestamps"] + if sctNode == nil { + if len(cert.SignedCertificateTimestampList) == 0 { + t.Log("No SCTs in certificate") + return + } + t.Fatal("SCT node missing when SCTs exist") + return + } + + // Print SCT structure + t.Log("\nSCT Node Tree:") + for key, child := range sctNode.Children { + t.Logf("\n=== SCT %s ===", key) + for field, val := range child.Children { + if val.Value != nil { + t.Logf(" %s: %v", field, val.Value) + } + } + } + + // Verify first SCT has required fields + if len(sctNode.Children) > 0 { + firstSCT := sctNode.Children["0"] + if firstSCT == nil { + t.Fatal("First SCT missing") + } + + // Check logID + if firstSCT.Children["logID"] == nil { + t.Error("logID field missing") + } + if firstSCT.Children["logIDHex"] == nil { + t.Error("logIDHex field missing") + } + + // Check timestamp + if firstSCT.Children["timestamp"] == nil { + t.Error("timestamp field missing") + } + if firstSCT.Children["timestampTime"] == nil { + t.Error("timestampTime field missing") + } + + // Check version + if firstSCT.Children["version"] == nil { + t.Error("version field missing") + } + + // Check signature + if firstSCT.Children["signature"] == nil { + t.Error("signature field missing") + } + + t.Logf("\nAll required fields present for SCT 0") + } +} diff --git a/internal/crl/zcrypto/builder.go b/internal/crl/zcrypto/builder.go index c896844..9e55449 100644 --- a/internal/crl/zcrypto/builder.go +++ b/internal/crl/zcrypto/builder.go @@ -24,6 +24,29 @@ 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) @@ -151,8 +174,36 @@ func buildRevokedCertificates(revoked []x509.RevokedCertificate) *node.Node { } rcNode.Children["revocationDate"] = node.New("revocationDate", rc.RevocationTime) + // Add parsed reason code if present + if rc.ReasonCode != nil { + extNode := node.New("2.5.29.21", nil) + extNode.Children["oid"] = node.New("oid", "2.5.29.21") + extNode.Children["critical"] = node.New("critical", false) + extNode.Children["value"] = node.New("value", *rc.ReasonCode) + extsNode := node.New("extensions", nil) + extsNode.Children["2.5.29.21"] = extNode + rcNode.Children["extensions"] = extsNode + } + + // Also keep raw extensions for other extension types if len(rc.Extensions) > 0 { - rcNode.Children["extensions"] = zcrypto.BuildExtensions(rc.Extensions) + // Merge with existing extensions node or create new one + extsNode := rcNode.Children["extensions"] + if extsNode == nil { + extsNode = node.New("extensions", nil) + rcNode.Children["extensions"] = extsNode + } + for _, ext := range rc.Extensions { + // Skip reason code - already handled above + if ext.Id.String() == "2.5.29.21" { + continue + } + extNode := node.New(ext.Id.String(), nil) + extNode.Children["oid"] = node.New("oid", ext.Id.String()) + extNode.Children["critical"] = node.New("critical", ext.Critical) + extNode.Children["value"] = node.New("value", ext.Value) + extsNode.Children[ext.Id.String()] = extNode + } } n.Children[fmt.Sprintf("%d", i)] = rcNode diff --git a/internal/linter/config.go b/internal/linter/config.go index ea45ff9..0f16040 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -3,7 +3,7 @@ package linter import "time" type Config struct { - PolicyPath string + PolicyPaths []string // Multiple policy paths CertPath string CertURLs []string CertTimeout time.Duration diff --git a/internal/linter/filter.go b/internal/linter/filter.go index 8074775..53796c6 100644 --- a/internal/linter/filter.go +++ b/internal/linter/filter.go @@ -49,15 +49,22 @@ var oidNameMap = map[string]string{ "2.5.29.29": "issuingDistributionPoint", // Built-in cert types - "ca": "ca", - "leaf": "leaf", + "ca": "ca", + "root": "root", + "intermediate": "intermediate", + "leaf": "leaf", } // normalizeOID converts human-readable name to OID or returns the OID if already an OID func normalizeOID(nameOrOID string) string { if oid, ok := oidNameMap[nameOrOID]; ok { // If input is a name, return the OID - if len(oid) > 10 && oid[0:4] != "ca" && oid[0:4] != "leaf" { + // Built-in types (ca, root, intermediate, leaf) are not OIDs + if oid == "ca" || oid == "root" || oid == "intermediate" || oid == "leaf" { + return oid + } + // Other names are OID mappings + if len(oid) > 10 { return oid } } @@ -144,6 +151,20 @@ func policyAppliesToCert(p policy.Policy, cert *x509.Certificate) bool { } continue } + if ct == "root" { + // Root CA: self-signed (Subject == Issuer) and isCA + if cert.BasicConstraintsValid && cert.IsCA && cert.Subject.String() == cert.Issuer.String() { + return true + } + continue + } + if ct == "intermediate" { + // Intermediate CA: has different issuer (not self-signed) and isCA + if cert.BasicConstraintsValid && cert.IsCA && cert.Subject.String() != cert.Issuer.String() { + return true + } + continue + } if ct == "leaf" { if !cert.BasicConstraintsValid || !cert.IsCA { return true diff --git a/internal/linter/runner.go b/internal/linter/runner.go index e7a5e9a..f5baf9f 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -25,24 +25,29 @@ import ( func Run(cfg Config, w io.Writer) error { applyDefaults(&cfg) - // Check if path is a directory first - isDir, err := isDirectory(cfg.PolicyPath) - if err != nil { - return fmt.Errorf("checking policy path: %w", err) - } - + // Load policies from all specified paths var policies []policy.Policy - if isDir { - policies, err = policy.ParseDir(cfg.PolicyPath) - if err != nil { - return fmt.Errorf("failed to parse policy directory: %w", err) - } - } else { - p, err := policy.ParseFile(cfg.PolicyPath) - if err != nil { - return fmt.Errorf("failed to parse policy file: %w", err) + var err error + for _, policyPath := range cfg.PolicyPaths { + // Check if path is a directory first + isDir, err2 := isDirectory(policyPath) + if err2 != nil { + return fmt.Errorf("checking policy path %s: %w", policyPath, err2) + } + + if isDir { + p, err2 := policy.ParseDir(policyPath) + if err2 != nil { + return fmt.Errorf("failed to parse policy directory %s: %w", policyPath, err2) + } + policies = append(policies, p...) + } else { + p, err2 := policy.ParseFile(policyPath) + if err2 != nil { + return fmt.Errorf("failed to parse policy file %s: %w", policyPath, err2) + } + policies = append(policies, p) } - policies = append(policies, p) } reg := operator.DefaultRegistry() @@ -228,7 +233,15 @@ func Run(cfg Config, w io.Writer) error { continue } - crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + // Build issuer certificates list for CRL type detection + var issuerCerts []*x509.Certificate + for _, issuer := range issuers { + if issuer.Cert != nil { + issuerCerts = append(issuerCerts, issuer.Cert) + } + } + + crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) if crlNode == nil { continue } diff --git a/internal/operator/compare.go b/internal/operator/compare.go index 339a8a9..e2bb1ef 100644 --- a/internal/operator/compare.go +++ b/internal/operator/compare.go @@ -89,6 +89,8 @@ func (Positive) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, err return v > 0, nil case int64: return v > 0, nil + case uint64: + return v > 0, nil case float64: return v > 0, nil case *big.Int: @@ -118,6 +120,8 @@ func (Odd) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { return v%2 != 0, nil case int64: return v%2 != 0, nil + case uint64: + return v%2 != 0, nil case float64: return int64(v)%2 != 0, nil case *big.Int: diff --git a/internal/operator/component.go b/internal/operator/component.go new file mode 100644 index 0000000..95c2ce7 --- /dev/null +++ b/internal/operator/component.go @@ -0,0 +1,265 @@ +package operator + +import ( + "fmt" + "regexp" + "strings" + + "github.com/cavoq/PCL/internal/node" +) + +// ComponentMaxLength validates that each component of a delimited string +// does not exceed the specified maximum length. +// This is useful for DNS label validation (max 63 chars per label), +// path segment validation, and other component-based string formats. +// +// Handles both: +// - Single string value: splits by delimiter and validates each component +// - Parent node with children: validates each child's string value +// +// Operands: [maxLength, delimiter] +// - maxLength: maximum length for each component (integer) +// - delimiter: character that separates components (string, default ".") +type ComponentMaxLength struct{} + +func (ComponentMaxLength) Name() string { return "componentMaxLength" } + +func (ComponentMaxLength) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + maxLen, ok := ToInt(operands[0]) + if !ok { + return false, fmt.Errorf("componentMaxLength requires integer max length operand") + } + + delimiter := "." + if len(operands) >= 2 { + if d, ok := operands[1].(string); ok && d != "" { + delimiter = d + } + } + + // Handle parent node with children (like dNSName with indexed children) + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if !validateComponentMaxLength(str, maxLen, delimiter) { + return false, nil + } + } + return true, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return validateComponentMaxLength(str, maxLen, delimiter), nil +} + +func validateComponentMaxLength(str string, maxLen int, delimiter string) bool { + components := strings.Split(str, delimiter) + for _, comp := range components { + if len(comp) > maxLen { + return false + } + } + return true +} + +// ComponentMinLength validates that each component of a delimited string +// meets the specified minimum length. +// +// Handles both: +// - Single string value: splits by delimiter and validates each component +// - Parent node with children: validates each child's string value +type ComponentMinLength struct{} + +func (ComponentMinLength) Name() string { return "componentMinLength" } + +func (ComponentMinLength) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + minLen, ok := ToInt(operands[0]) + if !ok { + return false, fmt.Errorf("componentMinLength requires integer min length operand") + } + + delimiter := "." + if len(operands) >= 2 { + if d, ok := operands[1].(string); ok && d != "" { + delimiter = d + } + } + + // Handle parent node with children + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if !validateComponentMinLength(str, minLen, delimiter) { + return false, nil + } + } + return true, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return validateComponentMinLength(str, minLen, delimiter), nil +} + +func validateComponentMinLength(str string, minLen int, delimiter string) bool { + components := strings.Split(str, delimiter) + for _, comp := range components { + if len(comp) < minLen { + return false + } + } + return true +} + +// ComponentRegex validates that each component of a delimited string +// matches the specified regex pattern. +// +// Handles both: +// - Single string value: splits by delimiter and validates each component +// - Parent node with children: validates each child's string value +type ComponentRegex struct{} + +func (ComponentRegex) Name() string { return "componentRegex" } + +func (ComponentRegex) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + pattern, ok := operands[0].(string) + if !ok { + return false, fmt.Errorf("componentRegex requires string pattern operand") + } + + re, err := getCompiledRegex(pattern) + if err != nil { + return false, err + } + + delimiter := "." + if len(operands) >= 2 { + if d, ok := operands[1].(string); ok && d != "" { + delimiter = d + } + } + + // Handle parent node with children + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if !validateComponentRegex(str, re, delimiter) { + return false, nil + } + } + return true, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return validateComponentRegex(str, re, delimiter), nil +} + +func validateComponentRegex(str string, re *regexp.Regexp, delimiter string) bool { + components := strings.Split(str, delimiter) + for _, comp := range components { + if !re.MatchString(comp) { + return false + } + } + return true +} + +// ComponentNotRegex validates that each component of a delimited string +// does NOT match the specified regex pattern. +// +// Handles both: +// - Single string value: splits by delimiter and validates each component +// - Parent node with children: validates each child's string value +type ComponentNotRegex struct{} + +func (ComponentNotRegex) Name() string { return "componentNotRegex" } + +func (ComponentNotRegex) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + pattern, ok := operands[0].(string) + if !ok { + return false, fmt.Errorf("componentNotRegex requires string pattern operand") + } + + re, err := getCompiledRegex(pattern) + if err != nil { + return false, err + } + + delimiter := "." + if len(operands) >= 2 { + if d, ok := operands[1].(string); ok && d != "" { + delimiter = d + } + } + + // Handle parent node with children + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if !validateComponentNotRegex(str, re, delimiter) { + return false, nil + } + } + return true, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return validateComponentNotRegex(str, re, delimiter), nil +} + +func validateComponentNotRegex(str string, re *regexp.Regexp, delimiter string) bool { + components := strings.Split(str, delimiter) + for _, comp := range components { + if re.MatchString(comp) { + return false + } + } + return true +} \ No newline at end of file diff --git a/internal/operator/component_test.go b/internal/operator/component_test.go new file mode 100644 index 0000000..d88e10e --- /dev/null +++ b/internal/operator/component_test.go @@ -0,0 +1,253 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestComponentMaxLength(t *testing.T) { + op := ComponentMaxLength{} + + tests := []struct { + name string + value string + maxLen int + delimiter string + expected bool + }{ + { + name: "valid DNS labels (all under 63)", + value: "subdomain.example.test", + maxLen: 63, + delimiter: ".", + expected: true, + }, + { + name: "label exceeds max 63", + value: "thisisaverylonglabelthatdefinitelyexceedssixtythreecharactersaaa.example.test", + maxLen: 63, + delimiter: ".", + expected: false, + }, + { + name: "exactly max length 63", + value: "exactly63characterssssssssssssssssssssssssssssssssssss.example", + maxLen: 63, + delimiter: ".", + expected: true, + }, + { + name: "single component valid", + value: "short", + maxLen: 63, + delimiter: ".", + expected: true, + }, + { + name: "empty component should pass maxLength (length 0 <= any max)", + value: "example..test", + maxLen: 63, + delimiter: ".", + expected: true, + }, + { + name: "custom delimiter slash", + value: "path/to/resource", + maxLen: 10, + delimiter: "/", + expected: true, + }, + { + name: "custom delimiter exceeds", + value: "verylongpathsegment/to/resource", + maxLen: 10, + delimiter: "/", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + operands := []any{tt.maxLen, tt.delimiter} + result, err := op.Evaluate(n, nil, operands) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } + + // Test with default delimiter + t.Run("default delimiter", func(t *testing.T) { + n := node.New("test", "subdomain.example.test") + result, err := op.Evaluate(n, nil, []any{63}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !result { + t.Errorf("expected true for valid DNS with default delimiter") + } + }) +} + +func TestComponentMinLength(t *testing.T) { + op := ComponentMinLength{} + + tests := []struct { + name string + value string + minLen int + delimiter string + expected bool + }{ + { + name: "all labels meet minimum", + value: "subdomain.example.test", + minLen: 1, + delimiter: ".", + expected: true, + }, + { + name: "empty label fails minimum", + value: "example..test", + minLen: 1, + delimiter: ".", + expected: false, + }, + { + name: "single char labels", + value: "a.b.c", + minLen: 1, + delimiter: ".", + expected: true, + }, + { + name: "label too short", + value: "ab.c.d", + minLen: 3, + delimiter: ".", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + operands := []any{tt.minLen, tt.delimiter} + result, err := op.Evaluate(n, nil, operands) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestComponentRegex(t *testing.T) { + op := ComponentRegex{} + + tests := []struct { + name string + value string + pattern string + delimiter string + expected bool + }{ + { + name: "valid DNS labels (alphanumeric and hyphen)", + value: "sub-domain.example.test", + pattern: "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + delimiter: ".", + expected: true, + }, + { + name: "DNS label with underscore fails", + value: "invalid_label.example.test", + pattern: "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + delimiter: ".", + expected: false, + }, + { + name: "IDN A-label format", + value: "xn--pss25c.xn--abc.example", + pattern: "^(xn--[a-z0-9-]+|[a-z0-9]([a-z0-9-]*[a-z0-9])?)$", + delimiter: ".", + expected: true, + }, + { + name: "label starts with hyphen fails", + value: "-invalid.example.test", + pattern: "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + delimiter: ".", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + operands := []any{tt.pattern, tt.delimiter} + result, err := op.Evaluate(n, nil, operands) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestComponentNotRegex(t *testing.T) { + op := ComponentNotRegex{} + + tests := []struct { + name string + value string + pattern string + delimiter string + expected bool + }{ + { + name: "no underscore in labels", + value: "valid.example.test", + pattern: "_", + delimiter: ".", + expected: true, + }, + { + name: "underscore present fails", + value: "invalid_label.example.test", + pattern: "_", + delimiter: ".", + expected: false, + }, + { + name: "no forbidden chars", + value: "clean.example", + pattern: "[*?]", + delimiter: ".", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + operands := []any{tt.pattern, tt.delimiter} + result, err := op.Evaluate(n, nil, operands) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/context.go b/internal/operator/context.go index d7a34ac..6a65470 100644 --- a/internal/operator/context.go +++ b/internal/operator/context.go @@ -34,6 +34,34 @@ 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. +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 +} + type ContextOption func(*EvaluationContext) func WithCRLs(crls []*crl.Info) ContextOption { diff --git a/internal/operator/crl.go b/internal/operator/crl.go index aa0920c..2bc79f4 100644 --- a/internal/operator/crl.go +++ b/internal/operator/crl.go @@ -59,51 +59,44 @@ type CRLSignedBy struct{} func (CRLSignedBy) Name() string { return "crlSignedBy" } func (CRLSignedBy) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool, error) { - if !ctx.HasCRLs() || !ctx.HasChain() { + if !ctx.HasCRLs() { + return false, nil + } + + if !ctx.HasChain() { return false, nil } - // Track if we found any applicable CRL (CRL whose issuer is in chain) - var foundApplicableCRL bool for _, crlInfo := range ctx.CRLs { if crlInfo.CRL == nil { continue } crl := crlInfo.CRL - // Find issuer in chain - var crlIssuerInChain *x509.Certificate - for _, certInfo := range ctx.Chain { - if certInfo.Cert == nil { + // Find matching issuer from chain + crlIssuer := crl.Issuer.String() + var matchingIssuer *x509.Certificate + for _, issuerInfo := range ctx.Chain { + if issuerInfo.Cert == nil { continue } - - if crl.Issuer.String() != certInfo.Cert.Subject.String() { - continue + if issuerInfo.Cert.Subject.String() == crlIssuer { + matchingIssuer = issuerInfo.Cert + break } - - crlIssuerInChain = certInfo.Cert - break } - // If CRL issuer not in chain, skip this CRL (can't verify) - if crlIssuerInChain == nil { + // If issuer not in chain, skip this CRL (not applicable to our chain) + if matchingIssuer == nil { continue } - foundApplicableCRL = true - - // Verify signature - err := crl.CheckSignatureFrom(crlIssuerInChain) - if err != nil { + // Verify signature only for applicable CRLs (issuer in chain) + if err := crl.CheckSignatureFrom(matchingIssuer); err != nil { return false, nil } } - // If we found and verified at least one applicable CRL, return true - // If no CRLs were applicable (all skipped - issuers not in chain), return true (not applicable) - // The cert-not-revoked rule handles checking revocation status - _ = foundApplicableCRL // track for future use return true, nil } @@ -112,18 +105,18 @@ type NotRevoked struct{} func (NotRevoked) Name() string { return "notRevoked" } func (NotRevoked) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool, error) { - if !ctx.HasCert() { + if ctx == nil || ctx.Cert == nil || ctx.Cert.Cert == nil { return false, nil } - if len(ctx.CRLs) == 0 { - return true, nil - } - cert := ctx.Cert.Cert certSerial := cert.SerialNumber.String() certIssuer := cert.Issuer.String() + if !ctx.HasCRLs() { + return true, nil // No CRLs = not revoked + } + for _, crlInfo := range ctx.CRLs { if crlInfo.CRL == nil { continue @@ -142,4 +135,4 @@ func (NotRevoked) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool, } return true, nil -} +} \ No newline at end of file diff --git a/internal/operator/date.go b/internal/operator/date.go index 7ecf851..4edd929 100644 --- a/internal/operator/date.go +++ b/internal/operator/date.go @@ -93,3 +93,144 @@ func toTime(v any) (time.Time, error) { return time.Time{}, fmt.Errorf("cannot convert %T to time", v) } } + +// DateDiff checks that the difference between two dates is within specified limits. +// Target should be a node containing both date fields as children. +// Operands format (map): +// - start: name/path of the start date field (child of target) +// - end: name/path of the end date field (child of target) +// - maxDays: maximum allowed days (optional) +// - maxMonths: maximum allowed months (optional) +// - minDays: minimum allowed days (optional) +// +// Example YAML usage: +// target: crl +// operator: dateDiff +// operands: +// start: thisUpdate +// end: nextUpdate +// maxDays: 10 +// +// For BR CRL validation: +// - Subscriber CRL: maxDays: 10 +// - CA CRL: maxMonths: 12 +type DateDiff struct{} + +func (DateDiff) Name() string { return "dateDiff" } + +func (DateDiff) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil { + return false, nil + } + + // Parse operands + var startPath string + var endPath string + var maxDays int + var maxMonths int + var minDays int + + if len(operands) == 0 { + return false, nil + } + + if m, ok := operands[0].(map[string]any); ok { + if p, ok := m["start"].(string); ok { + startPath = p + } + if p, ok := m["end"].(string); ok { + endPath = p + } + // Also support "from" as alias for "start" + if p, ok := m["from"].(string); ok && startPath == "" { + startPath = p + } + if d, ok := m["maxDays"].(int); ok { + maxDays = d + } + if d64, ok := m["maxDays"].(int64); ok { + maxDays = int(d64) + } + if f64, ok := m["maxDays"].(float64); ok { + maxDays = int(f64) + } + if mVal, ok := m["maxMonths"].(int); ok { + maxMonths = mVal + } + if m64, ok := m["maxMonths"].(int64); ok { + maxMonths = int(m64) + } + if mf64, ok := m["maxMonths"].(float64); ok { + maxMonths = int(mf64) + } + if d, ok := m["minDays"].(int); ok { + minDays = d + } + } + + if startPath == "" { + return false, nil + } + + // Resolve start date from target node's children + startNode := resolvePath(n, startPath) + if startNode == nil || startNode.Value == nil { + return false, nil + } + + startDate, ok := startNode.Value.(time.Time) + if !ok { + return false, nil + } + + // Resolve end date - if endPath not specified, use target node's value + var endDate time.Time + if endPath != "" { + endNode := resolvePath(n, endPath) + if endNode == nil || endNode.Value == nil { + return false, nil + } + endDate, ok = endNode.Value.(time.Time) + if !ok { + return false, nil + } + } else { + // Use target node's value as end date + if n.Value == nil { + return false, nil + } + endDate, ok = n.Value.(time.Time) + if !ok { + return false, nil + } + } + + // Calculate difference + diff := endDate.Sub(startDate) + + // Check maximum days + if maxDays > 0 { + maxDuration := time.Duration(maxDays) * 24 * time.Hour + if diff > maxDuration { + return false, nil + } + } + + // Check maximum months (using AddDate for accurate month calculation) + if maxMonths > 0 { + maxDate := startDate.AddDate(0, maxMonths, 0) + if endDate.After(maxDate) { + return false, nil + } + } + + // Check minimum days + if minDays > 0 { + minDuration := time.Duration(minDays) * 24 * time.Hour + if diff < minDuration { + return false, nil + } + } + + return true, nil +} diff --git a/internal/operator/date_test.go b/internal/operator/date_test.go index 5a70549..e41a6f2 100644 --- a/internal/operator/date_test.go +++ b/internal/operator/date_test.go @@ -95,3 +95,176 @@ func TestDateOperatorNilNode(t *testing.T) { } } } + +func TestDateDiff(t *testing.T) { + op := DateDiff{} + now := time.Now() + + tests := []struct { + name string + node *node.Node + operands []any + want bool + wantErr bool + }{ + { + name: "nil node returns false", + node: nil, + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxDays": 10}}, + want: false, + wantErr: false, + }, + { + name: "maxDays within limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(5*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxDays": 10}}, + want: true, + wantErr: false, + }, + { + name: "maxDays exceeds limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(15*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxDays": 10}}, + want: false, + wantErr: false, + }, + { + name: "maxMonths within limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.AddDate(0, 6, 0)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxMonths": 12}}, + want: true, + wantErr: false, + }, + { + name: "maxMonths exceeds limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.AddDate(0, 13, 0)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxMonths": 12}}, + want: false, + wantErr: false, + }, + { + name: "minDays within limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(5*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minDays": 3}}, + want: true, + wantErr: false, + }, + { + name: "minDays below limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(2*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minDays": 3}}, + want: false, + wantErr: false, + }, + { + name: "no operands returns false", + node: node.New("test", nil), + operands: []any{}, + want: false, + wantErr: false, + }, + { + name: "missing start returns false", + node: node.New("test", nil), + operands: []any{map[string]any{"end": "nextUpdate", "maxDays": 10}}, + want: false, + wantErr: false, + }, + { + name: "missing start node returns false", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["nextUpdate"] = node.New("nextUpdate", now.Add(5*24*time.Hour)) + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxDays": 10}}, + want: false, + wantErr: false, + }, + { + name: "from alias for start", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(5*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"from": "thisUpdate", "end": "nextUpdate", "maxDays": 10}}, + want: true, + wantErr: false, + }, + { + name: "int64 and float64 for maxDays", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(5*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxDays": int64(10)}}, + want: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, tt.operands) + if tt.wantErr && err == nil { + t.Errorf("DateDiff.Evaluate() expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("DateDiff.Evaluate() unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("DateDiff.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/operator/encoding.go b/internal/operator/encoding.go new file mode 100644 index 0000000..74a083f --- /dev/null +++ b/internal/operator/encoding.go @@ -0,0 +1,213 @@ +package operator + +import ( + "github.com/cavoq/PCL/internal/node" +) + +// IsIA5String checks if a string value uses IA5String encoding (ASCII). +// IA5String is equivalent to ASCII (characters 0x00-0x7F). +type IsIA5String struct{} + +func (IsIA5String) Name() string { return "isIA5String" } + +func (IsIA5String) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check encoding child from DN attribute encoding info + encodingNode := n.Children["encoding"] + if encodingNode == nil || encodingNode.Value == nil { + // If no encoding info, assume it's IA5String compatible if value is ASCII + if n.Value != nil { + if str, ok := n.Value.(string); ok { + return isASCII(str), nil + } + } + return false, nil + } + + // IA5String tag is 22 + if tag, ok := encodingNode.Value.(int); ok { + return tag == 22, nil + } + + return false, nil +} + +// IsPrintableString checks if a string value uses PrintableString encoding. +// PrintableString allows limited character set: A-Z, a-z, 0-9, space, and specific special chars. +type IsPrintableString struct{} + +func (IsPrintableString) Name() string { return "isPrintableString" } + +func (IsPrintableString) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check encoding child from DN attribute encoding info + encodingNode := n.Children["encoding"] + if encodingNode == nil || encodingNode.Value == nil { + // If no encoding info, check if value is PrintableString compatible + if n.Value != nil { + if str, ok := n.Value.(string); ok { + return isPrintableStringCompatible(str), nil + } + } + return false, nil + } + + // PrintableString tag is 19 + if tag, ok := encodingNode.Value.(int); ok { + return tag == 19, nil + } + + return false, nil +} + +// IsUTF8String checks if a string value uses UTF8String encoding. +type IsUTF8String struct{} + +func (IsUTF8String) Name() string { return "isUTF8String" } + +func (IsUTF8String) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check encoding child from DN attribute encoding info + encodingNode := n.Children["encoding"] + if encodingNode == nil || encodingNode.Value == nil { + return false, nil + } + + // UTF8String tag is 12 + if tag, ok := encodingNode.Value.(int); ok { + return tag == 12, nil + } + + return false, nil +} + +// ValidIA5String checks that a string contains only valid IA5String characters. +// IA5String = ASCII (0x00-0x7F). +// If the node has children (array-like), checks all children. +type ValidIA5String struct{} + +func (ValidIA5String) Name() string { return "validIA5String" } + +func (ValidIA5String) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // If node has value, check it directly + if n.Value != nil { + str, ok := n.Value.(string) + if !ok { + return false, nil + } + return isASCII(str), nil + } + + // If node has children (array-like), check all children + if len(n.Children) > 0 { + for _, child := range n.Children { + if child.Value == nil { + continue + } + str, ok := child.Value.(string) + if !ok { + return false, nil + } + if !isASCII(str) { + return false, nil + } + } + return true, nil + } + + return false, nil +} + +// ValidPrintableString checks that a string contains only valid PrintableString characters. +// If the node has children (array-like), checks all children. +type ValidPrintableString struct{} + +func (ValidPrintableString) Name() string { return "validPrintableString" } + +func (ValidPrintableString) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // If node has value, check it directly + if n.Value != nil { + str, ok := n.Value.(string) + if !ok { + return false, nil + } + return isPrintableStringCompatible(str), nil + } + + // If node has children (array-like), check all children + if len(n.Children) > 0 { + for _, child := range n.Children { + if child.Value == nil { + continue + } + str, ok := child.Value.(string) + if !ok { + return false, nil + } + if !isPrintableStringCompatible(str) { + return false, nil + } + } + return true, nil + } + + return false, nil +} + +// Helper functions + +func isASCII(s string) bool { + for _, c := range s { + if c > 0x7F { + return false + } + } + return true +} + +func isPrintableStringCompatible(s string) bool { + for _, c := range s { + if !isPrintableChar(c) { + return false + } + } + return true +} + +func isPrintableChar(c rune) bool { + // Upper case letters + if c >= 'A' && c <= 'Z' { + return true + } + // Lower case letters + if c >= 'a' && c <= 'z' { + return true + } + // Digits + if c >= '0' && c <= '9' { + return true + } + // Special characters allowed in PrintableString per ASN.1 + switch c { + case ' ', '\'', '(', ')', '+', ',', '-', '.', '/', ':', '=', '?': + return true + } + return false +} \ No newline at end of file diff --git a/internal/operator/encoding_test.go b/internal/operator/encoding_test.go new file mode 100644 index 0000000..f43eb36 --- /dev/null +++ b/internal/operator/encoding_test.go @@ -0,0 +1,474 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestIsIA5String(t *testing.T) { + op := IsIA5String{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "encoding tag 22 (IA5String) returns true", + node: func() *node.Node { + n := node.New("test", "test@example.com") + n.Children["encoding"] = node.New("encoding", 22) + return n + }(), + want: true, + }, + { + name: "encoding tag 19 (PrintableString) returns false", + node: func() *node.Node { + n := node.New("test", "Test") + n.Children["encoding"] = node.New("encoding", 19) + return n + }(), + want: false, + }, + { + name: "encoding tag 12 (UTF8String) returns false", + node: func() *node.Node { + n := node.New("test", "Test") + n.Children["encoding"] = node.New("encoding", 12) + return n + }(), + want: false, + }, + { + name: "no encoding child with ASCII value returns true", + node: node.New("test", "example.com"), + want: true, + }, + { + name: "no encoding child with non-ASCII value returns false", + node: node.New("test", "exampleé.com"), + want: false, + }, + { + name: "no encoding child with non-string value returns false", + node: node.New("test", 123), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("IsIA5String.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("IsIA5String.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsPrintableString(t *testing.T) { + op := IsPrintableString{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "encoding tag 19 (PrintableString) returns true", + node: func() *node.Node { + n := node.New("test", "Test-Org") + n.Children["encoding"] = node.New("encoding", 19) + return n + }(), + want: true, + }, + { + name: "encoding tag 22 (IA5String) returns false", + node: func() *node.Node { + n := node.New("test", "test@example.com") + n.Children["encoding"] = node.New("encoding", 22) + return n + }(), + want: false, + }, + { + name: "no encoding child with printable value returns true", + node: node.New("test", "Test-Org-123"), + want: true, + }, + { + name: "no encoding child with non-printable value returns false", + node: node.New("test", "Test@Org"), + want: false, + }, + { + name: "no encoding child with unicode value returns false", + node: node.New("test", "Testé"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("IsPrintableString.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("IsPrintableString.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsUTF8String(t *testing.T) { + op := IsUTF8String{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "encoding tag 12 (UTF8String) returns true", + node: func() *node.Node { + n := node.New("test", "Testé") + n.Children["encoding"] = node.New("encoding", 12) + return n + }(), + want: true, + }, + { + name: "encoding tag 19 (PrintableString) returns false", + node: func() *node.Node { + n := node.New("test", "Test") + n.Children["encoding"] = node.New("encoding", 19) + return n + }(), + want: false, + }, + { + name: "no encoding child returns false", + node: node.New("test", "Test"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("IsUTF8String.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("IsUTF8String.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidIA5String(t *testing.T) { + op := ValidIA5String{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "pure ASCII string returns true", + node: node.New("test", "example.com"), + want: true, + }, + { + name: "string with non-ASCII returns false", + node: node.New("test", "exampleé.com"), + want: false, + }, + { + name: "string with 0x80 returns false", + node: node.New("test", "test\x80"), + want: false, + }, + { + name: "non-string value returns false", + node: node.New("test", 123), + want: false, + }, + { + name: "children with all ASCII returns true", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "example.com") + n.Children["1"] = node.New("1", "test.org") + return n + }(), + want: true, + }, + { + name: "children with non-ASCII returns false", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "example.com") + n.Children["1"] = node.New("1", "testé.org") + return n + }(), + want: false, + }, + { + name: "children with nil value skipped returns true", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "example.com") + n.Children["1"] = node.New("1", nil) + return n + }(), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("ValidIA5String.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("ValidIA5String.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidPrintableString(t *testing.T) { + op := ValidPrintableString{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "valid printable string returns true", + node: node.New("test", "Test-Org-123"), + want: true, + }, + { + name: "string with letters digits space returns true", + node: node.New("test", "Test Org 123"), + want: true, + }, + { + name: "string with special chars returns true", + node: node.New("test", "Test's (Org)+123,-./:=?"), + want: true, + }, + { + name: "string with @ returns false", + node: node.New("test", "test@example.com"), + want: false, + }, + { + name: "string with unicode returns false", + node: node.New("test", "Testé"), + want: false, + }, + { + name: "string with underscore returns false", + node: node.New("test", "test_org"), + want: false, + }, + { + name: "non-string value returns false", + node: node.New("test", 123), + want: false, + }, + { + name: "children with all printable returns true", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "Test-Org") + n.Children["1"] = node.New("1", "Org-123") + return n + }(), + want: true, + }, + { + name: "children with non-printable returns false", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "Test-Org") + n.Children["1"] = node.New("1", "test@example.com") + return n + }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("ValidPrintableString.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("ValidPrintableString.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsASCII(t *testing.T) { + tests := []struct { + name string + s string + want bool + }{ + { + name: "empty string is ASCII", + s: "", + want: true, + }, + { + name: "simple ASCII string", + s: "example.com", + want: true, + }, + { + name: "string with unicode", + s: "exampleé.com", + want: false, + }, + { + name: "string with 0x80", + s: "test\x80", + want: false, + }, + { + name: "all printable ASCII", + s: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isASCII(tt.s); got != tt.want { + t.Errorf("isASCII(%q) = %v, want %v", tt.s, got, tt.want) + } + }) + } +} + +func TestIsPrintableStringCompatible(t *testing.T) { + tests := []struct { + name string + s string + want bool + }{ + { + name: "empty string is printable", + s: "", + want: true, + }, + { + name: "letters and digits", + s: "TestOrg123", + want: true, + }, + { + name: "with space", + s: "Test Org", + want: true, + }, + { + name: "with apostrophe", + s: "Test's", + want: true, + }, + { + name: "with parentheses", + s: "(Test)", + want: true, + }, + { + name: "with plus comma dash", + s: "Test+Org-123,", + want: true, + }, + { + name: "with dot slash colon", + s: "Test./:123", + want: true, + }, + { + name: "with equals question", + s: "Test=?123", + want: true, + }, + { + name: "with @ is not printable", + s: "test@example.com", + want: false, + }, + { + name: "with underscore is not printable", + s: "test_org", + want: false, + }, + { + name: "with unicode is not printable", + s: "Testé", + want: false, + }, + { + name: "with exclamation is not printable", + s: "Test!", + want: false, + }, + { + name: "with ampersand is not printable", + s: "Test&Org", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isPrintableStringCompatible(tt.s); got != tt.want { + t.Errorf("isPrintableStringCompatible(%q) = %v, want %v", tt.s, got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/equality.go b/internal/operator/equality.go index 316395f..8059aed 100644 --- a/internal/operator/equality.go +++ b/internal/operator/equality.go @@ -1,7 +1,6 @@ package operator import ( - "fmt" "reflect" "github.com/cavoq/PCL/internal/node" @@ -58,19 +57,36 @@ type Matches struct{} func (Matches) Name() string { return "matches" } func (Matches) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { - if n == nil || len(operands) != 1 { + if n == nil || len(operands) == 0 { return false, nil } - path, ok := operands[0].(string) - if !ok { - return false, fmt.Errorf("matches operator requires a string path operand") - } + // Support multiple path operands - any match is OK + for _, op := range operands { + path, ok := op.(string) + if !ok { + continue + } - target, found := ctx.Root.Resolve(path) - if !found || target == nil { - return false, nil + target, found := ctx.Root.Resolve(path) + if !found || target == nil { + continue + } + + // Check if target is a parent node with indexed children (array-like) + if len(target.Children) > 0 && target.Value == nil { + for _, child := range target.Children { + if reflect.DeepEqual(n.Value, child.Value) { + return true, nil + } + } + } else { + // Single value comparison + if reflect.DeepEqual(n.Value, target.Value) { + return true, nil + } + } } - return reflect.DeepEqual(n.Value, target.Value), nil + return false, nil } diff --git a/internal/operator/every.go b/internal/operator/every.go new file mode 100644 index 0000000..9b05d49 --- /dev/null +++ b/internal/operator/every.go @@ -0,0 +1,205 @@ +package operator + +import ( + "fmt" + + "github.com/cavoq/PCL/internal/node" +) + +// Every checks that every element in an array-like node satisfies a condition. +// Operands format (map): +// - path: sub-path relative to each element (optional, empty means check element itself) +// - check: operator name to apply to each element +// - values: operands for the check operator (optional) +// - skipMissing: if true, skip elements where path doesn't exist (default: false) +// +// Example YAML usage: +// target: crl.revokedCertificates +// operator: every +// operands: +// path: extensions.2.5.29.21.value +// check: in +// values: [1, 3, 4, 5, 9] +type Every struct{} + +func (Every) Name() string { return "every" } + +func (Every) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { + if n == nil { + return false, nil + } + + // Parse operands + if len(operands) == 0 { + return false, fmt.Errorf("every operator requires operands") + } + + // Operands can be a map or we parse from slice + var subPath string + var checkOp string + var checkOperands []any + var skipMissing bool + + // Try parsing as map first + if m, ok := operands[0].(map[string]any); ok { + if p, ok := m["path"].(string); ok { + subPath = p + } + if c, ok := m["check"].(string); ok { + checkOp = c + } + if v, ok := m["values"]; ok { + if slice, ok := v.([]any); ok { + checkOperands = slice + } else { + checkOperands = []any{v} + } + } + if s, ok := m["skipMissing"].(bool); ok { + skipMissing = s + } + } else { + // Alternative: parse as [path, check, values...] + if len(operands) >= 2 { + if p, ok := operands[0].(string); ok { + subPath = p + } + if c, ok := operands[1].(string); ok { + checkOp = c + } + if len(operands) > 2 { + checkOperands = operands[2:] + } + } + } + + if checkOp == "" { + return false, fmt.Errorf("every operator requires 'check' operand") + } + + // Get the check operator from registry + registry := DefaultRegistry() + op, err := registry.Get(checkOp) + if err != nil { + return false, fmt.Errorf("every: unknown check operator '%s'", checkOp) + } + + // If node has no children (empty array), trivially true + if len(n.Children) == 0 { + return true, nil + } + + // Check each child + for _, child := range n.Children { + if child == nil { + continue + } + + // Resolve sub-path if provided + var targetNode *node.Node + if subPath == "" { + targetNode = child + } else { + targetNode = resolvePath(child, subPath) + if targetNode == nil { + if skipMissing { + continue // Skip this element + } + return false, nil // Element doesn't have required path + } + } + + // Apply check operator + result, err := op.Evaluate(targetNode, ctx, checkOperands) + if err != nil { + return false, err + } + if !result { + return false, nil // At least one element failed + } + } + + return true, nil +} + +// resolvePath resolves a dot-separated path from a node. +// Handles OID-style keys that contain dots (e.g., "2.5.29.21"). +func resolvePath(n *node.Node, path string) *node.Node { + if n == nil || path == "" { + return n + } + + current := n + parts := splitPath(path) + + for i := 0; i < len(parts); i++ { + if current == nil || current.Children == nil { + return nil + } + + // Try to find child with exact match + part := parts[i] + next := current.Children[part] + + // If not found and part looks like OID start (numeric), + // try combining with subsequent parts to find OID key + if next == nil && isOIDStart(part) && i+1 < len(parts) { + // Try progressively combining parts until we find a match + for j := i + 1; j <= len(parts); j++ { + combined := combineParts(parts, i, j) + if current.Children[combined] != nil { + next = current.Children[combined] + i = j - 1 // Skip the combined parts + break + } + } + } + + if next == nil { + return nil + } + current = next + } + + return current +} + +// isOIDStart checks if a part looks like the start of an OID (numeric). +func isOIDStart(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +// combineParts combines parts from i to j (exclusive) with dots. +func combineParts(parts []string, i, j int) string { + result := parts[i] + for k := i + 1; k < j; k++ { + result += "." + parts[k] + } + return result +} + +// splitPath splits a path by dots, handling numeric indices. +func splitPath(path string) []string { + var parts []string + start := 0 + for i := 0; i < len(path); i++ { + if path[i] == '.' { + if i > start { + parts = append(parts, path[start:i]) + } + start = i + 1 + } + } + if start < len(path) { + parts = append(parts, path[start:]) + } + return parts +} \ No newline at end of file diff --git a/internal/operator/every_test.go b/internal/operator/every_test.go new file mode 100644 index 0000000..d33a4d7 --- /dev/null +++ b/internal/operator/every_test.go @@ -0,0 +1,323 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestEvery(t *testing.T) { + op := Every{} + + tests := []struct { + name string + node *node.Node + operands []any + want bool + wantErr bool + }{ + { + name: "nil node returns false", + node: nil, + operands: []any{map[string]any{"check": "present"}}, + want: false, + wantErr: false, + }, + { + name: "empty children returns true", + node: node.New("test", nil), + operands: []any{map[string]any{"check": "present"}}, + want: true, + wantErr: false, + }, + { + name: "all children pass present check", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "value1") + n.Children["1"] = node.New("1", "value2") + return n + }(), + operands: []any{map[string]any{"check": "present"}}, + want: true, + wantErr: false, + }, + { + name: "some children fail notEmpty check", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "value1") + n.Children["1"] = node.New("1", nil) // nil value = empty + return n + }(), + operands: []any{map[string]any{"check": "notEmpty"}}, + want: false, + wantErr: false, + }, + { + name: "all children pass in check with values", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", 1) + n.Children["1"] = node.New("1", 3) + n.Children["2"] = node.New("2", 5) + return n + }(), + operands: []any{map[string]any{ + "check": "in", + "values": []any{1, 3, 5, 7, 9}, + }}, + want: true, + wantErr: false, + }, + { + name: "one child fails in check", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", 1) + n.Children["1"] = node.New("1", 2) // 2 not in allowed list + n.Children["2"] = node.New("2", 5) + return n + }(), + operands: []any{map[string]any{ + "check": "in", + "values": []any{1, 3, 5, 7, 9}, + }}, + want: false, + wantErr: false, + }, + { + name: "sub-path check - all pass", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + child0.Children["value"] = node.New("value", 1) + n.Children["0"] = child0 + child1 := node.New("1", nil) + child1.Children["value"] = node.New("value", 3) + n.Children["1"] = child1 + return n + }(), + operands: []any{map[string]any{ + "path": "value", + "check": "in", + "values": []any{1, 3, 5}, + }}, + want: true, + wantErr: false, + }, + { + name: "sub-path check - one fails", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + child0.Children["value"] = node.New("value", 1) + n.Children["0"] = child0 + child1 := node.New("1", nil) + child1.Children["value"] = node.New("value", 2) // 2 not allowed + n.Children["1"] = child1 + return n + }(), + operands: []any{map[string]any{ + "path": "value", + "check": "in", + "values": []any{1, 3, 5}, + }}, + want: false, + wantErr: false, + }, + { + name: "sub-path missing - fails by default", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + child0.Children["value"] = node.New("value", 1) + n.Children["0"] = child0 + child1 := node.New("1", nil) // no value child + n.Children["1"] = child1 + return n + }(), + operands: []any{map[string]any{ + "path": "value", + "check": "present", + }}, + want: false, + wantErr: false, + }, + { + name: "sub-path missing - skip with skipMissing", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + child0.Children["value"] = node.New("value", 1) + n.Children["0"] = child0 + child1 := node.New("1", nil) // no value child, skipped + n.Children["1"] = child1 + return n + }(), + operands: []any{map[string]any{ + "path": "value", + "check": "present", + "skipMissing": true, + }}, + want: true, + wantErr: false, + }, + { + name: "nested sub-path check - simplified", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + child0.Children["a"] = node.New("a", nil) + child0.Children["a"].Children["b"] = node.New("b", 1) + n.Children["0"] = child0 + child1 := node.New("1", nil) + child1.Children["a"] = node.New("a", nil) + child1.Children["a"].Children["b"] = node.New("b", 3) + n.Children["1"] = child1 + return n + }(), + operands: []any{map[string]any{ + "path": "a.b", + "check": "in", + "values": []any{1, 3, 5, 9}, + }}, + want: true, + wantErr: false, + }, + { + name: "nested sub-path check", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + ext0 := node.New("extensions", nil) + reason0 := node.New("2.5.29.21", nil) + reason0.Children["value"] = node.New("value", 1) + ext0.Children["2.5.29.21"] = reason0 + child0.Children["extensions"] = ext0 + n.Children["0"] = child0 + child1 := node.New("1", nil) + ext1 := node.New("extensions", nil) + reason1 := node.New("2.5.29.21", nil) + reason1.Children["value"] = node.New("value", 3) + ext1.Children["2.5.29.21"] = reason1 + child1.Children["extensions"] = ext1 + n.Children["1"] = child1 + return n + }(), + operands: []any{map[string]any{ + "path": "extensions.2.5.29.21.value", + "check": "in", + "values": []any{1, 3, 5, 9}, + }}, + want: true, + wantErr: false, + }, + { + name: "missing check operand returns error", + node: node.New("test", nil), + operands: []any{map[string]any{"path": "value"}}, + want: false, + wantErr: true, + }, + { + name: "unknown check operator returns error", + node: node.New("test", nil), + operands: []any{map[string]any{"check": "unknownOperator"}}, + want: false, + wantErr: true, + }, + { + name: "no operands returns error", + node: node.New("test", nil), + operands: []any{}, + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, tt.operands) + if tt.wantErr && err == nil { + t.Errorf("Every.Evaluate() expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("Every.Evaluate() unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("Every.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolvePath(t *testing.T) { + tests := []struct { + name string + node *node.Node + path string + want *node.Node + }{ + { + name: "empty path returns original node", + node: node.New("test", "value"), + path: "", + want: node.New("test", "value"), + }, + { + name: "single level path", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["child"] = node.New("child", "value") + return n + }(), + path: "child", + want: node.New("child", "value"), + }, + { + name: "multi level path", + node: func() *node.Node { + n := node.New("test", nil) + l1 := node.New("l1", nil) + l2 := node.New("l2", nil) + l2.Children["l3"] = node.New("l3", "value") + l1.Children["l2"] = l2 + n.Children["l1"] = l1 + return n + }(), + path: "l1.l2.l3", + want: node.New("l3", "value"), + }, + { + name: "path not found returns nil", + node: node.New("test", nil), + path: "nonexistent", + want: nil, + }, + { + name: "nil node returns nil", + node: nil, + path: "any", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolvePath(tt.node, tt.path) + if got == nil && tt.want != nil { + t.Errorf("resolvePath() = nil, want non-nil") + } + if got != nil && tt.want == nil { + t.Errorf("resolvePath() = non-nil, want nil") + } + // Compare values if both non-nil + if got != nil && tt.want != nil { + if got.Value != tt.want.Value { + t.Errorf("resolvePath().Value = %v, want %v", got.Value, tt.want.Value) + } + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/membership.go b/internal/operator/membership.go index c5e7532..8ad057a 100644 --- a/internal/operator/membership.go +++ b/internal/operator/membership.go @@ -54,35 +54,51 @@ func (Contains) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bo if n == nil { return false, nil } - if len(operands) != 1 { - return false, fmt.Errorf("contains requires exactly 1 operand") + if len(operands) == 0 { + return false, fmt.Errorf("contains requires at least 1 operand") } - target := operands[0] - val := reflect.ValueOf(n.Value) if val.Kind() == reflect.Slice || val.Kind() == reflect.Array { for i := 0; i < val.Len(); i++ { - if equal(val.Index(i).Interface(), target) { - return true, nil + for _, target := range operands { + if equal(val.Index(i).Interface(), target) { + return true, nil + } } } return false, nil } if len(n.Children) > 0 { + // First check child values for _, child := range n.Children { - if equal(child.Value, target) { - return true, nil + for _, target := range operands { + if equal(child.Value, target) { + return true, nil + } + } + } + // Also check child names (for cases like certificatePolicies where key is OID) + for name := range n.Children { + for _, target := range operands { + if equal(name, target) { + return true, nil + } } } return false, nil } if str, ok := n.Value.(string); ok { - if substr, ok := target.(string); ok { - return len(str) > 0 && len(substr) > 0 && strings.Contains(str, substr), nil + for _, target := range operands { + if substr, ok := target.(string); ok { + if len(str) > 0 && len(substr) > 0 && strings.Contains(str, substr) { + return true, nil + } + } } + return false, nil } return false, fmt.Errorf("contains requires a slice, array, node with children, or string") diff --git a/internal/operator/membership_test.go b/internal/operator/membership_test.go index d6bc822..736c634 100644 --- a/internal/operator/membership_test.go +++ b/internal/operator/membership_test.go @@ -150,8 +150,21 @@ func TestContainsWrongOperands(t *testing.T) { t.Error("should error with no operands") } - _, err = op.Evaluate(n, nil, []any{"a", "b"}) - if err == nil { - t.Error("should error with too many operands") + // Multiple operands now allowed (any match semantics) + result, err := op.Evaluate(n, nil, []any{"a", "b"}) + if err != nil { + t.Error("multiple operands should now be allowed") + } + if !result { + t.Error("should match 'a' in slice") + } + + // Test multiple operands with no match + result, err = op.Evaluate(n, nil, []any{"x", "y"}) + if err != nil { + t.Error("multiple operands should be allowed") + } + if result { + t.Error("should not match 'x' or 'y' in slice") } } diff --git a/internal/operator/operator.go b/internal/operator/operator.go index 8feeca5..f4a4635 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -56,7 +56,36 @@ var All = []Operator{ OCSPValid{}, NotRevokedOCSP{}, OCSPGood{}, + // Generic operators + Every{}, + DateDiff{}, NameConstraintsValid{}, CertificatePolicyValid{}, IsNull{}, + // Generic component validation operators (useful for DNS labels, path segments, etc.) + ComponentMaxLength{}, + ComponentMinLength{}, + ComponentRegex{}, + ComponentNotRegex{}, + // UTF-8 validation operators + UTF8NoBOM{}, + ContainsBOM{}, + // Subject DN validation operators + NoDuplicateAttributes{}, + // Unique value operators (for AIA, CRL DP, etc.) + UniqueValues{}, + UniqueChildren{}, + // Time format validation operators (ASN.1) + UTCTimeHasZulu{}, + UTCTimeHasSeconds{}, + GeneralizedTimeHasZulu{}, + GeneralizedTimeNoFraction{}, + IsUTCTime{}, + IsGeneralizedTime{}, + // Encoding validation operators (ASN.1) + IsIA5String{}, + IsPrintableString{}, + IsUTF8String{}, + ValidIA5String{}, + ValidPrintableString{}, } diff --git a/internal/operator/regex.go b/internal/operator/regex.go index 5ecc1c0..10df6ea 100644 --- a/internal/operator/regex.go +++ b/internal/operator/regex.go @@ -69,6 +69,21 @@ func matchRegex(n *node.Node, operands []any) (bool, error) { return false, err } + // Handle parent node with children (like dNSName with indexed children) + // Returns true if ANY child matches (useful for presence checks) + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if re.MatchString(str) { + return true, nil + } + } + return false, nil + } + str, ok := n.Value.(string) if !ok { return false, nil diff --git a/internal/operator/subject.go b/internal/operator/subject.go new file mode 100644 index 0000000..1696c45 --- /dev/null +++ b/internal/operator/subject.go @@ -0,0 +1,98 @@ +package operator + +import ( + "fmt" + + "github.com/cavoq/PCL/internal/node" +) + +// NoDuplicateAttributes checks that subject DN does not contain +// duplicate AttributeTypeAndValue instances per CABF BR 7.1.4.1 +type NoDuplicateAttributes struct{} + +func (NoDuplicateAttributes) Name() string { return "noDuplicateAttributes" } + +func (NoDuplicateAttributes) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Subject DN attributes that must be unique (single instance) + // Per CABF BR 7.1.4.1 and zlint implementation + singleInstanceOIDs := map[string]string{ + "2.5.4.3": "commonName", + "2.5.4.4": "surname", + "2.5.4.5": "serialNumber", + "2.5.4.6": "countryName", + "2.5.4.7": "localityName", + "2.5.4.8": "stateOrProvinceName", + "2.5.4.10": "organizationName", + "2.5.4.15": "businessCategory", + "2.5.4.42": "givenName", + "2.5.4.97": "organizationIdentifier", + "1.3.6.1.4.1.311.60.2.1.1": "jurisdictionLocality", + "1.3.6.1.4.1.311.60.2.1.2": "jurisdictionStateOrProvince", + "1.3.6.1.4.1.311.60.2.1.3": "jurisdictionCountry", + } + + // Attributes exempt from single-instance requirement + // domainComponent and streetAddress can have multiple instances + exemptOIDs := map[string]bool{ + "0.9.2342.19200300.100.1.25": true, // domainComponent (DC) + "2.5.4.9": true, // streetAddress + "2.5.4.11": true, // organizationalUnitName (deprecated but exempt) + } + + // Check children of subject node for duplicates + // Subject node structure: subject.commonName, subject.organizationName, etc. + foundOIDs := make(map[string]int) + + for childName, child := range n.Children { + // childName is the attribute name (e.g., "commonName", "organizationName") + // Check if this attribute appears multiple times + + // Get OID from child if available + oidNode := child.Children["oid"] + var oid string + if oidNode != nil && oidNode.Value != nil { + oid = fmt.Sprintf("%v", oidNode.Value) + } + + // If no OID from node, try to map name to OID + if oid == "" { + nameToOID := map[string]string{ + "commonName": "2.5.4.3", + "surname": "2.5.4.4", + "serialNumber": "2.5.4.5", + "countryName": "2.5.4.6", + "localityName": "2.5.4.7", + "stateOrProvinceName": "2.5.4.8", + "organizationName": "2.5.4.10", + "businessCategory": "2.5.4.15", + "givenName": "2.5.4.42", + "organizationIdentifier": "2.5.4.97", + } + oid = nameToOID[childName] + } + + // Skip if no OID found + if oid == "" { + continue + } + + // Skip exempt attributes + if exemptOIDs[oid] { + continue + } + + // Only check single-instance OIDs + if singleInstanceOIDs[oid] != "" { + foundOIDs[oid]++ + if foundOIDs[oid] > 1 { + return false, nil + } + } + } + + return true, nil +} \ No newline at end of file diff --git a/internal/operator/subject_test.go b/internal/operator/subject_test.go new file mode 100644 index 0000000..498c9e9 --- /dev/null +++ b/internal/operator/subject_test.go @@ -0,0 +1,185 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestNoDuplicateAttributes(t *testing.T) { + op := NoDuplicateAttributes{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "single commonName returns true", + node: func() *node.Node { + n := node.New("subject", nil) + cn := node.New("commonName", "example.com") + cn.Children["oid"] = node.New("oid", "2.5.4.3") + n.Children["commonName"] = cn + return n + }(), + want: true, + }, + { + name: "multiple domainComponent returns true (exempt)", + node: func() *node.Node { + n := node.New("subject", nil) + dc1 := node.New("domainComponent", "example") + dc1.Children["oid"] = node.New("oid", "0.9.2342.19200300.100.1.25") + dc2 := node.New("domainComponent", "com") + dc2.Children["oid"] = node.New("oid", "0.9.2342.19200300.100.1.25") + n.Children["domainComponent0"] = dc1 + n.Children["domainComponent1"] = dc2 + return n + }(), + want: true, + }, + { + name: "multiple streetAddress returns true (exempt)", + node: func() *node.Node { + n := node.New("subject", nil) + sa1 := node.New("streetAddress", "Street 1") + sa1.Children["oid"] = node.New("oid", "2.5.4.9") + sa2 := node.New("streetAddress", "Street 2") + sa2.Children["oid"] = node.New("oid", "2.5.4.9") + n.Children["streetAddress0"] = sa1 + n.Children["streetAddress1"] = sa2 + return n + }(), + want: true, + }, + { + name: "multiple organizationalUnit returns true (exempt)", + node: func() *node.Node { + n := node.New("subject", nil) + ou1 := node.New("organizationalUnitName", "OU1") + ou1.Children["oid"] = node.New("oid", "2.5.4.11") + ou2 := node.New("organizationalUnitName", "OU2") + ou2.Children["oid"] = node.New("oid", "2.5.4.11") + n.Children["organizationalUnitName0"] = ou1 + n.Children["organizationalUnitName1"] = ou2 + return n + }(), + want: true, + }, + { + name: "duplicate commonName by OID returns false", + node: func() *node.Node { + n := node.New("subject", nil) + cn1 := node.New("commonName", "example.com") + cn1.Children["oid"] = node.New("oid", "2.5.4.3") + cn2 := node.New("commonName", "example.org") + cn2.Children["oid"] = node.New("oid", "2.5.4.3") + n.Children["commonName0"] = cn1 + n.Children["commonName1"] = cn2 + return n + }(), + want: false, + }, + { + name: "duplicate commonName without OID info - only name matches", + node: func() *node.Node { + n := node.New("subject", nil) + // When parsed with different keys but both named "commonName" + // and no OID info, detection relies on name matching + cn1 := node.New("commonName", "example.com") + cn2 := node.New("commonName", "example.org") + n.Children["commonName"] = cn1 + n.Children["commonName_1"] = cn2 + return n + }(), + // Expected true because "commonName_1" doesn't match nameToOID["commonName"] + // This is edge case - duplicate detection requires OID info + want: true, + }, + { + name: "duplicate organizationName returns false", + node: func() *node.Node { + n := node.New("subject", nil) + o1 := node.New("organizationName", "Org1") + o1.Children["oid"] = node.New("oid", "2.5.4.10") + o2 := node.New("organizationName", "Org2") + o2.Children["oid"] = node.New("oid", "2.5.4.10") + n.Children["organizationName0"] = o1 + n.Children["organizationName1"] = o2 + return n + }(), + want: false, + }, + { + name: "duplicate countryName returns false", + node: func() *node.Node { + n := node.New("subject", nil) + c1 := node.New("countryName", "US") + c1.Children["oid"] = node.New("oid", "2.5.4.6") + c2 := node.New("countryName", "GB") + c2.Children["oid"] = node.New("oid", "2.5.4.6") + n.Children["countryName0"] = c1 + n.Children["countryName1"] = c2 + return n + }(), + want: false, + }, + { + name: "different attributes return true", + node: func() *node.Node { + n := node.New("subject", nil) + cn := node.New("commonName", "example.com") + cn.Children["oid"] = node.New("oid", "2.5.4.3") + o := node.New("organizationName", "Example Org") + o.Children["oid"] = node.New("oid", "2.5.4.10") + c := node.New("countryName", "US") + c.Children["oid"] = node.New("oid", "2.5.4.6") + n.Children["commonName"] = cn + n.Children["organizationName"] = o + n.Children["countryName"] = c + return n + }(), + want: true, + }, + { + name: "child without OID node is skipped", + node: func() *node.Node { + n := node.New("subject", nil) + cn := node.New("commonName", "example.com") + // No OID child + n.Children["commonName"] = cn + return n + }(), + want: true, + }, + { + name: "child with OID but not in single instance list returns true", + node: func() *node.Node { + n := node.New("subject", nil) + attr := node.New("unknownAttribute", "value") + attr.Children["oid"] = node.New("oid", "1.2.3.4.5.6") + n.Children["unknownAttribute"] = attr + return n + }(), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("NoDuplicateAttributes.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("NoDuplicateAttributes.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/time.go b/internal/operator/time.go new file mode 100644 index 0000000..9420985 --- /dev/null +++ b/internal/operator/time.go @@ -0,0 +1,172 @@ +package operator + +import ( + "github.com/cavoq/PCL/internal/node" +) + +// UTCTimeHasZulu validates that UTCTime has 'Z' suffix per RFC 5280 4.1.2.5.1. +// UTCTime format MUST end with 'Z' (not timezone offset). +type UTCTimeHasZulu struct{} + +func (UTCTimeHasZulu) Name() string { return "utctimeHasZulu" } + +func (UTCTimeHasZulu) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check hasZulu child from encoding info + hasZuluNode := n.Children["hasZulu"] + if hasZuluNode == nil || hasZuluNode.Value == nil { + return false, nil + } + + if b, ok := hasZuluNode.Value.(bool); ok { + return b, nil + } + + return false, nil +} + +// UTCTimeHasSeconds validates that UTCTime includes seconds per RFC 5280 4.1.2.5.1. +// Seconds MUST be present even if the value is 00. +type UTCTimeHasSeconds struct{} + +func (UTCTimeHasSeconds) Name() string { return "utctimeHasSeconds" } + +func (UTCTimeHasSeconds) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check hasSeconds child from encoding info + hasSecondsNode := n.Children["hasSeconds"] + if hasSecondsNode == nil || hasSecondsNode.Value == nil { + return false, nil + } + + if b, ok := hasSecondsNode.Value.(bool); ok { + return b, nil + } + + return false, nil +} + +// GeneralizedTimeHasZulu validates that GeneralizedTime has 'Z' suffix. +// GeneralizedTime MUST end with 'Z' per RFC 5280. +type GeneralizedTimeHasZulu struct{} + +func (GeneralizedTimeHasZulu) Name() string { return "generalizedTimeHasZulu" } + +func (GeneralizedTimeHasZulu) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check hasZulu child from encoding info + hasZuluNode := n.Children["hasZulu"] + if hasZuluNode == nil || hasZuluNode.Value == nil { + return false, nil + } + + if b, ok := hasZuluNode.Value.(bool); ok { + return b, nil + } + + return false, nil +} + +// GeneralizedTimeNoFraction validates that GeneralizedTime has no fractional seconds. +// Fractional seconds are not recommended by RFC 5280. +type GeneralizedTimeNoFraction struct{} + +func (GeneralizedTimeNoFraction) Name() string { return "generalizedTimeNoFraction" } + +func (GeneralizedTimeNoFraction) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Check isUTC (false for GeneralizedTime) to ensure we're checking GeneralizedTime + isUTCNode := n.Children["isUTC"] + if isUTCNode == nil || isUTCNode.Value == nil { + return false, nil + } + + isUTC, ok := isUTCNode.Value.(bool) + if !ok { + return false, nil + } + + // This operator only applies to GeneralizedTime (isUTC=false) + if isUTC { + return false, nil // Skip for UTCTime + } + + // For GeneralizedTime, check hasFraction from format string + // We derive this from the format child + formatNode := n.Children["format"] + if formatNode == nil || formatNode.Value == nil { + return false, nil + } + + format, ok := formatNode.Value.(string) + if !ok { + return false, nil + } + + // Fractional seconds are indicated by '.' in the format + hasFraction := false + for _, c := range format { + if c == '.' { + hasFraction = true + break + } + } + + return !hasFraction, nil +} + +// IsUTCTime checks if the time encoding is UTCTime (tag 23). +type IsUTCTime struct{} + +func (IsUTCTime) Name() string { return "isUTCTime" } + +func (IsUTCTime) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + isUTCNode := n.Children["isUTC"] + if isUTCNode == nil || isUTCNode.Value == nil { + return false, nil + } + + if b, ok := isUTCNode.Value.(bool); ok { + return b, nil + } + + return false, nil +} + +// IsGeneralizedTime checks if the time encoding is GeneralizedTime (tag 24). +type IsGeneralizedTime struct{} + +func (IsGeneralizedTime) Name() string { return "isGeneralizedTime" } + +func (IsGeneralizedTime) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + isUTCNode := n.Children["isUTC"] + if isUTCNode == nil || isUTCNode.Value == nil { + return false, nil + } + + if b, ok := isUTCNode.Value.(bool); ok { + return !b, nil // GeneralizedTime when isUTC is false + } + + return false, nil +} \ No newline at end of file diff --git a/internal/operator/time_test.go b/internal/operator/time_test.go new file mode 100644 index 0000000..96da617 --- /dev/null +++ b/internal/operator/time_test.go @@ -0,0 +1,332 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestUTCTimeHasZulu(t *testing.T) { + op := UTCTimeHasZulu{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "node without hasZulu child returns false", + node: node.New("test", "2501011200Z"), + want: false, + }, + { + name: "hasZulu true returns true", + node: func() *node.Node { + n := node.New("test", "2501011200Z") + n.Children["hasZulu"] = node.New("hasZulu", true) + return n + }(), + want: true, + }, + { + name: "hasZulu false returns false", + node: func() *node.Node { + n := node.New("test", "2501011200+0000") + n.Children["hasZulu"] = node.New("hasZulu", false) + return n + }(), + want: false, + }, + { + name: "hasZulu non-bool returns false", + node: func() *node.Node { + n := node.New("test", "2501011200Z") + n.Children["hasZulu"] = node.New("hasZulu", "true") + return n + }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("UTCTimeHasZulu.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("UTCTimeHasZulu.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUTCTimeHasSeconds(t *testing.T) { + op := UTCTimeHasSeconds{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "node without hasSeconds child returns false", + node: node.New("test", "2501011200Z"), + want: false, + }, + { + name: "hasSeconds true returns true", + node: func() *node.Node { + n := node.New("test", "250101120000Z") + n.Children["hasSeconds"] = node.New("hasSeconds", true) + return n + }(), + want: true, + }, + { + name: "hasSeconds false returns false", + node: func() *node.Node { + n := node.New("test", "2501011200Z") + n.Children["hasSeconds"] = node.New("hasSeconds", false) + return n + }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("UTCTimeHasSeconds.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("UTCTimeHasSeconds.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGeneralizedTimeHasZulu(t *testing.T) { + op := GeneralizedTimeHasZulu{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "hasZulu true returns true", + node: func() *node.Node { + n := node.New("test", "20250101120000Z") + n.Children["hasZulu"] = node.New("hasZulu", true) + return n + }(), + want: true, + }, + { + name: "hasZulu false returns false", + node: func() *node.Node { + n := node.New("test", "20250101120000+0000") + n.Children["hasZulu"] = node.New("hasZulu", false) + return n + }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("GeneralizedTimeHasZulu.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("GeneralizedTimeHasZulu.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGeneralizedTimeNoFraction(t *testing.T) { + op := GeneralizedTimeNoFraction{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "UTCTime (isUTC true) returns false", + node: func() *node.Node { + n := node.New("test", "2501011200Z") + n.Children["isUTC"] = node.New("isUTC", true) + return n + }(), + want: false, + }, + { + name: "GeneralizedTime without fraction returns true", + node: func() *node.Node { + n := node.New("test", "20250101120000Z") + n.Children["isUTC"] = node.New("isUTC", false) + n.Children["format"] = node.New("format", "20060102150405Z") + return n + }(), + want: true, + }, + { + name: "GeneralizedTime with fraction returns false", + node: func() *node.Node { + n := node.New("test", "20250101120000.123Z") + n.Children["isUTC"] = node.New("isUTC", false) + n.Children["format"] = node.New("format", "20060102150405.999Z") + return n + }(), + want: false, + }, + { + name: "GeneralizedTime without format child returns false", + node: func() *node.Node { + n := node.New("test", "20250101120000Z") + n.Children["isUTC"] = node.New("isUTC", false) + return n + }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("GeneralizedTimeNoFraction.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("GeneralizedTimeNoFraction.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsUTCTime(t *testing.T) { + op := IsUTCTime{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "isUTC true returns true", + node: func() *node.Node { + n := node.New("test", "2501011200Z") + n.Children["isUTC"] = node.New("isUTC", true) + return n + }(), + want: true, + }, + { + name: "isUTC false returns false (GeneralizedTime)", + node: func() *node.Node { + n := node.New("test", "20250101120000Z") + n.Children["isUTC"] = node.New("isUTC", false) + return n + }(), + want: false, + }, + { + name: "no isUTC child returns false", + node: node.New("test", "2501011200Z"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("IsUTCTime.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("IsUTCTime.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsGeneralizedTime(t *testing.T) { + op := IsGeneralizedTime{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node returns false", + node: nil, + want: false, + }, + { + name: "isUTC false returns true (GeneralizedTime)", + node: func() *node.Node { + n := node.New("test", "20250101120000Z") + n.Children["isUTC"] = node.New("isUTC", false) + return n + }(), + want: true, + }, + { + name: "isUTC true returns false (UTCTime)", + node: func() *node.Node { + n := node.New("test", "2501011200Z") + n.Children["isUTC"] = node.New("isUTC", true) + return n + }(), + want: false, + }, + { + name: "no isUTC child returns false", + node: node.New("test", "20250101120000Z"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("IsGeneralizedTime.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("IsGeneralizedTime.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/unique.go b/internal/operator/unique.go new file mode 100644 index 0000000..f6220fb --- /dev/null +++ b/internal/operator/unique.go @@ -0,0 +1,70 @@ +package operator + +import ( + "github.com/cavoq/PCL/internal/node" +) + +// UniqueValues checks that all children of a node have unique values. +// This is useful for validating that CRL Distribution Points, AIA URLs, etc. +// contain unique locations (no duplicates). +type UniqueValues struct{} + +func (UniqueValues) Name() string { return "uniqueValues" } + +func (UniqueValues) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // If the node has children, check that all child values are unique + if len(n.Children) == 0 { + // No children - trivially unique (or no values to check) + return true, nil + } + + seen := make(map[any]bool) + for _, child := range n.Children { + if child.Value == nil { + continue // Skip nil values + } + if seen[child.Value] { + return false, nil // Duplicate found + } + seen[child.Value] = true + } + + return true, nil +} + +// UniqueChildren checks that all children have unique names. +// This is useful for validating that array-like structures don't have +// duplicate entries when indexed by name. +type UniqueChildren struct{} + +func (UniqueChildren) Name() string { return "uniqueChildren" } + +func (UniqueChildren) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Children names are already unique in the map structure + // But we check if there are duplicate child VALUE entries + seen := make(map[string]bool) + for _, child := range n.Children { + if child.Value == nil { + continue + } + // Convert value to string for comparison + valStr, ok := child.Value.(string) + if !ok { + continue // Skip non-string values + } + if seen[valStr] { + return false, nil // Duplicate value found + } + seen[valStr] = true + } + + return true, nil +} \ No newline at end of file diff --git a/internal/operator/unique_test.go b/internal/operator/unique_test.go new file mode 100644 index 0000000..c169611 --- /dev/null +++ b/internal/operator/unique_test.go @@ -0,0 +1,124 @@ +package operator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestUniqueValues(t *testing.T) { + op := UniqueValues{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node should return false", + node: nil, + want: false, + }, + { + name: "node with no children should return true", + node: node.New("test", "value"), + want: true, + }, + { + name: "unique child values should return true", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "http://crl1.example.com") + n.Children["1"] = node.New("1", "http://crl2.example.com") + return n + }(), + want: true, + }, + { + name: "duplicate child values should return false", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "http://crl.example.com") + n.Children["1"] = node.New("1", "http://crl.example.com") + return n + }(), + want: false, + }, + { + name: "children with nil values should be skipped", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", "http://crl.example.com") + n.Children["1"] = node.New("1", nil) + n.Children["2"] = node.New("2", "http://crl.example.com") + return n + }(), + want: false, + }, + { + name: "empty children should return true", + node: node.New("test", nil), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("UniqueValues.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("UniqueValues.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUniqueChildren(t *testing.T) { + op := UniqueChildren{} + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "nil node should return false", + node: nil, + want: false, + }, + { + name: "unique child string values should return true", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["a"] = node.New("a", "value1") + n.Children["b"] = node.New("b", "value2") + return n + }(), + want: true, + }, + { + name: "duplicate child string values should return false", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["a"] = node.New("a", "same") + n.Children["b"] = node.New("b", "same") + return n + }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("UniqueChildren.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("UniqueChildren.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/utf8.go b/internal/operator/utf8.go new file mode 100644 index 0000000..467d144 --- /dev/null +++ b/internal/operator/utf8.go @@ -0,0 +1,55 @@ +package operator + +import ( + "bytes" + + "github.com/cavoq/PCL/internal/node" +) + +// UTF8NoBOM validates that a UTF-8 string does not start with a Byte Order Mark (BOM). +// The UTF-8 BOM is the byte sequence 0xEF 0xBB 0xBF. +// This is useful for validating UTF8String fields per RFC 3629 and RFC 9598. +// +// Per RFC 3629: "The UTF-8 BOM is not recommended for use in UTF-8 encoded strings" +// Per RFC 9598 3: "The UTF8String encoding MUST NOT contain a Byte Order Mark (BOM)" +// +// Example: validate SmtpUTF8Mailbox doesn't contain BOM +// target: certificate.subjectAltName.otherName.smtpUTF8Mailbox +// operator: utf8NoBom +type UTF8NoBOM struct{} + +func (UTF8NoBOM) Name() string { return "utf8NoBom" } + +func (UTF8NoBOM) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // UTF-8 BOM byte sequence: EF BB BF + utf8BOM := []byte{0xEF, 0xBB, 0xBF} + + switch v := n.Value.(type) { + case string: + // Check if string starts with BOM (when encoded as UTF-8) + return !bytes.HasPrefix([]byte(v), utf8BOM), nil + case []byte: + return !bytes.HasPrefix(v, utf8BOM), nil + default: + // Non-string/bytes values cannot have BOM + return true, nil + } +} + +// ContainsBOM validates that a UTF-8 string DOES start with a Byte Order Mark. +// This is the inverse of UTF8NoBOM, useful for detecting problematic BOM presence. +type ContainsBOM struct{} + +func (ContainsBOM) Name() string { return "containsBom" } + +func (ContainsBOM) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { + noBom, err := UTF8NoBOM{}.Evaluate(n, ctx, operands) + if err != nil { + return false, err + } + return !noBom, nil +} \ No newline at end of file diff --git a/internal/operator/utf8_test.go b/internal/operator/utf8_test.go new file mode 100644 index 0000000..97b6261 --- /dev/null +++ b/internal/operator/utf8_test.go @@ -0,0 +1,144 @@ +package operator + +import ( + "bytes" + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestUTF8NoBOM(t *testing.T) { + op := UTF8NoBOM{} + + // UTF-8 BOM byte sequence: EF BB BF + utf8BOM := []byte{0xEF, 0xBB, 0xBF} + + tests := []struct { + name string + value interface{} + expected bool + }{ + { + name: "string without BOM", + value: "normal UTF-8 string", + expected: true, + }, + { + name: "string with BOM", + value: string(utf8BOM) + "string with BOM", + expected: false, + }, + { + name: "bytes without BOM", + value: []byte("normal bytes"), + expected: true, + }, + { + name: "bytes with BOM", + value: append(utf8BOM, []byte("bytes with BOM")...), + expected: false, + }, + { + name: "empty string", + value: "", + expected: true, + }, + { + name: "empty bytes", + value: []byte{}, + expected: true, + }, + { + name: "non-string value", + value: 123, + expected: true, // non-string/bytes cannot have BOM + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + result, err := op.Evaluate(n, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestContainsBOM(t *testing.T) { + op := ContainsBOM{} + + utf8BOM := []byte{0xEF, 0xBB, 0xBF} + + tests := []struct { + name string + value interface{} + expected bool + }{ + { + name: "string without BOM", + value: "normal string", + expected: false, + }, + { + name: "string with BOM", + value: string(utf8BOM) + "string with BOM", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + result, err := op.Evaluate(n, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// Test that utf8NoBom can be used with actual UTF-8 strings +func TestUTF8NoBOMWithMultibyteChars(t *testing.T) { + op := UTF8NoBOM{} + + // Chinese characters (multi-byte UTF-8) + chineseStr := "医者@example.com" + n := node.New("test", chineseStr) + result, err := op.Evaluate(n, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !result { + t.Errorf("multi-byte UTF-8 string should pass utf8NoBom check") + } + + // Japanese characters + japaneseStr := "テスト" + n2 := node.New("test", japaneseStr) + result2, err := op.Evaluate(n2, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !result2 { + t.Errorf("Japanese UTF-8 string should pass utf8NoBom check") + } + + // Verify BOM detection works with raw bytes + utf8BOM := bytes.Join([][]byte{{0xEF, 0xBB, 0xBF}, []byte("医者@example.com")}, nil) + n3 := node.New("test", utf8BOM) + result3, err := op.Evaluate(n3, nil, nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result3 { + t.Errorf("bytes with BOM should fail utf8NoBom check") + } +} \ No newline at end of file diff --git a/internal/zcrypto/helpers.go b/internal/zcrypto/helpers.go index c7cbacd..42f6658 100644 --- a/internal/zcrypto/helpers.go +++ b/internal/zcrypto/helpers.go @@ -53,6 +53,33 @@ func BuildPkixName(name string, pkixName pkix.Name) *node.Node { if pkixName.SerialNumber != "" { n.Children["serialNumber"] = node.New("serialNumber", pkixName.SerialNumber) } + if len(pkixName.OrganizationIDs) > 0 { + n.Children["organizationIdentifier"] = node.New("organizationIdentifier", pkixName.OrganizationIDs[0]) + } + + // EV-specific fields + if len(pkixName.JurisdictionCountry) > 0 { + n.Children["jurisdictionCountryName"] = node.New("jurisdictionCountryName", pkixName.JurisdictionCountry[0]) + } + if len(pkixName.JurisdictionProvince) > 0 { + n.Children["jurisdictionStateOrProvinceName"] = node.New("jurisdictionStateOrProvinceName", pkixName.JurisdictionProvince[0]) + } + if len(pkixName.JurisdictionLocality) > 0 { + n.Children["jurisdictionLocalityName"] = node.New("jurisdictionLocalityName", pkixName.JurisdictionLocality[0]) + } + + // Parse additional attributes from Names (e.g., businessCategory) + // OID 2.5.4.15 = businessCategory + for _, atv := range pkixName.Names { + if len(atv.Type) == 4 && atv.Type[0] == 2 && atv.Type[1] == 5 && atv.Type[2] == 4 { + switch atv.Type[3] { + case 15: // businessCategory (2.5.4.15) + if val, ok := atv.Value.(string); ok { + n.Children["businessCategory"] = node.New("businessCategory", val) + } + } + } + } return n } From 3c4c8c3789431d56ce20af5a8cf5487fa2e3ef5d Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 00:11:57 +0800 Subject: [PATCH 14/44] docs: update README with new operators and certificate type detection - Add dateDiff and every operators for CRL validation - Document certificate type detection enhancement (BasicConstraints check) - Add CRL type detection (isCACRL field) for BR 7.2 rules - Update CRL node tree structure with isCACRL and revoked entry paths --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 07cad18..e723159 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,11 @@ A flexible X.509 certificate linter that validates certificates against configur ```bash go install github.com/cavoq/PCL/cmd/pcl@latest -pcl --policy --cert [--crl ] [--ocsp ] [--output text|json|yaml] +pcl --policy [--policy ...] --cert [--crl ] [--ocsp ] [--output text|json|yaml] ``` +Multiple policies can be specified with repeatable `--policy` flags. All rules from all policies will be applied. + By default, only failed rules are shown. Use `-v` to include passed rules and `-vv` to include skipped rules. ### Auto-Validate Mode @@ -199,11 +201,19 @@ rules: - [RFC 5758](https://datatracker.ietf.org/doc/html/rfc5758) - DSA and ECDSA with SHA2 - [RFC 5759](https://datatracker.ietf.org/doc/html/rfc5759) - Suite B Certificate and CRL Profile - [RFC 6960](https://datatracker.ietf.org/doc/html/rfc6960) - Online Certificate Status Protocol (OCSP) +- [RFC 8017](https://datatracker.ietf.org/doc/html/rfc8017) - PKCS #1: RSA Cryptography Specifications v2.2 (security recommendations) +- [RFC 8410](https://datatracker.ietf.org/doc/html/rfc8410) - Algorithm Identifiers for Ed25519, Ed448, X25519, and X448 - [RFC 8813](https://datatracker.ietf.org/doc/html/rfc8813) - Updates to RFC 5480 +- [RFC 9162](https://datatracker.ietf.org/doc/html/rfc9162) - Certificate Transparency Version 2.0 - [RFC 5019](https://datatracker.ietf.org/doc/html/rfc5019) - Lightweight OCSP Profile for High-Volume Environments - [RFC 9608](https://datatracker.ietf.org/doc/html/rfc9608) - No Revocation Available for X.509 Certificates +- [RFC 9549](https://datatracker.ietf.org/doc/html/rfc9549) - Internationalization Updates to RFC 5280 (IDN, DNS labels) +- [RFC 9598](https://datatracker.ietf.org/doc/html/rfc9598) - Internationalized Email Addresses in X.509 (rfc822Name) - [RFC 9654](https://datatracker.ietf.org/doc/html/rfc9654) - OCSP Nonce Extension -- CA/Browser Forum Baseline Requirements +- CA/Browser Forum Baseline Requirements (BR) +- CA/Browser Forum Extended Validation Guidelines (EVG) +- CA/Browser Forum S/MIME Baseline Requirements (SMIME BR) +- CA/Browser Forum Code Signing Baseline Requirements (CS BR) ## ➕ Supported Operators @@ -232,6 +242,9 @@ rules: | `odd` | Value is an odd number (for RSA exponent validation) | | `maxLength`, `minLength` | String/array length constraints | | `regex`, `notRegex` | Regular expression pattern matching | +| `componentMaxLength`, `componentMinLength` | Per-component length validation (DNS labels, path segments) | +| `componentRegex`, `componentNotRegex` | Per-component regex validation | +| `utf8NoBom`, `containsBom` | UTF-8 BOM detection | ### Date Operators @@ -241,6 +254,8 @@ rules: | `after` | Date is after current time | | `validityOrderCorrect` | Validates notBefore < notAfter | | `validityDays` | Certificate validity period check | +| `dateDiff` | Date difference validation with maxDays/maxMonths limits (for CRL nextUpdate) | +| `every` | Generic array iteration with sub-path and operator check (for CRL entries) | ### Extension Operators @@ -270,6 +285,14 @@ rules: | `ekuServerAuth`, `ekuClientAuth` | TLS authentication EKU checks | | `noUniqueIdentifiers` | Absence of issuer/subject unique IDs | +### Collection/Array Operators + +| Operator | Description | +|----------|-------------| +| `uniqueValues` | All children have unique values (for CRL DP, AIA URLs) | +| `uniqueChildren` | All children have unique string values | +| `noDuplicateAttributes` | Subject DN has no duplicate AttributeTypeAndValue | + ### CRL Operators | Operator | Description | @@ -278,6 +301,9 @@ rules: | `crlNotExpired` | CRL nextUpdate is in the future | | `crlSignedBy` | CRL signature verification against chain | | `notRevoked` | Certificate not in CRL revoked list | +| `crlEntryHasReasonCode` | Revoked certificate entry has reason code extension (OID 2.5.29.21) | +| `crlEntryReasonValid` | Revocation reason code is valid (0-10, except 7) | +| `crlEntriesAllHaveReason` | All revoked entries have reason code extensions | ### OCSP Operators @@ -294,6 +320,27 @@ rules: | `nameConstraintsValid` | Validates names against permitted/excluded subtrees from chain | | `certificatePolicyValid` | Validates policy OIDs through chain with mappings and constraints | +### ASN.1 Time Format Operators + +| Operator | Description | +|----------|-------------| +| `utctimeHasZulu` | UTCTime ends with 'Z' (RFC 5280 4.1.2.5.1) | +| `utctimeHasSeconds` | UTCTime includes seconds (RFC 5280 4.1.2.5.1) | +| `generalizedTimeHasZulu` | GeneralizedTime ends with 'Z' (RFC 5280 4.1.2.5.2) | +| `generalizedTimeNoFraction` | GeneralizedTime has no fractional seconds (recommended) | +| `isUTCTime` | Time encoding is UTCTime (tag 23) | +| `isGeneralizedTime` | Time encoding is GeneralizedTime (tag 24) | + +### ASN.1 Encoding Operators + +| Operator | Description | +|----------|-------------| +| `isIA5String` | Value uses IA5String encoding (ASCII) | +| `isPrintableString` | Value uses PrintableString encoding | +| `isUTF8String` | Value uses UTF8String encoding | +| `validIA5String` | All characters valid for IA5String (ASCII) | +| `validPrintableString` | All characters valid for PrintableString | + ## 🔀 Conditional Rules Rules can include a `when` clause to apply only when certain conditions are met: @@ -349,11 +396,13 @@ Example: EV certificates should have SCT embedded (warning), while AIA extension ## Certificate Chain Support -PCL automatically builds and validates certificate chains, applying rules based on certificate position: +PCL automatically builds and validates certificate chains, applying rules based on certificate position and BasicConstraints: + +- `leaf`: End-entity certificates (position 0, no BasicConstraints or IsCA=false) +- `intermediate`: Subordinate CA certificates (position 0+ with IsCA=true, not self-signed) +- `root`: Self-signed root CA certificates (IsCA=true, Subject==Issuer) -- `leaf`: End-entity certificates -- `intermediate`: CA certificates in the chain -- `root`: Self-signed root CA certificates +**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. @@ -441,15 +490,20 @@ crl ├── issuer # Same as certificate ├── thisUpdate # time.Time ├── nextUpdate # time.Time +├── isCACRL # Boolean: true if issuer is a CA certificate (requires --issuer) ├── revokedCertificates │ ├── # Each revoked cert │ │ ├── serialNumber │ │ ├── revocationDate │ │ ├── revocationReason +│ │ └── extensions +│ │ └── 2.5.29.21 # reasonCode extension └── extensions └── ... ``` +**CRL Type Detection:** The `isCACRL` field enables differentiation between Subscriber CRLs and CA CRLs (BR 7.2). This requires providing issuer certificates via `--issuer`. + ### OCSP Node Tree ``` From fd4beed6dbb6092734aba6163447f5bf93fcab05 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 09:45:02 +0800 Subject: [PATCH 15/44] feat: extend noUnknownCriticalExtensions operator for CRL support - Make noUnknownCriticalExtensions work for both certificates and CRLs - For certificates: use zcrypto's UnhandledCriticalExtensions (fast) - For CRLs: check extensions from node tree against known OIDs - Known CRL extension OIDs per RFC 5280 Section 5.2: - 2.5.29.20 (cRLNumber) - 2.5.29.27 (deltaCRLIndicator) - 2.5.29.28 (issuingDistributionPoint) - 2.5.29.35 (authorityKeyIdentifier) - 2.5.29.46 (freshestCRL) - 1.3.6.1.5.5.7.1.1 (authorityInformationAccess) --- internal/operator/constraints.go | 61 ++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/internal/operator/constraints.go b/internal/operator/constraints.go index 6fd72c6..0714c91 100644 --- a/internal/operator/constraints.go +++ b/internal/operator/constraints.go @@ -236,14 +236,63 @@ type NoUnknownCriticalExtensions struct{} func (NoUnknownCriticalExtensions) Name() string { return "noUnknownCriticalExtensions" } -func (NoUnknownCriticalExtensions) Evaluate(_ *node.Node, ctx *EvaluationContext, _ []any) (bool, error) { - if ctx == nil || ctx.Cert == nil || ctx.Cert.Cert == nil { +func (NoUnknownCriticalExtensions) Evaluate(n *node.Node, ctx *EvaluationContext, _ []any) (bool, error) { + // For certificates: use zcrypto's UnhandledCriticalExtensions (more accurate) + if ctx != nil && ctx.Cert != nil && ctx.Cert.Cert != nil { + cert := ctx.Cert.Cert + return len(cert.UnhandledCriticalExtensions) == 0, nil + } + + // For CRLs or other node types: check extensions from node tree + if n == nil { return false, nil } - cert := ctx.Cert.Cert + // Determine if this is a CRL node + if n.Name != "crl" { + return false, nil // Not applicable + } - // Check UnhandledCriticalExtensions which Go's x509 parser populates - // with OIDs of critical extensions it doesn't understand - return len(cert.UnhandledCriticalExtensions) == 0, nil + extsNode, _ := n.Resolve("extensions") + if extsNode == nil { + return true, nil // No extensions = no unknown critical + } + + // Check each extension for unknown critical ones + for oid, extNode := range extsNode.Children { + criticalNode, _ := extNode.Resolve("critical") + if criticalNode == nil { + continue + } + + critical, ok := criticalNode.Value.(bool) + if !ok || !critical { + continue // Non-critical extensions are OK + } + + // Check if this OID is known for CRLs + if !isKnownCRLExtensionOID(oid) { + return false, nil // Unknown critical extension found + } + } + + return true, nil +} + +// Known CRL extension OIDs per RFC 5280 Section 5.2 +func isKnownCRLExtensionOID(oid string) bool { + knownOIDs := []string{ + "2.5.29.20", // cRLNumber + "2.5.29.27", // deltaCRLIndicator + "2.5.29.28", // issuingDistributionPoint + "2.5.29.35", // authorityKeyIdentifier + "2.5.29.46", // freshestCRL + "1.3.6.1.5.5.7.1.1", // authorityInformationAccess + } + for _, known := range knownOIDs { + if oid == known { + return true + } + } + return false } From 62241271fe0a3c8c9679b587efaf953a54f0b128 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 10:06:04 +0800 Subject: [PATCH 16/44] feat: detect ocspSigning certificate type via EKU - Check for ocspSigning EKU in GetCertType before returning "leaf" - Enables OCSP responder certificate rules (BR 7.1.2.8) to be applied - appliesTo: [ocspSigning] now works correctly OCSP responder certificate vs OCSP response: - OCSP responder certificate: linted with --cert, type is "ocspSigning" - OCSP response: linted with --ocsp, has separate "ocsp.xxx" rules - Auto-validate handles both independently in parallel --- internal/cert/cert.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 0bba888..2d4d9be 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -49,6 +49,12 @@ func GetCertType(cert *x509.Certificate, position, chainLen int) string { } return "intermediate" } + // Check for ocspSigning EKU before returning "leaf" + for _, eku := range cert.ExtKeyUsage { + if eku == x509.ExtKeyUsageOcspSigning { + return "ocspSigning" + } + } return "leaf" } // At other positions, check if it's root or intermediate From ef27c3eabf9dea1aab6215650d3a3a4c1edbdc63 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 11:21:27 +0800 Subject: [PATCH 17/44] feat: implement PSL-based TLD and domain validation operators Add Public Suffix List (PSL) support for BR 4.2.2 (Internal Names) and BR 3.2.2.6 (Wildcard public suffix) validation. New operators: - tldRegistered/tldNotRegistered: TLD validation via PSL ICANN section - isPublicSuffix/isNotPublicSuffix: Public suffix check - componentTLDRegistered/componentTLDNotRegistered: Array TLD validation - componentIsPublicSuffix/componentNotPublicSuffix: Array public suffix validation - anyComponentMatches/noComponentMatches: Pattern matching on array children CLI options: - --psl-file: Custom PSL file path - --use-psl: Enable/disable PSL loading (default: true) - --data-dir: Data directory for external files - pcl update-data: Download/update PSL from publicsuffix.org --- README.md | 40 +++ cmd/pcl/main.go | 37 ++- internal/data/loader.go | 331 +++++++++++++++++++++ internal/data/loader_test.go | 269 +++++++++++++++++ internal/linter/config.go | 5 + internal/operator/component.go | 231 +++++++++++++++ internal/operator/component_cidr_test.go | 363 +++++++++++++++++++++++ internal/operator/operator.go | 14 + internal/operator/psl.go | 213 +++++++++++++ internal/operator/psl_test.go | 198 +++++++++++++ 10 files changed, 1700 insertions(+), 1 deletion(-) create mode 100644 internal/data/loader.go create mode 100644 internal/data/loader_test.go create mode 100644 internal/operator/component_cidr_test.go create mode 100644 internal/operator/psl.go create mode 100644 internal/operator/psl_test.go diff --git a/README.md b/README.md index e723159..f5050dd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,27 @@ pcl --policy --cert leaf.pem --auto-validate --ocsp-hash sha1 # RFC 501 pcl --policy --cert leaf.pem --auto-validate --ocsp-hash sha256 # Modern (default) ``` +### Public Suffix List (PSL) Options + +PCL uses the Public Suffix List to validate TLDs and domain names (BR 4.2.2, 3.2.2.6): + +```bash +# Use default PSL location (./data/public_suffix_list.dat or ~/.pcl/data/) +pcl --policy --cert cert.pem + +# Specify custom PSL file +pcl --policy --cert cert.pem --psl-file /path/to/public_suffix_list.dat + +# Disable PSL loading (use regex fallback) +pcl --policy --cert cert.pem --use-psl=false + +# Update/download PSL to default location +pcl update-data + +# Update PSL to custom directory +pcl update-data --data-dir /custom/data/path +``` + ### Debug OCSP Requests Use `-vv` to see detailed OCSP request/response information: @@ -244,6 +265,8 @@ rules: | `regex`, `notRegex` | Regular expression pattern matching | | `componentMaxLength`, `componentMinLength` | Per-component length validation (DNS labels, path segments) | | `componentRegex`, `componentNotRegex` | Per-component regex validation | +| `anyComponentMatches`, `noComponentMatches` | ANY/NO component matches regex (for wildcard detection) | +| `componentInCIDR`, `componentNotInCIDR` | Per-component CIDR range validation (for IP address checking) | | `utf8NoBom`, `containsBom` | UTF-8 BOM detection | ### Date Operators @@ -331,6 +354,23 @@ rules: | `isUTCTime` | Time encoding is UTCTime (tag 23) | | `isGeneralizedTime` | Time encoding is GeneralizedTime (tag 24) | +### Public Suffix List (PSL) / TLD Operators + +| Operator | Description | +|----------|-------------| +| `tldRegistered` | TLD is registered in IANA Root Zone Database (via PSL ICANN section) | +| `tldNotRegistered` | TLD is NOT registered in IANA Root Zone Database | +| `isPublicSuffix` | Domain is a public suffix (ICANN or private) | +| `isNotPublicSuffix` | Domain is NOT a public suffix | +| `componentTLDRegistered` | All domain components have registered TLDs (for SAN arrays) | +| `componentTLDNotRegistered` | No domain component has an unregistered TLD | +| `componentIsPublicSuffix` | FQDN portion of wildcard is a public suffix (BR 3.2.2.6) | +| `componentNotPublicSuffix` | FQDN portion of wildcard is NOT a public suffix | + +These operators use the Public Suffix List (PSL) from [publicsuffix.org](https://publicsuffix.org). The PSL ICANN section contains all IANA-registered TLDs, enabling validation of: +- **BR 4.2.2**: Internal Names - certificates must not contain domains with unregistered TLDs +- **BR 3.2.2.6**: Wildcard certificates - FQDN portion must not be a public suffix + ### ASN.1 Encoding Operators | Operator | Description | diff --git a/cmd/pcl/main.go b/cmd/pcl/main.go index 1d39c52..3705fb6 100644 --- a/cmd/pcl/main.go +++ b/cmd/pcl/main.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + "github.com/cavoq/PCL/internal/data" "github.com/cavoq/PCL/internal/linter" ) @@ -14,6 +15,16 @@ func newRootCmd(opts *linter.Config) *cobra.Command { root := &cobra.Command{ Use: "pcl", Short: "Policy-based X.509 certificate linter", + PreRunE: func(cmd *cobra.Command, args []string) error { + // Load PSL if specified or if file exists in default location + if opts.PSLFile != "" || opts.UsePSL { + if err := data.DefaultLoader.LoadPSL(opts.PSLFile); err != nil { + // If PSL loading fails, continue with regex fallback + fmt.Fprintf(os.Stderr, "Warning: PSL not loaded (%v), using regex fallback\n", err) + } + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { if len(opts.PolicyPaths) == 0 { return fmt.Errorf("--policy is required") @@ -56,13 +67,37 @@ func newRootCmd(opts *linter.Config) *cobra.Command { // OCSP request hash algorithm (RFC 5019 vs modern) root.Flags().StringVar(&opts.OCSPHashAlgorithm, "ocsp-hash", "sha256", "Hash algorithm for OCSP CertID: 'sha1' (RFC 5019) or 'sha256' (default, modern)") + // PSL/TLD data options + root.Flags().StringVar(&opts.PSLFile, "psl-file", "", "Path to Public Suffix List file (default: ./data/public_suffix_list.dat or ~/.pcl/data/public_suffix_list.dat)") + root.Flags().BoolVar(&opts.UsePSL, "use-psl", true, "Enable PSL loading for TLD validation (BR 4.2.2, 3.2.2.6)") + root.Flags().StringVar(&opts.DataDir, "data-dir", "", "Directory for external data files (default: ./data or ~/.pcl/data)") + return root } +func newUpdateDataCmd() *cobra.Command { + var dataDir string + + cmd := &cobra.Command{ + Use: "update-data", + Short: "Download and update external data files (PSL)", + RunE: func(cmd *cobra.Command, args []string) error { + return data.UpdateData(dataDir) + }, + } + + cmd.Flags().StringVar(&dataDir, "data-dir", "", "Directory to store data files (default: ./data or ~/.pcl/data)") + + return cmd +} + func main() { var opts linter.Config - if err := newRootCmd(&opts).Execute(); err != nil { + root := newRootCmd(&opts) + root.AddCommand(newUpdateDataCmd()) + + if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/internal/data/loader.go b/internal/data/loader.go new file mode 100644 index 0000000..332bda3 --- /dev/null +++ b/internal/data/loader.go @@ -0,0 +1,331 @@ +// Package data provides external data loading for PCL. +// +// Currently supports: +// - Public Suffix List (PSL) from publicsuffix.org +// - IANA Root Zone Database TLD list +// +// Data files can be updated via: +// pcl --update-data +package data + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// PSL represents the Public Suffix List loaded from external file. +// It contains both ICANN domains (official TLDs) and PRIVATE domains. +type PSL struct { + // ICANN domains - official TLDs from IANA Root Zone Database + ICANNDomains map[string]bool + + // Private domains - additional public suffixes (e.g., github.io) + PrivateDomains map[string]bool + + // All public suffixes (ICANN + Private combined) + AllPublicSuffixes map[string]bool + + // Metadata + LoadedAt time.Time + SourceFile string +} + +// Loader manages external data file loading. +type Loader struct { + psl *PSL + pslMutex sync.RWMutex + + // Default data directory + dataDir string +} + +// DefaultLoader is the global loader instance. +var DefaultLoader = &Loader{ + dataDir: getDefaultDataDir(), +} + +// getDefaultDataDir returns the default directory for data files. +// Checks: ./data, ~/.pcl/data, then falls back to embedded. +func getDefaultDataDir() string { + // 1. Current working directory ./data + if cwd, err := os.Getwd(); err == nil { + dataPath := filepath.Join(cwd, "data") + if _, err := os.Stat(dataPath); err == nil { + return dataPath + } + } + + // 2. User home directory ~/.pcl/data + if home, err := os.UserHomeDir(); err == nil { + dataPath := filepath.Join(home, ".pcl", "data") + if _, err := os.Stat(dataPath); err == nil { + return dataPath + } + } + + // 3. Fallback: return empty (will use embedded/regex fallback) + return "" +} + +// LoadPSL loads the Public Suffix List from file. +// If file doesn't exist, returns error (caller should handle fallback). +func (l *Loader) LoadPSL(filename string) error { + l.pslMutex.Lock() + defer l.pslMutex.Unlock() + + // Resolve file path + var filePath string + if filename != "" { + filePath = filename + } else if l.dataDir != "" { + filePath = filepath.Join(l.dataDir, "public_suffix_list.dat") + } else { + return fmt.Errorf("no PSL file specified and no data directory found") + } + + // Check file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return fmt.Errorf("PSL file not found: %s", filePath) + } + + // Parse file + psl, err := parsePSLFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse PSL file: %w", err) + } + + psl.SourceFile = filePath + psl.LoadedAt = time.Now() + l.psl = psl + + return nil +} + +// 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=== +func parsePSLFile(filePath string) (*PSL, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + psl := &PSL{ + ICANNDomains: make(map[string]bool), + PrivateDomains: make(map[string]bool), + AllPublicSuffixes: make(map[string]bool), + } + + scanner := bufio.NewScanner(file) + var section string // "icann", "private", or "" + + for scanner.Scan() { + line := scanner.Text() + + // Skip empty lines + if strings.TrimSpace(line) == "" { + continue + } + + // Detect section markers + if strings.Contains(line, "BEGIN ICANN DOMAINS") { + section = "icann" + continue + } + if strings.Contains(line, "END ICANN DOMAINS") { + section = "" + continue + } + if strings.Contains(line, "BEGIN PRIVATE DOMAINS") { + section = "private" + continue + } + if strings.Contains(line, "END PRIVATE DOMAINS") { + section = "" + continue + } + + // Skip comments (lines starting with //) + if strings.HasPrefix(line, "//") { + continue + } + + // Parse domain entry + domain := strings.TrimSpace(line) + if domain == "" { + continue + } + + // Add to appropriate section + switch section { + case "icann": + psl.ICANNDomains[domain] = true + psl.AllPublicSuffixes[domain] = true + case "private": + psl.PrivateDomains[domain] = true + psl.AllPublicSuffixes[domain] = true + default: + // Before any section marker - still valid entries + // Treat as ICANN (early entries in file are TLDs) + psl.ICANNDomains[domain] = true + psl.AllPublicSuffixes[domain] = true + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return psl, nil +} + +// GetPSL returns the loaded PSL, or nil if not loaded. +func (l *Loader) GetPSL() *PSL { + l.pslMutex.RLock() + defer l.pslMutex.RUnlock() + return l.psl +} + +// IsPublicSuffix checks if a domain is a public suffix. +// Uses loaded PSL if available, otherwise returns false. +func (l *Loader) IsPublicSuffix(domain string) bool { + l.pslMutex.RLock() + defer l.pslMutex.RUnlock() + + if l.psl == nil { + return false + } + + return l.psl.AllPublicSuffixes[domain] +} + +// IsICANNDomain checks if a domain is in ICANN section (official TLD). +func (l *Loader) IsICANNDomain(domain string) bool { + l.pslMutex.RLock() + defer l.pslMutex.RUnlock() + + if l.psl == nil { + return false + } + + return l.psl.ICANNDomains[domain] +} + +// TLDRegistered checks if a TLD is registered in IANA Root Zone. +// This checks the top-level label of the domain against ICANN domains. +func (l *Loader) TLDRegistered(domain string) bool { + l.pslMutex.RLock() + defer l.pslMutex.RUnlock() + + if l.psl == nil { + return false + } + + // Extract TLD (last label) + labels := strings.Split(domain, ".") + if len(labels) == 0 { + return false + } + + tld := labels[len(labels)-1] + return l.psl.ICANNDomains[tld] +} + +// Stats returns statistics about the loaded PSL. +func (l *Loader) Stats() (icann, private int, loaded bool) { + l.pslMutex.RLock() + defer l.pslMutex.RUnlock() + + if l.psl == nil { + return 0, 0, false + } + + return len(l.psl.ICANNDomains), len(l.psl.PrivateDomains), true +} + +// DownloadPSL downloads the Public Suffix List from publicsuffix.org. +func DownloadPSL(url string, destPath string) error { + if url == "" { + url = "https://publicsuffix.org/list/public_suffix_list.dat" + } + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download PSL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download PSL: HTTP %d", resp.StatusCode) + } + + // Ensure destination directory exists + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Write to file + file, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// UpdateData downloads and updates all external data files. +func UpdateData(dataDir string) error { + if dataDir == "" { + dataDir = getDefaultDataDir() + } + + if dataDir == "" { + // Create default data directory + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + dataDir = filepath.Join(home, ".pcl", "data") + } + + // Download PSL + pslPath := filepath.Join(dataDir, "public_suffix_list.dat") + fmt.Printf("Downloading Public Suffix List to: %s\n", pslPath) + if err := DownloadPSL("", pslPath); err != nil { + return fmt.Errorf("failed to update PSL: %w", err) + } + + // Load to verify + if err := DefaultLoader.LoadPSL(pslPath); err != nil { + return fmt.Errorf("failed to load downloaded PSL: %w", err) + } + + icann, private, _ := DefaultLoader.Stats() + 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 new file mode 100644 index 0000000..e22e6af --- /dev/null +++ b/internal/data/loader_test.go @@ -0,0 +1,269 @@ +package data + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParsePSLFile(t *testing.T) { + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_psl.dat") + + content := `// Public Suffix List test data +// ===BEGIN ICANN DOMAINS=== +com +net +org +edu +gov +// ===END ICANN DOMAINS=== +// ===BEGIN PRIVATE DOMAINS=== +github.io +blogspot.com +appspot.com +// ===END PRIVATE DOMAINS=== +` + + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + psl, err := parsePSLFile(testFile) + if err != nil { + t.Fatalf("Failed to parse PSL file: %v", err) + } + + // Verify ICANN domains + expectedICANN := []string{"com", "net", "org", "edu", "gov"} + for _, domain := range expectedICANN { + if !psl.ICANNDomains[domain] { + t.Errorf("Expected %s in ICANNDomains, but not found", domain) + } + } + + // Verify Private domains + expectedPrivate := []string{"github.io", "blogspot.com", "appspot.com"} + for _, domain := range expectedPrivate { + if !psl.PrivateDomains[domain] { + t.Errorf("Expected %s in PrivateDomains, but not found", domain) + } + } + + // Verify counts + if len(psl.ICANNDomains) != 5 { + t.Errorf("Expected 5 ICANN domains, got %d", len(psl.ICANNDomains)) + } + if len(psl.PrivateDomains) != 3 { + t.Errorf("Expected 3 private domains, got %d", len(psl.PrivateDomains)) + } + if len(psl.AllPublicSuffixes) != 8 { + t.Errorf("Expected 8 total public suffixes, got %d", len(psl.AllPublicSuffixes)) + } +} + +func TestIsPublicSuffix(t *testing.T) { + // Setup test loader + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_psl.dat") + + content := `// ===BEGIN ICANN DOMAINS=== +com +net +// ===END ICANN DOMAINS=== +// ===BEGIN PRIVATE DOMAINS=== +github.io +// ===END PRIVATE DOMAINS=== +` + os.WriteFile(testFile, []byte(content), 0644) + + loader := &Loader{dataDir: tmpDir} + if err := loader.LoadPSL(testFile); err != nil { + t.Fatalf("Failed to load PSL: %v", err) + } + + tests := []struct { + domain string + want bool + }{ + {"com", true}, + {"net", true}, + {"github.io", true}, + {"example", false}, + {"unknown.io", false}, + } + + for _, tt := range tests { + got := loader.IsPublicSuffix(tt.domain) + if got != tt.want { + t.Errorf("IsPublicSuffix(%s) = %v, want %v", tt.domain, got, tt.want) + } + } +} + +func TestTLDRegistered(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_psl.dat") + + content := `// ===BEGIN ICANN DOMAINS=== +com +net +org +uk +co.uk +// ===END ICANN DOMAINS=== +` + os.WriteFile(testFile, []byte(content), 0644) + + loader := &Loader{dataDir: tmpDir} + if err := loader.LoadPSL(testFile); err != nil { + t.Fatalf("Failed to load PSL: %v", err) + } + + tests := []struct { + 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 + } + + for _, tt := range tests { + got := loader.TLDRegistered(tt.domain) + if got != tt.want { + t.Errorf("TLDRegistered(%s) = %v, want %v", tt.domain, got, tt.want) + } + } +} + +func TestLoaderWithoutPSL(t *testing.T) { + loader := &Loader{dataDir: ""} + + // Should return false when no PSL loaded + if loader.IsPublicSuffix("com") { + t.Error("IsPublicSuffix should return false when PSL not loaded") + } + if loader.TLDRegistered("example.com") { + t.Error("TLDRegistered should return false when PSL not loaded") + } + + icann, private, loaded := loader.Stats() + if loaded { + t.Error("Stats should indicate not loaded") + } + if icann != 0 || private != 0 { + t.Error("Stats should return 0 counts when not loaded") + } +} + +func TestGetDefaultDataDir(t *testing.T) { + dir := getDefaultDataDir() + // Should return empty string if no data directory exists + // Or return path if data directory exists in cwd or home + t.Logf("Default data dir: %s", dir) +} + +func TestParsePSLWithCommentsAndWhitespace(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_psl.dat") + + content := `// This is a comment +// Another comment + + com + + net + +// ===BEGIN PRIVATE DOMAINS=== +// Comment in private section + github.io +// ===END PRIVATE DOMAINS=== +` + os.WriteFile(testFile, []byte(content), 0644) + + psl, err := parsePSLFile(testFile) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Should handle whitespace trimming + if !psl.ICANNDomains["com"] { + t.Error("Should have 'com' after trimming whitespace") + } + if !psl.ICANNDomains["net"] { + t.Error("Should have 'net' after trimming whitespace") + } + if !psl.PrivateDomains["github.io"] { + t.Error("Should have 'github.io' after trimming whitespace") + } +} + +func TestPSLWildcardDomains(t *testing.T) { + // PSL contains wildcard entries like *.ck which means all .ck subdomains + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_psl.dat") + + content := `// ===BEGIN ICANN DOMAINS=== +*.ck +*.jp +// ===END ICANN DOMAINS=== +` + os.WriteFile(testFile, []byte(content), 0644) + + psl, err := parsePSLFile(testFile) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Wildcards are stored as-is (e.g., "*.ck") + if !psl.ICANNDomains["*.ck"] { + t.Error("Should have '*.ck' wildcard entry") + } + + // Test wildcard matching logic separately + tests := []struct { + domain string + wildcard string + expected bool + }{ + {"com.ck", "*.ck", true}, + {"edu.ck", "*.ck", true}, + {"ck", "*.ck", false}, // TLD itself doesn't match wildcard + {"example.jp", "*.jp", true}, + } + + for _, tt := range tests { + matches := matchesWildcard(tt.domain, tt.wildcard) + if matches != tt.expected { + t.Errorf("matchesWildcard(%s, %s) = %v, want %v", tt.domain, tt.wildcard, matches, tt.expected) + } + } +} + +// matchesWildcard checks if a domain matches a PSL wildcard entry. +// PSL wildcard "*.ck" means any second-level domain under .ck is a public suffix. +func matchesWildcard(domain, wildcard string) bool { + if !strings.HasPrefix(wildcard, "*.") { + return false + } + + // Get the suffix part after "*." + suffix := strings.TrimPrefix(wildcard, "*.") + + // Check if domain ends with the suffix and has exactly one extra label + labels := strings.Split(domain, ".") + suffixLabels := strings.Split(suffix, ".") + + if len(labels) == len(suffixLabels)+1 && strings.HasSuffix(domain, "."+suffix) { + return true + } + + return false +} \ No newline at end of file diff --git a/internal/linter/config.go b/internal/linter/config.go index 0f16040..e1c093d 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -32,4 +32,9 @@ type Config struct { // OCSP request hash algorithm OCSPHashAlgorithm string // Hash algorithm for CertID: "sha1" (RFC 5019) or "sha256" (default, modern) + + // PSL/TLD data options + PSLFile string // Path to Public Suffix List file (optional) + UsePSL bool // Enable PSL loading (default: true if file exists) + DataDir string // Directory for external data files (optional) } diff --git a/internal/operator/component.go b/internal/operator/component.go index 95c2ce7..b83f39d 100644 --- a/internal/operator/component.go +++ b/internal/operator/component.go @@ -2,6 +2,7 @@ package operator import ( "fmt" + "net" "regexp" "strings" @@ -262,4 +263,234 @@ func validateComponentNotRegex(str string, re *regexp.Regexp, delimiter string) } } return true +} + +// ComponentInCIDR checks if any component (IP address) is within any of the specified CIDR ranges. +// Useful for checking if IP addresses fall within reserved ranges. +// +// Handles: +// - Parent node with children (like iPAddress with indexed children) +// - Single IP address string value +// +// Operands: list of CIDR strings (e.g., ["10.0.0.0/8", "192.168.0.0/16"]) +// Returns true if at least one IP is within any CIDR range. +type ComponentInCIDR struct{} + +func (ComponentInCIDR) Name() string { return "componentInCIDR" } + +func (ComponentInCIDR) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + // Parse CIDR ranges from operands + cidrs := parseCIDROperands(operands) + if len(cidrs) == 0 { + return false, fmt.Errorf("componentInCIDR requires valid CIDR operands") + } + + // Handle parent node with children (iPAddress array) + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if ipInCIDRs(str, cidrs) { + return true, nil + } + } + return false, nil + } + + // Handle single IP address string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return ipInCIDRs(str, cidrs), nil +} + +// ComponentNotInCIDR checks if all components (IP addresses) are NOT within any of the specified CIDR ranges. +// Useful for validating that IP addresses are not reserved/private. +// +// Handles: +// - Parent node with children (like iPAddress with indexed children) +// - Single IP address string value +// +// Operands: list of CIDR strings (e.g., ["10.0.0.0/8", "192.168.0.0/16"]) +// Returns true if ALL IPs are outside ALL CIDR ranges. +type ComponentNotInCIDR struct{} + +func (ComponentNotInCIDR) Name() string { return "componentNotInCIDR" } + +func (ComponentNotInCIDR) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + // Parse CIDR ranges from operands + cidrs := parseCIDROperands(operands) + if len(cidrs) == 0 { + return false, fmt.Errorf("componentNotInCIDR requires valid CIDR operands") + } + + // Handle parent node with children (iPAddress array) + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if ipInCIDRs(str, cidrs) { + // Found an IP in a reserved range - validation fails + return false, nil + } + } + // All IPs are outside reserved ranges - validation passes + return true, nil + } + + // Handle single IP address string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return !ipInCIDRs(str, cidrs), nil +} + +// parseCIDROperands converts operand list to CIDR network slices +func parseCIDROperands(operands []any) []*net.IPNet { + var cidrs []*net.IPNet + for _, op := range operands { + cidrStr, ok := op.(string) + if !ok { + continue + } + _, ipNet, err := net.ParseCIDR(cidrStr) + if err != nil { + continue // Skip invalid CIDR + } + cidrs = append(cidrs, ipNet) + } + return cidrs +} + +// ipInCIDRs checks if an IP address string is within any of the CIDR ranges +func ipInCIDRs(ipStr string, cidrs []*net.IPNet) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false // Invalid IP address + } + + for _, cidr := range cidrs { + if cidr.Contains(ip) { + return true + } + } + return false +} + +// AnyComponentMatches checks if ANY component (child value) matches the regex pattern. +// Unlike componentRegex which splits by delimiter, this checks the entire value. +// Useful for detecting wildcard domains (pattern "^\\*.") in SAN arrays. +// +// Handles: +// - Parent node with children: returns true if ANY child matches +// - Single string value: matches against the entire string +// +// Operands: [pattern] +// Returns true if at least one component matches. +type AnyComponentMatches struct{} + +func (AnyComponentMatches) Name() string { return "anyComponentMatches" } + +func (AnyComponentMatches) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + pattern, ok := operands[0].(string) + if !ok { + return false, fmt.Errorf("anyComponentMatches requires string pattern operand") + } + + re, err := getCompiledRegex(pattern) + if err != nil { + return false, err + } + + // Handle parent node with children + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if re.MatchString(str) { + return true, nil + } + } + return false, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return re.MatchString(str), nil +} + +// NoComponentMatches checks if NO component (child value) matches the regex pattern. +// Opposite of anyComponentMatches. +// +// Handles: +// - Parent node with children: returns true if NONE of the children match +// - Single string value: returns true if the string doesn't match +// +// Operands: [pattern] +type NoComponentMatches struct{} + +func (NoComponentMatches) Name() string { return "noComponentMatches" } + +func (NoComponentMatches) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil || len(operands) < 1 { + return false, nil + } + + pattern, ok := operands[0].(string) + if !ok { + return false, fmt.Errorf("noComponentMatches requires string pattern operand") + } + + re, err := getCompiledRegex(pattern) + if err != nil { + return false, err + } + + // Handle parent node with children + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + if re.MatchString(str) { + return false, nil + } + } + return true, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return !re.MatchString(str), nil } \ No newline at end of file diff --git a/internal/operator/component_cidr_test.go b/internal/operator/component_cidr_test.go new file mode 100644 index 0000000..f2b18c4 --- /dev/null +++ b/internal/operator/component_cidr_test.go @@ -0,0 +1,363 @@ +package operator + +import ( + "net" + "testing" + + "github.com/cavoq/PCL/internal/node" +) + +func TestComponentInCIDR(t *testing.T) { + tests := []struct { + name string + node *node.Node + operands []any + want bool + }{ + { + name: "single IP in range", + node: node.New("iPAddress", "10.0.0.1"), + operands: []any{"10.0.0.0/8"}, + want: true, + }, + { + name: "single IP not in range", + node: node.New("iPAddress", "8.8.8.8"), + operands: []any{"10.0.0.0/8"}, + want: false, + }, + { + name: "multiple CIDRs - IP in first", + node: node.New("iPAddress", "10.1.2.3"), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: true, + }, + { + name: "multiple CIDRs - IP in second", + node: node.New("iPAddress", "192.168.1.1"), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: true, + }, + { + name: "multiple CIDRs - IP in neither", + node: node.New("iPAddress", "1.1.1.1"), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: false, + }, + { + name: "IPv6 in range", + node: node.New("iPAddress", "::1"), + operands: []any{"::1/128"}, + want: true, + }, + { + name: "IPv6 not in range", + node: node.New("iPAddress", "2001:db8::1"), + operands: []any{"::1/128", "fe80::/10"}, + want: false, + }, + { + name: "IPv6 link-local in range", + node: node.New("iPAddress", "fe80::1234"), + operands: []any{"fe80::/10"}, + want: true, + }, + { + name: "array of IPs - one in range", + node: func() *node.Node { + n := node.New("iPAddress", nil) + n.Children["0"] = node.New("0", "8.8.8.8") + n.Children["1"] = node.New("1", "10.0.0.1") + return n + }(), + operands: []any{"10.0.0.0/8"}, + want: true, + }, + { + name: "array of IPs - none in range", + node: func() *node.Node { + n := node.New("iPAddress", nil) + n.Children["0"] = node.New("0", "8.8.8.8") + n.Children["1"] = node.New("1", "1.1.1.1") + return n + }(), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: false, + }, + { + name: "invalid CIDR operand skipped", + node: node.New("iPAddress", "10.0.0.1"), + operands: []any{"invalid-cidr", "10.0.0.0/8"}, + want: true, + }, + { + name: "invalid IP address", + node: node.New("iPAddress", "not-an-ip"), + operands: []any{"10.0.0.0/8"}, + want: false, + }, + { + name: "nil node", + node: nil, + operands: []any{"10.0.0.0/8"}, + want: false, + }, + { + name: "no operands", + node: node.New("iPAddress", "10.0.0.1"), + operands: []any{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := ComponentInCIDR{} + got, err := op.Evaluate(tt.node, nil, tt.operands) + if err != nil && tt.want != false { + t.Errorf("ComponentInCIDR.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("ComponentInCIDR.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestComponentNotInCIDR(t *testing.T) { + tests := []struct { + name string + node *node.Node + operands []any + want bool + }{ + { + name: "single IP not in range - passes", + node: node.New("iPAddress", "8.8.8.8"), + operands: []any{"10.0.0.0/8"}, + want: true, + }, + { + name: "single IP in range - fails", + node: node.New("iPAddress", "10.0.0.1"), + operands: []any{"10.0.0.0/8"}, + want: false, + }, + { + name: "multiple CIDRs - IP outside all - passes", + node: node.New("iPAddress", "1.1.1.1"), + operands: []any{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}, + want: true, + }, + { + name: "multiple CIDRs - IP in one - fails", + node: node.New("iPAddress", "172.17.0.1"), + operands: []any{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12"}, + want: false, + }, + { + name: "loopback - fails", + node: node.New("iPAddress", "127.0.0.1"), + operands: []any{"127.0.0.0/8"}, + want: false, + }, + { + name: "public IP - passes", + node: node.New("iPAddress", "93.184.216.34"), // example.com + operands: []any{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"}, + want: true, + }, + { + name: "IPv6 not in range - passes", + node: node.New("iPAddress", "2001:db8::1"), + operands: []any{"::1/128", "fe80::/10"}, + want: true, + }, + { + name: "IPv6 link-local - fails", + node: node.New("iPAddress", "fe80::1"), + operands: []any{"fe80::/10"}, + want: false, + }, + { + name: "array of IPs - all outside - passes", + node: func() *node.Node { + n := node.New("iPAddress", nil) + n.Children["0"] = node.New("0", "8.8.8.8") + n.Children["1"] = node.New("1", "1.1.1.1") + return n + }(), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: true, + }, + { + name: "array of IPs - one in range - fails", + node: func() *node.Node { + n := node.New("iPAddress", nil) + n.Children["0"] = node.New("0", "8.8.8.8") + n.Children["1"] = node.New("1", "192.168.1.1") + return n + }(), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: false, + }, + { + name: "array of IPs - all in range - fails", + node: func() *node.Node { + n := node.New("iPAddress", nil) + n.Children["0"] = node.New("0", "10.0.0.1") + n.Children["1"] = node.New("1", "192.168.1.1") + return n + }(), + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + want: false, + }, + { + name: "nil node", + node: nil, + operands: []any{"10.0.0.0/8"}, + want: false, + }, + { + name: "no operands", + node: node.New("iPAddress", "8.8.8.8"), + operands: []any{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := ComponentNotInCIDR{} + got, err := op.Evaluate(tt.node, nil, tt.operands) + if err != nil && tt.want != false { + t.Errorf("ComponentNotInCIDR.Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("ComponentNotInCIDR.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseCIDROperands(t *testing.T) { + tests := []struct { + name string + operands []any + wantLen int + }{ + { + name: "valid CIDRs", + operands: []any{"10.0.0.0/8", "192.168.0.0/16"}, + wantLen: 2, + }, + { + name: "mixed valid and invalid", + operands: []any{"10.0.0.0/8", "invalid", "192.168.0.0/16"}, + wantLen: 2, + }, + { + name: "all invalid", + operands: []any{"invalid", "also-invalid"}, + wantLen: 0, + }, + { + name: "non-string operand", + operands: []any{123, "10.0.0.0/8"}, + wantLen: 1, + }, + { + name: "empty operands", + operands: []any{}, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cidrs := parseCIDROperands(tt.operands) + if len(cidrs) != tt.wantLen { + t.Errorf("parseCIDROperands() length = %v, want %v", len(cidrs), tt.wantLen) + } + }) + } +} + +func TestIPInCIDRs(t *testing.T) { + // Setup CIDRs + _, cidr10, _ := net.ParseCIDR("10.0.0.0/8") + _, cidr192, _ := net.ParseCIDR("192.168.0.0/16") + _, cidr172, _ := net.ParseCIDR("172.16.0.0/12") + _, cidr127, _ := net.ParseCIDR("127.0.0.0/8") + + tests := []struct { + name string + ip string + cidrs []*net.IPNet + want bool + }{ + { + name: "IP in 10.0.0.0/8", + ip: "10.1.2.3", + cidrs: []*net.IPNet{cidr10}, + want: true, + }, + { + name: "IP in 192.168.0.0/16", + ip: "192.168.100.50", + cidrs: []*net.IPNet{cidr192}, + want: true, + }, + { + name: "IP in 172.16.0.0/12 (172.16-31)", + ip: "172.20.5.10", + cidrs: []*net.IPNet{cidr172}, + want: true, + }, + { + name: "IP at boundary of 172.16.0.0/12", + ip: "172.15.255.255", // Just below 172.16 + cidrs: []*net.IPNet{cidr172}, + want: false, + }, + { + name: "IP at upper boundary of 172.16.0.0/12", + ip: "172.31.255.255", + cidrs: []*net.IPNet{cidr172}, + want: true, + }, + { + name: "IP outside all CIDRs", + ip: "8.8.8.8", + cidrs: []*net.IPNet{cidr10, cidr192, cidr172, cidr127}, + want: false, + }, + { + name: "loopback", + ip: "127.0.0.1", + cidrs: []*net.IPNet{cidr127}, + want: true, + }, + { + name: "invalid IP", + ip: "not-an-ip", + cidrs: []*net.IPNet{cidr10}, + want: false, + }, + { + name: "empty CIDRs", + ip: "10.0.0.1", + cidrs: []*net.IPNet{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ipInCIDRs(tt.ip, tt.cidrs) + if got != tt.want { + t.Errorf("ipInCIDRs() = %v, want %v", got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/operator/operator.go b/internal/operator/operator.go index f4a4635..ce8244c 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -67,6 +67,20 @@ var All = []Operator{ ComponentMinLength{}, ComponentRegex{}, ComponentNotRegex{}, + AnyComponentMatches{}, + NoComponentMatches{}, + // CIDR range validation operators (for IP address checking) + ComponentInCIDR{}, + ComponentNotInCIDR{}, + // PSL/TLD validation operators (for domain name checking) + TLDRegistered{}, + TLDNotRegistered{}, + IsPublicSuffix{}, + IsNotPublicSuffix{}, + ComponentTLDRegistered{}, + ComponentTLDNotRegistered{}, + ComponentIsPublicSuffix{}, + ComponentNotPublicSuffix{}, // UTF-8 validation operators UTF8NoBOM{}, ContainsBOM{}, diff --git a/internal/operator/psl.go b/internal/operator/psl.go new file mode 100644 index 0000000..f3c97a6 --- /dev/null +++ b/internal/operator/psl.go @@ -0,0 +1,213 @@ +package operator + +import ( + "strings" + + "github.com/cavoq/PCL/internal/data" + "github.com/cavoq/PCL/internal/node" +) + +// TLDRegistered checks if the domain's TLD is in IANA Root Zone Database. +// Uses external PSL data (ICANN section) for validation. +// +// Returns true if: +// - PSL is loaded AND domain's TLD exists in ICANN domains list +// +// Returns false if: +// - PSL not loaded (no external data) +// - Domain's TLD not in ICANN list (Internal Name) +// +// Useful for BR 4.2.2: "CAs SHALL NOT issue Certificates containing Internal Names" +type TLDRegistered struct{} + +func (TLDRegistered) Name() string { return "tldRegistered" } + +func (TLDRegistered) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Get domain value + var domain string + switch v := n.Value.(type) { + case string: + domain = v + default: + return false, nil + } + + // Check via data loader + return data.DefaultLoader.TLDRegistered(domain), nil +} + +// TLDNotRegistered checks if the domain's TLD is NOT in IANA Root Zone Database. +// Inverse of TLDRegistered - useful for error rules. +// +// Returns true if domain is an Internal Name (TLD not registered). +type TLDNotRegistered struct{} + +func (TLDNotRegistered) Name() string { return "tldNotRegistered" } + +func (TLDNotRegistered) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { + reg, err := TLDRegistered{}.Evaluate(n, ctx, operands) + if err != nil { + return false, err + } + return !reg, nil +} + +// IsPublicSuffix checks if a domain is a public suffix. +// Uses external PSL data (ICANN + PRIVATE sections). +// +// Returns true if: +// - PSL is loaded AND domain exists in public suffix list +// +// Useful for BR 3.2.2.6: Wildcard certificate validation +type IsPublicSuffix struct{} + +func (IsPublicSuffix) Name() string { return "isPublicSuffix" } + +func (IsPublicSuffix) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + var domain string + switch v := n.Value.(type) { + case string: + domain = v + default: + return false, nil + } + + // Check via data loader + return data.DefaultLoader.IsPublicSuffix(domain), nil +} + +// IsNotPublicSuffix checks if a domain is NOT a public suffix. +type IsNotPublicSuffix struct{} + +func (IsNotPublicSuffix) Name() string { return "isNotPublicSuffix" } + +func (IsNotPublicSuffix) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { + isPS, err := IsPublicSuffix{}.Evaluate(n, ctx, operands) + if err != nil { + return false, err + } + return !isPS, nil +} + +// ComponentTLDRegistered checks TLD registration for each component in an array. +// Useful for validating multiple DNS names in subjectAltName. +type ComponentTLDRegistered struct{} + +func (ComponentTLDRegistered) Name() string { return "componentTLDRegistered" } + +func (ComponentTLDRegistered) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Handle parent node with children (dNSName array) + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + // All domains must have registered TLDs + if !data.DefaultLoader.TLDRegistered(str) { + return false, nil + } + } + return true, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + return data.DefaultLoader.TLDRegistered(str), nil +} + +// ComponentTLDNotRegistered checks if ANY component has unregistered TLD. +// Returns true if at least one domain is an Internal Name. +type ComponentTLDNotRegistered struct{} + +func (ComponentTLDNotRegistered) Name() string { return "componentTLDNotRegistered" } + +func (ComponentTLDNotRegistered) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { + reg, err := ComponentTLDRegistered{}.Evaluate(n, ctx, operands) + if err != nil { + return false, err + } + return !reg, nil +} + +// ComponentIsPublicSuffix checks if ANY component is a public suffix. +// Useful for wildcard validation - FQDN portion must not be public suffix. +type ComponentIsPublicSuffix struct{} + +func (ComponentIsPublicSuffix) Name() string { return "componentIsPublicSuffix" } + +func (ComponentIsPublicSuffix) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { + if n == nil { + return false, nil + } + + // Handle parent node with children + if n.Value == nil && len(n.Children) > 0 { + for _, child := range n.Children { + str, ok := child.Value.(string) + if !ok { + continue + } + // Check if this domain is a public suffix + if data.DefaultLoader.IsPublicSuffix(str) { + return true, nil + } + // For wildcards, check FQDN portion + if strings.HasPrefix(str, "*.") { + fqdnPortion := strings.TrimPrefix(str, "*.") + if data.DefaultLoader.IsPublicSuffix(fqdnPortion) { + return true, nil + } + } + } + return false, nil + } + + // Handle single string value + str, ok := n.Value.(string) + if !ok { + return false, nil + } + + // Direct check + if data.DefaultLoader.IsPublicSuffix(str) { + return true, nil + } + + // For wildcards, check FQDN portion + if strings.HasPrefix(str, "*.") { + fqdnPortion := strings.TrimPrefix(str, "*.") + return data.DefaultLoader.IsPublicSuffix(fqdnPortion), nil + } + + return false, nil +} + +// ComponentNotPublicSuffix checks ALL components are NOT public suffixes. +type ComponentNotPublicSuffix struct{} + +func (ComponentNotPublicSuffix) Name() string { return "componentNotPublicSuffix" } + +func (ComponentNotPublicSuffix) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (bool, error) { + isPS, err := ComponentIsPublicSuffix{}.Evaluate(n, ctx, operands) + if err != nil { + return false, err + } + return !isPS, nil +} \ No newline at end of file diff --git a/internal/operator/psl_test.go b/internal/operator/psl_test.go new file mode 100644 index 0000000..45d0194 --- /dev/null +++ b/internal/operator/psl_test.go @@ -0,0 +1,198 @@ +package operator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cavoq/PCL/internal/data" + "github.com/cavoq/PCL/internal/node" +) + +func getTestPSLPath() string { + // Try multiple locations + candidates := []string{ + "data/public_suffix_list.dat", // From project root + filepath.Join("..", "..", "data", "public_suffix_list.dat"), // From internal/operator + } + + // Also check if we're in test mode with cwd + if cwd, _ := os.Getwd(); cwd != "" { + candidates = append(candidates, + filepath.Join(cwd, "data", "public_suffix_list.dat"), + filepath.Join(cwd, "..", "..", "data", "public_suffix_list.dat"), + ) + } + + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func TestTLDRegistered(t *testing.T) { + pslPath := getTestPSLPath() + if pslPath == "" { + t.Skip("PSL file not found, skipping test") + } + if err := data.DefaultLoader.LoadPSL(pslPath); err != nil { + t.Skipf("PSL not available: %v", err) + } + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "valid TLD .com", + node: node.New("dNSName", "example.com"), + want: true, + }, + { + name: "valid TLD .net", + node: node.New("dNSName", "test.net"), + want: true, + }, + { + name: "reserved TLD .test", + node: node.New("dNSName", "example.test"), + want: false, + }, + { + name: "reserved TLD .local", + node: node.New("dNSName", "server.local"), + want: false, + }, + { + name: "reserved TLD .internal", + node: node.New("dNSName", "host.internal"), + want: false, + }, + } + + op := TLDRegistered{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("TLDRegistered(%s) = %v, want %v", tt.node.Value, got, tt.want) + } + }) + } +} + +func TestComponentTLDRegistered(t *testing.T) { + pslPath := getTestPSLPath() + if pslPath == "" { + t.Skip("PSL file not found, skipping test") + } + if err := data.DefaultLoader.LoadPSL(pslPath); err != nil { + t.Skipf("PSL not available: %v", err) + } + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "all domains have valid TLDs", + node: func() *node.Node { + n := node.New("dNSName", nil) + n.Children["0"] = node.New("0", "example.com") + n.Children["1"] = node.New("1", "test.org") + return n + }(), + want: true, + }, + { + name: "one domain has invalid TLD", + node: func() *node.Node { + n := node.New("dNSName", nil) + n.Children["0"] = node.New("0", "example.com") + n.Children["1"] = node.New("1", "server.local") + return n + }(), + want: false, + }, + { + name: "all domains have invalid TLDs", + node: func() *node.Node { + n := node.New("dNSName", nil) + n.Children["0"] = node.New("0", "server.test") + n.Children["1"] = node.New("1", "host.internal") + return n + }(), + want: false, + }, + } + + op := ComponentTLDRegistered{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("ComponentTLDRegistered = %v, want %v", got, tt.want) + } + }) + } +} + +func TestComponentIsPublicSuffix(t *testing.T) { + pslPath := getTestPSLPath() + if pslPath == "" { + t.Skip("PSL file not found, skipping test") + } + if err := data.DefaultLoader.LoadPSL(pslPath); err != nil { + t.Skipf("PSL not available: %v", err) + } + + tests := []struct { + name string + node *node.Node + want bool + }{ + { + name: "wildcard *.com - FQDN portion is public suffix", + node: node.New("dNSName", "*.com"), + want: true, + }, + { + name: "wildcard *.example.com - not public suffix", + node: node.New("dNSName", "*.example.com"), + want: false, + }, + { + name: "wildcard *.github.io - FQDN is private public suffix", + node: node.New("dNSName", "*.github.io"), + want: true, + }, + { + name: "non-wildcard normal domain", + node: node.New("dNSName", "www.example.com"), + want: false, + }, + } + + op := ComponentIsPublicSuffix{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.Evaluate(tt.node, nil, nil) + if err != nil { + t.Errorf("Evaluate() error = %v", err) + } + if got != tt.want { + t.Errorf("ComponentIsPublicSuffix(%s) = %v, want %v", tt.node.Value, got, tt.want) + } + }) + } +} \ No newline at end of file From 8bd5896f865601f79ced38be4506835742071591 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 15:17:54 +0800 Subject: [PATCH 18/44] feat: add ASN.1 parsers for AIA and CRL DP extensions Parse Authority Information Access and CRL Distribution Points extensions into structured node trees for policy validation: - AIA: accessDescriptions with accessMethod, accessLocation.type/scheme - CRL DP: distributionPoints with fullName, reasons, cRLIssuer flags - Add friendly extension names (authorityInfoAccess, cRLDistributionPoints) - Add date comparison operators (dateGt, dateLt, dateGte, dateLte) --- internal/cert/zcrypto/builder.go | 23 ++ internal/cert/zcrypto/extension_parser.go | 346 ++++++++++++++++ .../cert/zcrypto/extension_parser_test.go | 378 ++++++++++++++++++ internal/operator/date.go | 50 ++- internal/operator/date_test.go | 126 ++++++ internal/zcrypto/helpers.go | 36 +- internal/zcrypto/helpers_test.go | 24 +- 7 files changed, 973 insertions(+), 10 deletions(-) create mode 100644 internal/cert/zcrypto/extension_parser.go create mode 100644 internal/cert/zcrypto/extension_parser_test.go diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 7c2b2ed..6536ec8 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -58,6 +58,29 @@ func buildCertificate(cert *x509.Certificate) *node.Node { if len(cert.Extensions) > 0 { root.Children["extensions"] = zcrypto.BuildExtensions(cert.Extensions) + + // Parse AIA extension with full ASN.1 structure + for _, ext := range cert.Extensions { + oidStr := ext.Id.String() + if oidStr == "1.3.6.1.5.5.7.1.1" { + aiaNode := ParseAIA(ext.Value) + if extNode, ok := root.Children["extensions"].Children["authorityInfoAccess"]; ok { + // Merge parsed AIA into extension node + for k, v := range aiaNode.Children { + extNode.Children[k] = v + } + } + } + if oidStr == "2.5.29.31" { + crlDPNode := ParseCRLDP(ext.Value) + if extNode, ok := root.Children["extensions"].Children["cRLDistributionPoints"]; ok { + // Merge parsed CRL DP into extension node + for k, v := range crlDPNode.Children { + extNode.Children[k] = v + } + } + } + } } root.Children["keyUsage"] = buildKeyUsage(cert.KeyUsage) diff --git a/internal/cert/zcrypto/extension_parser.go b/internal/cert/zcrypto/extension_parser.go new file mode 100644 index 0000000..238f14e --- /dev/null +++ b/internal/cert/zcrypto/extension_parser.go @@ -0,0 +1,346 @@ +package zcrypto + +import ( + "fmt" + "strings" + + "github.com/cavoq/PCL/internal/node" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +// AIA OID: 1.3.6.1.5.5.7.1.1 +const aiaOID = "1.3.6.1.5.5.7.1.1" + +// CRL Distribution Points OID: 2.5.29.31 +const crlDPOID = "2.5.29.31" + +// ParseAIA parses the Authority Information Access extension (OID 1.3.6.1.5.5.7.1.1) +// and returns a node tree with accessDescriptions. +// +// ASN.1 structure (RFC 5280 4.2.2.1): +// AuthorityInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription +// AccessDescription ::= SEQUENCE { +// accessMethod OBJECT IDENTIFIER, +// accessLocation GeneralName } +// +// GeneralName context-specific tags: +// 0: otherName, 1: rfc822Name, 2: dNSName, 3: x400Address, +// 4: directoryName, 5: ediPartyName, 6: uniformResourceIdentifier, +// 7: iPAddress, 8: registeredID +func ParseAIA(extValue []byte) *node.Node { + n := node.New("authorityInfoAccess", nil) + accessDescriptionsNode := node.New("accessDescriptions", nil) + n.Children["accessDescriptions"] = accessDescriptionsNode + + input := cryptobyte.String(extValue) + + var ads cryptobyte.String + if !input.ReadASN1(&ads, cryptobyte_asn1.SEQUENCE) { + return n + } + + // Empty AIA is invalid per BR + if len(ads) == 0 { + n.Children["empty"] = node.New("empty", true) + return n + } + + n.Children["empty"] = node.New("empty", false) + + idx := 0 + for len(ads) > 0 { + var ad cryptobyte.String + if !ads.ReadASN1(&ad, cryptobyte_asn1.SEQUENCE) { + break + } + + adNode := node.New(fmt.Sprintf("%d", idx), nil) + + // Read accessMethod (OID) + var accessMethod cryptobyte.String + if !ad.ReadASN1(&accessMethod, cryptobyte_asn1.OBJECT_IDENTIFIER) { + break + } + methodOID := oidString(accessMethod) + adNode.Children["accessMethod"] = node.New("accessMethod", methodOID) + + // Read accessLocation (GeneralName - context-specific tagged) + var location cryptobyte.String + var locationTag cryptobyte_asn1.Tag + if !ad.ReadAnyASN1(&location, &locationTag) { + break + } + + locationNode := node.New("accessLocation", nil) + contextTag := int(locationTag & 0x1F) + locationType := generalNameType(contextTag) + locationNode.Children["type"] = node.New("type", locationType) + locationNode.Children["tag"] = node.New("tag", contextTag) + + // For URI (tag 6), extract the URI string + if contextTag == 6 { + uri := string(location) + locationNode.Children["value"] = node.New("value", uri) + // Extract scheme for convenience + if strings.Contains(uri, ":") { + scheme := strings.Split(uri, ":")[0] + locationNode.Children["scheme"] = node.New("scheme", scheme) + } + } else if contextTag == 2 { + // DNS name + locationNode.Children["value"] = node.New("value", string(location)) + } else if contextTag == 7 { + // IP address - 4 bytes for IPv4, 16 bytes for IPv6 + if len(location) == 4 || len(location) == 16 { + locationNode.Children["value"] = node.New("value", location) + } + } else { + // Other types - store raw bytes + locationNode.Children["value"] = node.New("value", location) + } + + adNode.Children["accessLocation"] = locationNode + accessDescriptionsNode.Children[fmt.Sprintf("%d", idx)] = adNode + idx++ + } + + n.Children["count"] = node.New("count", idx) + + // Convenience: check if contains OCSP and/or CA Issuers + hasOCSP := false + hasCaIssuers := false + for i := 0; i < idx; i++ { + if adNode, ok := accessDescriptionsNode.Children[fmt.Sprintf("%d", i)]; ok { + if methodNode, ok := adNode.Children["accessMethod"]; ok { + method := methodNode.Value.(string) + if method == "1.3.6.1.5.5.7.48.1" { // id-ad-ocsp + hasOCSP = true + } + if method == "1.3.6.1.5.5.7.48.2" { // id-ad-caIssuers + hasCaIssuers = true + } + } + } + } + n.Children["containsOCSP"] = node.New("containsOCSP", hasOCSP) + n.Children["containsCaIssuers"] = node.New("containsCaIssuers", hasCaIssuers) + + return n +} + +// ParseCRLDP parses the CRL Distribution Points extension (OID 2.5.29.31) +// and returns a node tree with distributionPoints. +// +// ASN.1 structure (RFC 5280 4.2.1.13): +// CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint +// DistributionPoint ::= SEQUENCE { +// distributionPoint [0] DistributionPointName OPTIONAL, +// reasons [1] ReasonFlags OPTIONAL, +// cRLIssuer [2] GeneralNames OPTIONAL } +// DistributionPointName ::= CHOICE { +// fullName [0] GeneralNames, +// nameRelativeToCRLIssuer [1] RelativeDistinguishedName } +func ParseCRLDP(extValue []byte) *node.Node { + n := node.New("cRLDistributionPoints", nil) + distributionPointsNode := node.New("distributionPoints", nil) + n.Children["distributionPoints"] = distributionPointsNode + + input := cryptobyte.String(extValue) + + var dps cryptobyte.String + if !input.ReadASN1(&dps, cryptobyte_asn1.SEQUENCE) { + return n + } + + // Empty CRL DP is invalid per BR + if len(dps) == 0 { + n.Children["empty"] = node.New("empty", true) + return n + } + + n.Children["empty"] = node.New("empty", false) + + idx := 0 + for len(dps) > 0 { + var dp cryptobyte.String + if !dps.ReadASN1(&dp, cryptobyte_asn1.SEQUENCE) { + break + } + + dpNode := node.New(fmt.Sprintf("%d", idx), nil) + + // Parse DistributionPoint fields + // [0] distributionPoint, [1] reasons, [2] cRLIssuer + hasFullName := false + hasReasons := false + hasCRLIssuer := false + + for len(dp) > 0 { + var field cryptobyte.String + var tag cryptobyte_asn1.Tag + if !dp.ReadAnyASN1(&field, &tag) { + break + } + + contextTag := int(tag & 0x1F) + + switch contextTag { + case 0: // distributionPoint [0] + dpNameNode := node.New("distributionPoint", nil) + // Check if fullName [0] or nameRelativeToCRLIssuer [1] + var inner cryptobyte.String + var innerTag cryptobyte_asn1.Tag + if field.ReadAnyASN1(&inner, &innerTag) { + innerTagNum := int(innerTag & 0x1F) + if innerTagNum == 0 { + // fullName [0] GeneralNames + fullNameNode := node.New("fullName", nil) + generalNamesNode := node.New("generalNames", nil) + uriIdx := 0 + for len(inner) > 0 { + var name cryptobyte.String + var nameTag cryptobyte_asn1.Tag + if !inner.ReadAnyASN1(&name, &nameTag) { + break + } + nameTagNum := int(nameTag & 0x1F) + gnNode := node.New(fmt.Sprintf("%d", uriIdx), nil) + gnNode.Children["type"] = node.New("type", generalNameType(nameTagNum)) + gnNode.Children["tag"] = node.New("tag", nameTagNum) + if nameTagNum == 6 { + uri := string(name) + gnNode.Children["value"] = node.New("value", uri) + if strings.Contains(uri, ":") { + scheme := strings.Split(uri, ":")[0] + gnNode.Children["scheme"] = node.New("scheme", scheme) + } + } else { + gnNode.Children["value"] = node.New("value", name) + } + generalNamesNode.Children[fmt.Sprintf("%d", uriIdx)] = gnNode + uriIdx++ + hasFullName = true + } + fullNameNode.Children["generalNames"] = generalNamesNode + fullNameNode.Children["count"] = node.New("count", uriIdx) + dpNameNode.Children["fullName"] = fullNameNode + } else if innerTagNum == 1 { + // nameRelativeToCRLIssuer [1] + dpNameNode.Children["nameRelativeToCRLIssuer"] = node.New("nameRelativeToCRLIssuer", field) + } + } + dpNode.Children["distributionPoint"] = dpNameNode + + case 1: // reasons [1] ReasonFlags + reasonsNode := node.New("reasons", nil) + reasonsNode.Children["present"] = node.New("present", true) + reasonsNode.Children["raw"] = node.New("raw", field) + // ReasonFlags is BIT STRING - parse the bits + if len(field) >= 2 { + unusedBits := int(field[0]) + reasonsNode.Children["unusedBits"] = node.New("unusedBits", unusedBits) + if len(field) > 1 { + reasonsBytes := field[1:] + reasonsNode.Children["value"] = node.New("value", reasonsBytes) + // Decode individual reasons (bits 0-9) + decodeReasonFlags(reasonsNode, reasonsBytes, unusedBits) + } + } + dpNode.Children["reasons"] = reasonsNode + hasReasons = true + + case 2: // cRLIssuer [2] GeneralNames + crlIssuerNode := node.New("cRLIssuer", nil) + crlIssuerNode.Children["present"] = node.New("present", true) + gnIdx := 0 + for len(field) > 0 { + var name cryptobyte.String + var nameTag cryptobyte_asn1.Tag + if !field.ReadAnyASN1(&name, &nameTag) { + break + } + nameTagNum := int(nameTag & 0x1F) + gnNode := node.New(fmt.Sprintf("%d", gnIdx), nil) + gnNode.Children["type"] = node.New("type", generalNameType(nameTagNum)) + gnNode.Children["tag"] = node.New("tag", nameTagNum) + gnNode.Children["value"] = node.New("value", name) + crlIssuerNode.Children[fmt.Sprintf("%d", gnIdx)] = gnNode + gnIdx++ + } + crlIssuerNode.Children["count"] = node.New("count", gnIdx) + dpNode.Children["cRLIssuer"] = crlIssuerNode + hasCRLIssuer = true + } + } + + // Mark presence/absence of optional fields + dpNode.Children["hasFullName"] = node.New("hasFullName", hasFullName) + dpNode.Children["hasReasons"] = node.New("hasReasons", hasReasons) + dpNode.Children["hasCRLIssuer"] = node.New("hasCRLIssuer", hasCRLIssuer) + + distributionPointsNode.Children[fmt.Sprintf("%d", idx)] = dpNode + idx++ + } + + n.Children["count"] = node.New("count", idx) + + return n +} + +// generalNameType returns the GeneralName type string for a context-specific tag number +func generalNameType(tag int) string { + switch tag { + case 0: + return "otherName" + case 1: + return "rfc822Name" + case 2: + return "dNSName" + case 3: + return "x400Address" + case 4: + return "directoryName" + case 5: + return "ediPartyName" + case 6: + return "uniformResourceIdentifier" + case 7: + return "iPAddress" + case 8: + return "registeredID" + default: + return "unknown" + } +} + +// decodeReasonFlags decodes the CRL revocation reason flags +// Bits: 0=unused, 1=keyCompromise, 2=cACompromise, 3=affiliationChanged, +// 4=superseded, 5=cessationOfOperation, 6=certificateHold, +// 8=removeFromCRL, 9=privilegeWithdrawn, 10=aACompromise +func decodeReasonFlags(n *node.Node, bytes []byte, unusedBits int) { + reasonNames := []string{ + "unused", // bit 0 + "keyCompromise", // bit 1 + "cACompromise", // bit 2 + "affiliationChanged", // bit 3 + "superseded", // bit 4 + "cessationOfOperation", // bit 5 + "certificateHold", // bit 6 + "", // bit 7 (unused) + "removeFromCRL", // bit 8 + "privilegeWithdrawn", // bit 9 + "aACompromise", // bit 10 + } + + for bit := 0; bit < len(reasonNames) && bit < len(bytes)*8-unusedBits; bit++ { + if reasonNames[bit] == "" { + continue + } + byteIdx := bit / 8 + bitIdx := 7 - (bit % 8) + if byteIdx < len(bytes) && (bytes[byteIdx]&(1< 0 { + maxDuration := time.Duration(maxHours) * time.Hour + if diff > maxDuration { + return false, nil + } + } + // Check maximum months (using AddDate for accurate month calculation) if maxMonths > 0 { maxDate := startDate.AddDate(0, maxMonths, 0) @@ -232,5 +268,13 @@ func (DateDiff) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bo } } + // Check minimum hours + if minHours > 0 { + minDuration := time.Duration(minHours) * time.Hour + if diff < minDuration { + return false, nil + } + } + return true, nil } diff --git a/internal/operator/date_test.go b/internal/operator/date_test.go index e41a6f2..e8b3623 100644 --- a/internal/operator/date_test.go +++ b/internal/operator/date_test.go @@ -251,6 +251,132 @@ func TestDateDiff(t *testing.T) { want: true, wantErr: false, }, + { + name: "minHours within limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(12*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minHours": 8}}, + want: true, + wantErr: false, + }, + { + name: "minHours below limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(4*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minHours": 8}}, + want: false, + wantErr: false, + }, + { + name: "maxHours within limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(6*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxHours": 8}}, + want: true, + wantErr: false, + }, + { + name: "maxHours exceeds limit", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(12*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxHours": 8}}, + want: false, + wantErr: false, + }, + { + name: "OCSP validity interval 8 hours to 10 days - valid", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(5*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minHours": 8, "maxDays": 10}}, + want: true, + wantErr: false, + }, + { + name: "OCSP validity interval 8 hours to 10 days - too short", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(4*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minHours": 8, "maxDays": 10}}, + want: false, + wantErr: false, + }, + { + name: "OCSP validity interval 8 hours to 10 days - too long", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(12*24*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minHours": 8, "maxDays": 10}}, + want: false, + wantErr: false, + }, + { + name: "int64 for minHours", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(12*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "minHours": int64(8)}}, + want: true, + wantErr: false, + }, + { + name: "float64 for maxHours", + node: func() *node.Node { + n := node.New("test", nil) + thisUpdate := node.New("thisUpdate", now) + nextUpdate := node.New("nextUpdate", now.Add(6*time.Hour)) + n.Children["thisUpdate"] = thisUpdate + n.Children["nextUpdate"] = nextUpdate + return n + }(), + operands: []any{map[string]any{"start": "thisUpdate", "end": "nextUpdate", "maxHours": float64(8)}}, + want: true, + wantErr: false, + }, } for _, tt := range tests { diff --git a/internal/zcrypto/helpers.go b/internal/zcrypto/helpers.go index 42f6658..65b2af9 100644 --- a/internal/zcrypto/helpers.go +++ b/internal/zcrypto/helpers.go @@ -9,6 +9,26 @@ import ( "github.com/cavoq/PCL/internal/node" ) +// Extension OID to friendly name mapping +var extensionNames = map[string]string{ + "2.5.29.14": "subjectKeyIdentifier", + "2.5.29.15": "keyUsage", + "2.5.29.17": "subjectAltName", + "2.5.29.18": "issuerAltName", + "2.5.29.19": "basicConstraints", + "2.5.29.31": "cRLDistributionPoints", + "2.5.29.32": "certificatePolicies", + "2.5.29.35": "authorityKeyIdentifier", + "2.5.29.37": "extKeyUsage", + "1.3.6.1.5.5.7.1.1": "authorityInfoAccess", + "1.3.6.1.5.5.7.1.11": "subjectInfoAccess", + "2.5.29.21": "cRLReason", + "2.5.29.29": "cRLNumber", + "2.5.29.20": "cRLDistributionPoints", // Note: this is actually issuingDistributionPoint + "1.3.6.1.5.5.7.48.1": "id-ad-ocsp", + "1.3.6.1.5.5.7.48.2": "id-ad-caIssuers", +} + func ToStdCert(cert *zx509.Certificate) (*stdx509.Certificate, error) { if cert == nil { return nil, nil @@ -88,11 +108,21 @@ func BuildExtensions(extensions []pkix.Extension) *node.Node { n := node.New("extensions", nil) for _, ext := range extensions { - extNode := node.New(ext.Id.String(), nil) - extNode.Children["oid"] = node.New("oid", ext.Id.String()) + oidStr := ext.Id.String() + extNode := node.New(oidStr, nil) + extNode.Children["oid"] = node.New("oid", oidStr) extNode.Children["critical"] = node.New("critical", ext.Critical) extNode.Children["value"] = node.New("value", ext.Value) - n.Children[ext.Id.String()] = extNode + + // Add friendly name if available + if name, ok := extensionNames[oidStr]; ok { + extNode.Children["name"] = node.New("name", name) + // Also add the extension under its friendly name for easier access + n.Children[name] = extNode + } + + // Always add under OID + n.Children[oidStr] = extNode } return n diff --git a/internal/zcrypto/helpers_test.go b/internal/zcrypto/helpers_test.go index 7f763f5..66d5e69 100644 --- a/internal/zcrypto/helpers_test.go +++ b/internal/zcrypto/helpers_test.go @@ -135,11 +135,14 @@ func TestBuildExtensions_WithExtensions(t *testing.T) { node := BuildExtensions(extensions) - if len(node.Children) != 2 { - t.Fatalf("expected 2 extension children, got %d", len(node.Children)) + // Each extension is added twice: once under OID, once under friendly name + // keyUsage -> 2.5.29.15 + keyUsage + // extKeyUsage -> 2.5.29.37 + extKeyUsage + if len(node.Children) != 4 { + t.Fatalf("expected 4 extension children (2 OIDs + 2 names), got %d", len(node.Children)) } - // Check keyUsage extension + // Check keyUsage extension by OID kuOID := "2.5.29.15" kuExt, ok := node.Children[kuOID] if !ok { @@ -153,7 +156,7 @@ func TestBuildExtensions_WithExtensions(t *testing.T) { t.Errorf("expected critical=true, got %v", kuExt.Children["critical"].Value) } - // Check extKeyUsage extension + // Check extKeyUsage extension by OID ekuOID := "2.5.29.37" ekuExt, ok := node.Children[ekuOID] if !ok { @@ -163,6 +166,19 @@ func TestBuildExtensions_WithExtensions(t *testing.T) { if ekuExt.Children["critical"].Value != false { t.Errorf("expected critical=false, got %v", ekuExt.Children["critical"].Value) } + + // Check friendly names also exist + if _, ok := node.Children["keyUsage"]; !ok { + t.Error("expected friendly name 'keyUsage'") + } + if _, ok := node.Children["extKeyUsage"]; !ok { + t.Error("expected friendly name 'extKeyUsage'") + } + + // Verify friendly name and OID point to same node + if node.Children["keyUsage"] != node.Children[kuOID] { + t.Error("keyUsage and OID should point to same node") + } } func TestBuildExtensions_ExtensionStructure(t *testing.T) { From c8640f0ac3c5e9d7cf3d671d71d7c2da75fc071d Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 15:18:26 +0800 Subject: [PATCH 19/44] feat: enhance every operator with wildcard paths and unified operands - Add wildcard `*` path support in every operator for nested array traversal - Change Operands type from []any to any to support map-based operands - Add normalizeOperands helper for consistent operand handling - Rename check/values to operator/operands (with backward compatibility) - Fix wildcard path resolution to skip matching child name segments - Add tests for new operator/operands syntax and wildcard paths --- internal/cert/zcrypto/builder_test.go | 179 +++++++++++++++++++++ internal/cert/zcrypto/extension_parser.go | 32 ++-- internal/operator/every.go | 169 ++++++++++++++------ internal/operator/every_test.go | 183 +++++++++++++++++++++- internal/policy/parser_test.go | 12 +- internal/rule/evaluator.go | 32 ++-- internal/rule/rule.go | 4 +- 7 files changed, 526 insertions(+), 85 deletions(-) diff --git a/internal/cert/zcrypto/builder_test.go b/internal/cert/zcrypto/builder_test.go index 5934c70..a1c61d1 100644 --- a/internal/cert/zcrypto/builder_test.go +++ b/internal/cert/zcrypto/builder_test.go @@ -1,6 +1,7 @@ package zcrypto import ( + "os" "testing" "time" @@ -268,3 +269,181 @@ func TestBuilder_ExtensionDetails(t *testing.T) { } } } + +func TestBuilder_AIAStructure(t *testing.T) { + data, err := os.ReadFile("../../../tests/certs/letsencrypt.pem") + if err != nil { + t.Fatalf("failed to read cert: %v", err) + } + loader := NewLoader() + cert, err := loader.Load(data) + if err != nil { + t.Fatalf("failed to load cert: %v", err) + } + + node := BuildTree(cert) + + exts := node.Children["extensions"] + if exts == nil { + t.Fatal("no extensions") + } + + // Check AIA friendly name exists + aia, ok := exts.Children["authorityInfoAccess"] + if !ok { + t.Fatal("authorityInfoAccess friendly name not found") + } + + // Check AIA OID exists + aiaOID, ok := exts.Children["1.3.6.1.5.5.7.1.1"] + if !ok { + t.Fatal("AIA OID not found") + } + + // Should point to same node + if aia != aiaOID { + t.Error("friendly name and OID should point to same node") + } + + // Check parsed accessDescriptions exist + ads, ok := aia.Children["accessDescriptions"] + if !ok { + t.Fatal("accessDescriptions not found") + } + + // Check count + count, ok := aia.Children["count"] + if !ok { + t.Fatal("count not found") + } + t.Logf("AIA count: %v", count.Value) + + // Check first accessDescription + ad0, ok := ads.Children["0"] + if !ok { + t.Fatal("first accessDescription not found") + } + + method, ok := ad0.Children["accessMethod"] + if !ok { + t.Fatal("accessMethod not found") + } + t.Logf("First accessMethod: %v", method.Value) + + loc, ok := ad0.Children["accessLocation"] + if !ok { + t.Fatal("accessLocation not found") + } + locType, ok := loc.Children["type"] + if !ok { + t.Fatal("accessLocation type not found") + } + t.Logf("accessLocation type: %v", locType.Value) + locTag, ok := loc.Children["tag"] + if !ok { + t.Fatal("accessLocation tag not found") + } + if locTag.Value.(int) != 6 { + t.Errorf("expected URI tag 6, got %v", locTag.Value) + } + + // Check containsOCSP and containsCaIssuers + hasOCSP, ok := aia.Children["containsOCSP"] + if ok { + t.Logf("containsOCSP: %v", hasOCSP.Value) + } + hasCaIssuers, ok := aia.Children["containsCaIssuers"] + if ok { + t.Logf("containsCaIssuers: %v", hasCaIssuers.Value) + } +} + +func TestBuilder_CRLDPStructure(t *testing.T) { + data, err := os.ReadFile("../../../tests/certs/letsencrypt.pem") + if err != nil { + t.Fatalf("failed to read cert: %v", err) + } + loader := NewLoader() + cert, err := loader.Load(data) + if err != nil { + t.Fatalf("failed to load cert: %v", err) + } + + node := BuildTree(cert) + + exts := node.Children["extensions"] + if exts == nil { + t.Fatal("no extensions") + } + + // Check CRL DP friendly name exists + crlDP, ok := exts.Children["cRLDistributionPoints"] + if !ok { + t.Fatal("cRLDistributionPoints friendly name not found") + } + + // Check CRL DP OID exists + crlDPOID, ok := exts.Children["2.5.29.31"] + if !ok { + t.Fatal("CRL DP OID not found") + } + + // Should point to same node + if crlDP != crlDPOID { + t.Error("friendly name and OID should point to same node") + } + + // Check parsed distributionPoints exist + dps, ok := crlDP.Children["distributionPoints"] + if !ok { + t.Fatal("distributionPoints not found") + } + + // Check first distributionPoint + dp0, ok := dps.Children["0"] + if !ok { + t.Fatal("first distributionPoint not found") + } + + // Check hasReasons and hasCRLIssuer + hasReasons, ok := dp0.Children["hasReasons"] + if ok { + t.Logf("hasReasons: %v", hasReasons.Value) + if hasReasons.Value.(bool) { + t.Error("should not have reasons field for Let's Encrypt cert") + } + } + hasCRLIssuer, ok := dp0.Children["hasCRLIssuer"] + if ok { + t.Logf("hasCRLIssuer: %v", hasCRLIssuer.Value) + if hasCRLIssuer.Value.(bool) { + t.Error("should not have cRLIssuer field for Let's Encrypt cert") + } + } + + // Check distributionPoint fullName + dp, ok := dp0.Children["distributionPoint"] + if !ok { + t.Fatal("distributionPoint not found") + } + fullName, ok := dp.Children["fullName"] + if !ok { + t.Fatal("fullName not found") + } + gn0, ok := fullName.Children["generalNames"].Children["0"] + if !ok { + t.Fatal("first generalName not found") + } + gnType, ok := gn0.Children["type"] + if !ok { + t.Fatal("generalName type not found") + } + t.Logf("GeneralName type: %v", gnType.Value) + if gnType.Value.(string) != "uniformResourceIdentifier" { + t.Errorf("expected URI type, got %v", gnType.Value) + } + scheme, ok := gn0.Children["scheme"] + if ok { + t.Logf("Scheme: %v", scheme.Value) + } +} diff --git a/internal/cert/zcrypto/extension_parser.go b/internal/cert/zcrypto/extension_parser.go index 238f14e..e0ca8fa 100644 --- a/internal/cert/zcrypto/extension_parser.go +++ b/internal/cert/zcrypto/extension_parser.go @@ -48,6 +48,10 @@ func ParseAIA(extValue []byte) *node.Node { n.Children["empty"] = node.New("empty", false) + // Track OCSP and CA Issuers presence + hasOCSP := false + hasCaIssuers := false + idx := 0 for len(ads) > 0 { var ad cryptobyte.String @@ -65,6 +69,14 @@ func ParseAIA(extValue []byte) *node.Node { methodOID := oidString(accessMethod) adNode.Children["accessMethod"] = node.New("accessMethod", methodOID) + // Track OCSP and CA Issuers + if methodOID == "1.3.6.1.5.5.7.48.1" { // id-ad-ocsp + hasOCSP = true + } + if methodOID == "1.3.6.1.5.5.7.48.2" { // id-ad-caIssuers + hasCaIssuers = true + } + // Read accessLocation (GeneralName - context-specific tagged) var location cryptobyte.String var locationTag cryptobyte_asn1.Tag @@ -74,8 +86,7 @@ func ParseAIA(extValue []byte) *node.Node { locationNode := node.New("accessLocation", nil) contextTag := int(locationTag & 0x1F) - locationType := generalNameType(contextTag) - locationNode.Children["type"] = node.New("type", locationType) + locationNode.Children["type"] = node.New("type", generalNameType(contextTag)) locationNode.Children["tag"] = node.New("tag", contextTag) // For URI (tag 6), extract the URI string @@ -106,23 +117,6 @@ func ParseAIA(extValue []byte) *node.Node { } n.Children["count"] = node.New("count", idx) - - // Convenience: check if contains OCSP and/or CA Issuers - hasOCSP := false - hasCaIssuers := false - for i := 0; i < idx; i++ { - if adNode, ok := accessDescriptionsNode.Children[fmt.Sprintf("%d", i)]; ok { - if methodNode, ok := adNode.Children["accessMethod"]; ok { - method := methodNode.Value.(string) - if method == "1.3.6.1.5.5.7.48.1" { // id-ad-ocsp - hasOCSP = true - } - if method == "1.3.6.1.5.5.7.48.2" { // id-ad-caIssuers - hasCaIssuers = true - } - } - } - } n.Children["containsOCSP"] = node.New("containsOCSP", hasOCSP) n.Children["containsCaIssuers"] = node.New("containsCaIssuers", hasCaIssuers) diff --git a/internal/operator/every.go b/internal/operator/every.go index 9b05d49..3ecd1d5 100644 --- a/internal/operator/every.go +++ b/internal/operator/every.go @@ -8,18 +8,26 @@ import ( // Every checks that every element in an array-like node satisfies a condition. // Operands format (map): -// - path: sub-path relative to each element (optional, empty means check element itself) -// - check: operator name to apply to each element -// - values: operands for the check operator (optional) +// - path: sub-path relative to each element (supports `*` wildcard for nested arrays) +// - operator: operator name to apply to each element (reuses top-level operator concept) +// - operands: operands for the inner operator (optional) // - skipMissing: if true, skip elements where path doesn't exist (default: false) // -// Example YAML usage: +// Example YAML usage for simple check: // target: crl.revokedCertificates // operator: every // operands: // path: extensions.2.5.29.21.value -// check: in -// values: [1, 3, 4, 5, 9] +// operator: in +// operands: [1, 3, 4, 5, 9] +// +// Example YAML usage with wildcard for nested arrays: +// target: certificate.extensions.cRLDistributionPoints.distributionPoints +// operator: every +// operands: +// path: "*.distributionPoint.fullName.generalNames.*.scheme" +// operator: eq +// operands: ["http"] type Every struct{} func (Every) Name() string { return "every" } @@ -34,57 +42,69 @@ func (Every) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (boo return false, fmt.Errorf("every operator requires operands") } - // Operands can be a map or we parse from slice var subPath string - var checkOp string - var checkOperands []any + var innerOp string + var innerOperands []any var skipMissing bool - // Try parsing as map first if m, ok := operands[0].(map[string]any); ok { if p, ok := m["path"].(string); ok { subPath = p } - if c, ok := m["check"].(string); ok { - checkOp = c + // Use "operator" for inner operator (consistent naming) + if op, ok := m["operator"].(string); ok { + innerOp = op + } + // Also support legacy "check" for backwards compatibility + if c, ok := m["check"].(string); ok && innerOp == "" { + innerOp = c } - if v, ok := m["values"]; ok { - if slice, ok := v.([]any); ok { - checkOperands = slice - } else { - checkOperands = []any{v} + if v, ok := m["operands"]; ok { + switch val := v.(type) { + case []any: + innerOperands = val + case map[string]any: + innerOperands = []any{val} + default: + innerOperands = []any{val} + } + } + // Also support legacy "values" for backwards compatibility + if vs, ok := m["values"]; ok && len(innerOperands) == 0 { + switch val := vs.(type) { + case []any: + innerOperands = val + default: + innerOperands = []any{val} } } if s, ok := m["skipMissing"].(bool); ok { skipMissing = s } - } else { - // Alternative: parse as [path, check, values...] - if len(operands) >= 2 { - if p, ok := operands[0].(string); ok { - subPath = p - } - if c, ok := operands[1].(string); ok { - checkOp = c - } - if len(operands) > 2 { - checkOperands = operands[2:] - } + } else if len(operands) >= 2 { + // Alternative: parse as [path, operator, operands...] + if p, ok := operands[0].(string); ok { + subPath = p + } + if op, ok := operands[1].(string); ok { + innerOp = op + } + if len(operands) > 2 { + innerOperands = operands[2:] } } - if checkOp == "" { - return false, fmt.Errorf("every operator requires 'check' operand") + if innerOp == "" { + return false, fmt.Errorf("every operator requires 'operator' operand") } - // Get the check operator from registry registry := DefaultRegistry() - op, err := registry.Get(checkOp) + op, err := registry.Get(innerOp) if err != nil { - return false, fmt.Errorf("every: unknown check operator '%s'", checkOp) + return false, fmt.Errorf("every: unknown operator '%s'", innerOp) } - // If node has no children (empty array), trivially true + // If node has no children, trivially true if len(n.Children) == 0 { return true, nil } @@ -95,7 +115,6 @@ func (Every) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (boo continue } - // Resolve sub-path if provided var targetNode *node.Node if subPath == "" { targetNode = child @@ -103,19 +122,34 @@ func (Every) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (boo targetNode = resolvePath(child, subPath) if targetNode == nil { if skipMissing { - continue // Skip this element + continue } - return false, nil // Element doesn't have required path + return false, nil } } - // Apply check operator - result, err := op.Evaluate(targetNode, ctx, checkOperands) - if err != nil { - return false, err - } - if !result { - return false, nil // At least one element failed + // If target is a virtual node (from wildcard), check all its children + if targetNode.Name == "*" && len(targetNode.Children) > 0 { + for _, subChild := range targetNode.Children { + if subChild == nil { + continue + } + result, err := op.Evaluate(subChild, ctx, innerOperands) + if err != nil { + return false, err + } + if !result { + return false, nil + } + } + } else { + result, err := op.Evaluate(targetNode, ctx, innerOperands) + if err != nil { + return false, err + } + if !result { + return false, nil + } } } @@ -124,6 +158,7 @@ func (Every) Evaluate(n *node.Node, ctx *EvaluationContext, operands []any) (boo // resolvePath resolves a dot-separated path from a node. // Handles OID-style keys that contain dots (e.g., "2.5.29.21"). +// Supports `*` wildcard to match all children at that level. func resolvePath(n *node.Node, path string) *node.Node { if n == nil || path == "" { return n @@ -137,8 +172,50 @@ func resolvePath(n *node.Node, path string) *node.Node { return nil } - // Try to find child with exact match part := parts[i] + + // Handle wildcard: collect all children and continue matching + if part == "*" { + virtualNode := node.New("*", nil) + for _, child := range current.Children { + if child == nil { + continue + } + // Build remaining path + if i+1 < len(parts) { + remainingPath := combineParts(parts, i+1, len(parts)) + // If the child's name matches the next path segment, + // skip that segment when resolving from the child + remainingParts := splitPath(remainingPath) + if len(remainingParts) > 0 && child.Name == remainingParts[0] { + // Skip the matching segment + if len(remainingParts) > 1 { + remainingPath = combineParts(remainingParts, 1, len(remainingParts)) + } else { + remainingPath = "" + } + } + result := resolvePath(child, remainingPath) + if result != nil { + // Merge results into virtual node + if len(result.Children) > 0 { + for _, v := range result.Children { + virtualNode.Children[fmt.Sprintf("%d", len(virtualNode.Children))] = v + } + } else { + // Single value result + virtualNode.Children[fmt.Sprintf("%d", len(virtualNode.Children))] = result + } + } + } else { + // * is the last part, add all children directly + virtualNode.Children[fmt.Sprintf("%d", len(virtualNode.Children))] = child + } + } + return virtualNode + } + + // Try to find child with exact match next := current.Children[part] // If not found and part looks like OID start (numeric), diff --git a/internal/operator/every_test.go b/internal/operator/every_test.go index d33a4d7..ab911c1 100644 --- a/internal/operator/every_test.go +++ b/internal/operator/every_test.go @@ -234,6 +234,48 @@ func TestEvery(t *testing.T) { want: false, wantErr: true, }, + // Test new operator/operands syntax + { + name: "new syntax - operator and operands", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["0"] = node.New("0", 1) + n.Children["1"] = node.New("1", 3) + n.Children["2"] = node.New("2", 5) + return n + }(), + operands: []any{map[string]any{ + "operator": "in", + "operands": []any{1, 3, 5, 7, 9}, + }}, + want: true, + wantErr: false, + }, + // Test new syntax with wildcard path + { + name: "new syntax - wildcard path for AIA scheme", + node: func() *node.Node { + n := node.New("accessDescriptions", nil) + ad0 := node.New("0", nil) + loc0 := node.New("accessLocation", nil) + loc0.Children["scheme"] = node.New("scheme", "http") + ad0.Children["accessLocation"] = loc0 + n.Children["0"] = ad0 + ad1 := node.New("1", nil) + loc1 := node.New("accessLocation", nil) + loc1.Children["scheme"] = node.New("scheme", "http") + ad1.Children["accessLocation"] = loc1 + n.Children["1"] = ad1 + return n + }(), + operands: []any{map[string]any{ + "path": "*.accessLocation.scheme", + "operator": "eq", + "operands": []any{"http"}, + }}, + want: true, + wantErr: false, + }, } for _, tt := range tests { @@ -320,4 +362,143 @@ func TestResolvePath(t *testing.T) { } }) } -} \ No newline at end of file +} +func TestResolvePathWithWildcard(t *testing.T) { + tests := []struct { + name string + node *node.Node + path string + wantCount int // Expected number of children in result + wantAnyValue any // Check if any child has this value + }{ + // Case 1: Wildcard at end - collect all direct children + { + name: "wildcard at end - collect all children", + node: func() *node.Node { + n := node.New("test", nil) + n.Children["a"] = node.New("a", "value1") + n.Children["b"] = node.New("b", "value2") + n.Children["c"] = node.New("c", "value3") + return n + }(), + path: "*", + wantCount: 3, + }, + // Case 2: Wildcard in middle - traverse then collect + { + name: "wildcard in middle - nested path", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + nested0 := node.New("nested", nil) + nested0.Children["value"] = node.New("value", 1) + child0.Children["nested"] = nested0 + n.Children["0"] = child0 + child1 := node.New("1", nil) + nested1 := node.New("nested", nil) + nested1.Children["value"] = node.New("value", 2) + child1.Children["nested"] = nested1 + n.Children["1"] = child1 + return n + }(), + path: "*.nested.value", + wantCount: 2, + }, + // Case 3: Double wildcard - deeply nested (CRL DP pattern) + { + name: "double wildcard - CRL DP structure", + node: func() *node.Node { + n := node.New("dps", nil) + dp0 := node.New("0", nil) + fn0 := node.New("fullName", nil) + gn0 := node.New("0", nil) + gn0.Children["scheme"] = node.New("scheme", "http") + gns0 := node.New("generalNames", nil) + gns0.Children["0"] = gn0 + fn0.Children["generalNames"] = gns0 + dp0.Children["fullName"] = fn0 + n.Children["0"] = dp0 + dp1 := node.New("1", nil) + fn1 := node.New("fullName", nil) + gn1 := node.New("0", nil) + gn1.Children["scheme"] = node.New("scheme", "http") + gns1 := node.New("generalNames", nil) + gns1.Children["0"] = gn1 + fn1.Children["generalNames"] = gns1 + dp1.Children["fullName"] = fn1 + n.Children["1"] = dp1 + return n + }(), + path: "*.fullName.generalNames.*.scheme", + wantCount: 2, + }, + // Case 4: OID path with wildcard (extensions area) + { + name: "wildcard with OID path - extensions", + node: func() *node.Node { + n := node.New("extensions", nil) + ext0 := node.New("2.5.29.15", nil) + ext0.Children["critical"] = node.New("critical", true) + n.Children["2.5.29.15"] = ext0 + ext1 := node.New("2.5.29.37", nil) + ext1.Children["critical"] = node.New("critical", false) + n.Children["2.5.29.37"] = ext1 + return n + }(), + path: "*.critical", + wantCount: 2, + }, + // Case 5: Wildcard in AIA structure (accessDescriptions area) + { + name: "wildcard in AIA - accessLocation scheme", + node: func() *node.Node { + n := node.New("accessDescriptions", nil) + ad0 := node.New("0", nil) + ad0.Children["accessMethod"] = node.New("accessMethod", "1.3.6.1.5.5.7.48.1") + loc0 := node.New("accessLocation", nil) + loc0.Children["scheme"] = node.New("scheme", "http") + ad0.Children["accessLocation"] = loc0 + n.Children["0"] = ad0 + ad1 := node.New("1", nil) + ad1.Children["accessMethod"] = node.New("accessMethod", "1.3.6.1.5.5.7.48.2") + loc1 := node.New("accessLocation", nil) + loc1.Children["scheme"] = node.New("scheme", "http") + ad1.Children["accessLocation"] = loc1 + n.Children["1"] = ad1 + return n + }(), + path: "*.accessLocation.scheme", + wantCount: 2, + }, + // Case 6: Missing children - wildcard skips + { + name: "wildcard with missing children", + node: func() *node.Node { + n := node.New("test", nil) + child0 := node.New("0", nil) + child0.Children["value"] = node.New("value", 1) + n.Children["0"] = child0 + child1 := node.New("1", nil) // no value + n.Children["1"] = child1 + return n + }(), + path: "*.value", + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolvePath(tt.node, tt.path) + if got == nil { + if tt.wantCount > 0 { + t.Errorf("resolvePath() = nil, want node with %d children", tt.wantCount) + } + return + } + if len(got.Children) != tt.wantCount { + t.Errorf("resolvePath() returned %d children, want %d", len(got.Children), tt.wantCount) + } + }) + } +} diff --git a/internal/policy/parser_test.go b/internal/policy/parser_test.go index 8387fdf..f131f48 100644 --- a/internal/policy/parser_test.go +++ b/internal/policy/parser_test.go @@ -414,11 +414,15 @@ rules: } operands := p.Rules[0].Operands - if len(operands) != 2 { - t.Fatalf("expected 2 operands, got %d: %v", len(operands), operands) + operandsSlice, ok := operands.([]any) + if !ok { + t.Fatalf("expected operands to be []any, got %T", operands) + } + if len(operandsSlice) != 2 { + t.Fatalf("expected 2 operands, got %d: %v", len(operandsSlice), operandsSlice) } - if operands[0] != "SHA256-RSA" { - t.Errorf("expected operand[0] to be 'SHA256-RSA', got %v (type %T)", operands[0], operands[0]) + if operandsSlice[0] != "SHA256-RSA" { + t.Errorf("expected operand[0] to be 'SHA256-RSA', got %v (type %T)", operandsSlice[0], operandsSlice[0]) } } diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index 8ac2037..03a7d65 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -23,6 +23,22 @@ type Result struct { Message string `json:"message,omitempty" yaml:"message,omitempty"` } +// normalizeOperands converts Operands (any type) to []any for operator evaluation. +// Handles: []any (direct use), map[string]any (wrap as single element), nil (empty). +func normalizeOperands(operands any) []any { + if operands == nil { + return nil + } + switch v := operands.(type) { + case []any: + return v + case map[string]any: + return []any{v} + default: + return []any{v} + } +} + func Evaluate( root *node.Node, r Rule, @@ -73,19 +89,9 @@ func Evaluate( n, found := root.Resolve(r.Target) // For presence/absence/null operators, continue evaluation even if target not found - // present: returns false when target not found (expected behavior) - // absent: returns true when target not found (expected behavior) - // isNull: returns false when target not found (absent is not NULL) - // For eq/neq operators on keyUsage boolean fields, treat missing as implicit false - // For other operators, skip when target not found (e.g., certificate rules when processing CRLs) if !found && r.Operator != "present" && r.Operator != "absent" && r.Operator != "isNull" { // Special handling for eq/neq on keyUsage boolean fields if (r.Operator == "eq" || r.Operator == "neq") && isKeyUsageBooleanField(r.Target) { - // Missing keyUsage bit = implicit false - // For eq true: false != true → FAIL - // For eq false: false == false → PASS - // For neq true: false != true → PASS - // For neq false: false == false → FAIL var targetNode *node.Node op, err := reg.Get(r.Operator) if err != nil { @@ -97,7 +103,7 @@ func Evaluate( Severity: r.Severity, } } - ok, err := op.Evaluate(targetNode, ctx, r.Operands) + ok, err := op.Evaluate(targetNode, ctx, normalizeOperands(r.Operands)) if err != nil { return Result{ RuleID: r.ID, @@ -144,7 +150,7 @@ func Evaluate( } } - ok, err := op.Evaluate(targetNode, ctx, r.Operands) + ok, err := op.Evaluate(targetNode, ctx, normalizeOperands(r.Operands)) if err != nil { return Result{ RuleID: r.ID, @@ -181,7 +187,7 @@ func evaluateCondition( return false, fmt.Errorf("operator not found: %s", cond.Operator) } - return op.Evaluate(n, ctx, cond.Operands) + return op.Evaluate(n, ctx, normalizeOperands(cond.Operands)) } func appliesTo(r Rule, ctx *operator.EvaluationContext) bool { diff --git a/internal/rule/rule.go b/internal/rule/rule.go index 5890554..313c41a 100644 --- a/internal/rule/rule.go +++ b/internal/rule/rule.go @@ -3,7 +3,7 @@ package rule type Condition struct { Target string `yaml:"target"` Operator string `yaml:"operator"` - Operands []any `yaml:"operands"` + Operands any `yaml:"operands"` // Can be []any or map[string]any } type Rule struct { @@ -11,7 +11,7 @@ type Rule struct { Reference string `yaml:"reference,omitempty"` Target string `yaml:"target"` Operator string `yaml:"operator"` - Operands []any `yaml:"operands"` + Operands any `yaml:"operands"` // Can be []any or map[string]any Severity string `yaml:"severity"` AppliesTo []string `yaml:"appliesTo,omitempty"` When *Condition `yaml:"when,omitempty"` From c5eb7af85d3e463371db3dd821284c900c5e294a Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 15:49:50 +0800 Subject: [PATCH 20/44] feat: add Certificate Policies extension ASN.1 parser Parse Certificate Policies extension (OID 2.5.29.32) with full ASN.1 structure: - policyInformations array with policyIdentifier and policyQualifiers - CPS URI qualifier (1.3.6.1.5.5.7.2.1): exposes cpsURI, encoding, scheme - UserNotice qualifier (1.3.6.1.5.5.7.2.2): explicitText with encoding, noticeReference - Qualifiers accessible by index and OID for direct policy lookups --- internal/cert/zcrypto/builder.go | 9 + internal/cert/zcrypto/cert_policies_test.go | 207 ++++++++++++++++++++ internal/cert/zcrypto/extension_parser.go | 204 +++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 internal/cert/zcrypto/cert_policies_test.go diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 6536ec8..f0122bd 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -80,6 +80,15 @@ func buildCertificate(cert *x509.Certificate) *node.Node { } } } + if oidStr == "2.5.29.32" { + certPoliciesNode := ParseCertPolicies(ext.Value) + if extNode, ok := root.Children["extensions"].Children["certificatePolicies"]; ok { + // Merge parsed Certificate Policies into extension node + for k, v := range certPoliciesNode.Children { + extNode.Children[k] = v + } + } + } } } diff --git a/internal/cert/zcrypto/cert_policies_test.go b/internal/cert/zcrypto/cert_policies_test.go new file mode 100644 index 0000000..868ea35 --- /dev/null +++ b/internal/cert/zcrypto/cert_policies_test.go @@ -0,0 +1,207 @@ +package zcrypto + +import ( + "testing" + + "github.com/cavoq/PCL/internal/node" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +// Helper to build Certificate Policies extension value +func buildCertPoliciesValue(policyOID string, cpsURI string, explicitText string) []byte { + var b cryptobyte.Builder + b.AddASN1(cryptobyte_asn1.SEQUENCE, func(b *cryptobyte.Builder) { + // PolicyInformation + b.AddASN1(cryptobyte_asn1.SEQUENCE, func(b *cryptobyte.Builder) { + // policyIdentifier + b.AddASN1(cryptobyte_asn1.OBJECT_IDENTIFIER, func(b *cryptobyte.Builder) { + // Parse OID string to bytes (simplified for testing) + if policyOID == "2.23.140.1.2.1" { // DV OID + b.AddBytes([]byte{0x60, 0x86, 0x48, 0x01, 0x86, 0xFD, 0x6C, 0x01, 0x02, 0x01}) + } else { + b.AddBytes([]byte{0x55, 0x1D, 0x20, 0x00}) // anyPolicy + } + }) + // policyQualifiers (optional) + if cpsURI != "" || explicitText != "" { + b.AddASN1(cryptobyte_asn1.SEQUENCE, func(b *cryptobyte.Builder) { + if cpsURI != "" { + // PolicyQualifierInfo for CPS + b.AddASN1(cryptobyte_asn1.SEQUENCE, func(b *cryptobyte.Builder) { + // policyQualifierId: id-qt-cps (1.3.6.1.5.5.7.2.1) + b.AddASN1(cryptobyte_asn1.OBJECT_IDENTIFIER, func(b *cryptobyte.Builder) { + b.AddBytes([]byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x02, 0x01}) + }) + // qualifier: IA5String + b.AddASN1(cryptobyte_asn1.IA5String, func(b *cryptobyte.Builder) { + b.AddBytes([]byte(cpsURI)) + }) + }) + } + if explicitText != "" { + // PolicyQualifierInfo for UserNotice + b.AddASN1(cryptobyte_asn1.SEQUENCE, func(b *cryptobyte.Builder) { + // policyQualifierId: id-qt-unotice (1.3.6.1.5.5.7.2.2) + b.AddASN1(cryptobyte_asn1.OBJECT_IDENTIFIER, func(b *cryptobyte.Builder) { + b.AddBytes([]byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x02, 0x02}) + }) + // qualifier: UserNotice SEQUENCE + b.AddASN1(cryptobyte_asn1.SEQUENCE, func(b *cryptobyte.Builder) { + // explicitText (UTF8String, tag 12) + b.AddASN1(cryptobyte_asn1.UTF8String, func(b *cryptobyte.Builder) { + b.AddBytes([]byte(explicitText)) + }) + }) + }) + } + }) + } + }) + }) + return b.BytesOrPanic() +} + +func TestParseCertPolicies(t *testing.T) { + tests := []struct { + name string + extValue []byte + checkFunc func(n *node.Node) bool + expected bool + }{ + { + name: "policy with CPS URI", + extValue: buildCertPoliciesValue("2.23.140.1.2.1", "http://cps.example.com/policy", ""), + checkFunc: func(n *node.Node) bool { + pi, ok := n.Children["policyInformations"] + if !ok { + return false + } + p0, ok := pi.Children["0"] + if !ok { + return false + } + pq, ok := p0.Children["policyQualifiers"] + if !ok { + return false + } + // Check CPS qualifier exists + cps, ok := pq.Children["1.3.6.1.5.5.7.2.1"] + return ok && cps != nil + }, + expected: true, + }, + { + name: "policy with userNotice explicitText", + extValue: buildCertPoliciesValue("2.23.140.1.2.1", "", "This is a test notice"), + checkFunc: func(n *node.Node) bool { + pi, ok := n.Children["policyInformations"] + if !ok { + return false + } + p0, ok := pi.Children["0"] + if !ok { + return false + } + pq, ok := p0.Children["policyQualifiers"] + if !ok { + return false + } + // Check userNotice qualifier exists + un, ok := pq.Children["1.3.6.1.5.5.7.2.2"] + return ok && un != nil + }, + expected: true, + }, + { + name: "policy with both CPS and userNotice", + extValue: buildCertPoliciesValue("2.23.140.1.2.1", "http://cps.example.com/policy", "Test notice"), + checkFunc: func(n *node.Node) bool { + pi, ok := n.Children["policyInformations"] + if !ok { + return false + } + // Check that there's exactly 1 policyInformation + return len(pi.Children) == 1 && pi.Children["0"] != nil + }, + expected: true, + }, + { + name: "CPS URI encoding is ia5String", + extValue: buildCertPoliciesValue("2.23.140.1.2.1", "http://cps.example.com/policy", ""), + checkFunc: func(n *node.Node) bool { + pi, ok := n.Children["policyInformations"] + if !ok { + return false + } + p0, ok := pi.Children["0"] + if !ok { + return false + } + pq, ok := p0.Children["policyQualifiers"] + if !ok { + return false + } + q0, ok := pq.Children["0"] + if !ok { + return false + } + encoding, ok := q0.Children["encoding"] + if !ok { + return false + } + return encoding.Value.(string) == "ia5String" + }, + expected: true, + }, + { + name: "userNotice explicitText encoding", + extValue: buildCertPoliciesValue("2.23.140.1.2.1", "", "Test notice"), + checkFunc: func(n *node.Node) bool { + pi, ok := n.Children["policyInformations"] + if !ok { + return false + } + p0, ok := pi.Children["0"] + if !ok { + return false + } + pq, ok := p0.Children["policyQualifiers"] + if !ok { + return false + } + q0, ok := pq.Children["0"] + if !ok { + return false + } + un, ok := q0.Children["userNotice"] + if !ok { + return false + } + et, ok := un.Children["explicitText"] + if !ok { + return false + } + encoding, ok := et.Children["encoding"] + if !ok { + return false + } + return encoding.Value.(string) == "utf8String" + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := ParseCertPolicies(tt.extValue) + if n == nil { + t.Fatalf("ParseCertPolicies returned nil") + } + got := tt.checkFunc(n) + if got != tt.expected { + t.Errorf("check failed: got %v, want %v", got, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/internal/cert/zcrypto/extension_parser.go b/internal/cert/zcrypto/extension_parser.go index e0ca8fa..5e3226d 100644 --- a/internal/cert/zcrypto/extension_parser.go +++ b/internal/cert/zcrypto/extension_parser.go @@ -337,4 +337,208 @@ func decodeReasonFlags(n *node.Node, bytes []byte, unusedBits int) { n.Children[reasonNames[bit]] = node.New(reasonNames[bit], true) } } +} + +// Certificate Policies OID: 2.5.29.32 +const certPoliciesOID = "2.5.29.32" + +// Policy Qualifier Type OIDs +const ( + idQtCps = "1.3.6.1.5.5.7.2.1" // CPS URI + idQtUnotice = "1.3.6.1.5.5.7.2.2" // UserNotice +) + +// ParseCertPolicies parses the Certificate Policies extension (OID 2.5.29.32) +// and returns a node tree with policyInformations and policyQualifiers. +// +// ASN.1 structure (RFC 5280 4.2.1.4): +// CertificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation +// PolicyInformation ::= SEQUENCE { +// policyIdentifier OBJECT IDENTIFIER, +// policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL } +// PolicyQualifierInfo ::= SEQUENCE { +// policyQualifierId OBJECT IDENTIFIER, +// qualifier ANY DEFINED BY policyQualifierId } +// +// Qualifier types: +// id-qt-cps (1.3.6.1.5.5.7.2.1): CPS URI - IA5String +// id-qt-unotice (1.3.6.1.5.5.7.2.2): UserNotice - SEQUENCE { noticeRef, explicitText } +func ParseCertPolicies(extValue []byte) *node.Node { + n := node.New("certificatePolicies", nil) + policiesNode := node.New("policyInformations", nil) + n.Children["policyInformations"] = policiesNode + + input := cryptobyte.String(extValue) + + var policies cryptobyte.String + if !input.ReadASN1(&policies, cryptobyte_asn1.SEQUENCE) { + return n + } + + if len(policies) == 0 { + n.Children["empty"] = node.New("empty", true) + return n + } + + idx := 0 + + for len(policies) > 0 { + var policy cryptobyte.String + if !policies.ReadASN1(&policy, cryptobyte_asn1.SEQUENCE) { + break + } + + policyNode := node.New(fmt.Sprintf("%d", idx), nil) + + // Read policyIdentifier (OID) + var policyOID cryptobyte.String + if !policy.ReadASN1(&policyOID, cryptobyte_asn1.OBJECT_IDENTIFIER) { + break + } + policyOIDStr := oidString(policyOID) + policyNode.Children["policyIdentifier"] = node.New("policyIdentifier", policyOIDStr) + + // Add policy by OID as well for direct access + n.Children[policyOIDStr] = policyNode + + // Read policyQualifiers (OPTIONAL SEQUENCE) + if len(policy) > 0 { + qualifiersNode := node.New("policyQualifiers", nil) + var qualifiers cryptobyte.String + if policy.ReadASN1(&qualifiers, cryptobyte_asn1.SEQUENCE) { + qIdx := 0 + for len(qualifiers) > 0 { + var qualifier cryptobyte.String + if !qualifiers.ReadASN1(&qualifier, cryptobyte_asn1.SEQUENCE) { + break + } + + qNode := node.New(fmt.Sprintf("%d", qIdx), nil) + + // Read policyQualifierId (OID) + var qOID cryptobyte.String + if !qualifier.ReadASN1(&qOID, cryptobyte_asn1.OBJECT_IDENTIFIER) { + break + } + qOIDStr := oidString(qOID) + qNode.Children["policyQualifierId"] = node.New("policyQualifierId", qOIDStr) + + // Parse qualifier based on OID + if qOIDStr == idQtCps { + // CPS URI - IA5String + var cpsURI cryptobyte.String + if qualifier.ReadASN1(&cpsURI, cryptobyte_asn1.IA5String) { + uri := string(cpsURI) + qNode.Children["cpsURI"] = node.New("cpsURI", uri) + qNode.Children["type"] = node.New("type", "cps") + // Check encoding + qNode.Children["encoding"] = node.New("encoding", "ia5String") + // Extract scheme for convenience + if strings.Contains(uri, ":") { + scheme := strings.Split(uri, ":")[0] + qNode.Children["scheme"] = node.New("scheme", scheme) + } + } + } else if qOIDStr == idQtUnotice { + // UserNotice - SEQUENCE + qNode.Children["type"] = node.New("type", "userNotice") + var userNotice cryptobyte.String + if qualifier.ReadASN1(&userNotice, cryptobyte_asn1.SEQUENCE) { + unNode := node.New("userNotice", nil) + // Parse elements: noticeReference (SEQUENCE) first, then explicitText (string) + // If first element is not SEQUENCE, it's explicitText + for len(userNotice) > 0 { + var element cryptobyte.String + var elementTag cryptobyte_asn1.Tag + if !userNotice.ReadAnyASN1(&element, &elementTag) { + break + } + + if elementTag == cryptobyte_asn1.SEQUENCE { + // noticeReference + nrNode := node.New("noticeReference", nil) + var org cryptobyte.String + var noticeNums cryptobyte.String + if element.ReadASN1(&org, cryptobyte_asn1.SEQUENCE) { + orgNode := node.New("organization", nil) + var orgStr cryptobyte.String + var orgTag cryptobyte_asn1.Tag + if org.ReadAnyASN1(&orgStr, &orgTag) { + orgNode.Children["value"] = node.New("value", string(orgStr)) + orgNode.Children["encoding"] = node.New("encoding", asn1StringType(int(orgTag))) + } + nrNode.Children["organization"] = orgNode + if org.ReadASN1(¬iceNums, cryptobyte_asn1.SEQUENCE) { + numsNode := node.New("noticeNumbers", nil) + numIdx := 0 + for len(noticeNums) > 0 { + var num int64 + if noticeNums.ReadASN1Integer(&num) { + numsNode.Children[fmt.Sprintf("%d", numIdx)] = node.New(fmt.Sprintf("%d", numIdx), num) + numIdx++ + } else { + break + } + } + numsNode.Children["count"] = node.New("count", numIdx) + nrNode.Children["noticeNumbers"] = numsNode + } + } + unNode.Children["noticeReference"] = nrNode + } else { + // explicitText - DisplayText (any string type) + etNode := node.New("explicitText", nil) + etNode.Children["value"] = node.New("value", string(element)) + etNode.Children["encoding"] = node.New("encoding", asn1StringType(int(elementTag))) + etNode.Children["tag"] = node.New("tag", int(elementTag)) + unNode.Children["explicitText"] = etNode + } + } + qNode.Children["userNotice"] = unNode + } + } else { + // Unknown qualifier type - store raw bytes + qNode.Children["type"] = node.New("type", "unknown") + var raw cryptobyte.String + var rawTag cryptobyte_asn1.Tag + if qualifier.ReadAnyASN1(&raw, &rawTag) { + qNode.Children["raw"] = node.New("raw", raw) + } + } + + qualifiersNode.Children[fmt.Sprintf("%d", qIdx)] = qNode + // Also add by OID for direct access + qualifiersNode.Children[qOIDStr] = qNode + qIdx++ + } + qualifiersNode.Children["count"] = node.New("count", qIdx) + } + policyNode.Children["policyQualifiers"] = qualifiersNode + } + + policiesNode.Children[fmt.Sprintf("%d", idx)] = policyNode + idx++ + } + + return n +} + +// asn1StringType returns the ASN.1 string type name for a tag number +func asn1StringType(tag int) string { + switch tag { + case 12: + return "utf8String" + case 13: + return "printableString" + case 22: + return "ia5String" + case 20: + return "bmpString" + case 19: + return "visibleString" + case 26: + return "universalString" + default: + return "unknown" + } } \ No newline at end of file From ba987eed312d3f53cdfe5f21ef153198096965a8 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 16:21:15 +0800 Subject: [PATCH 21/44] feat: add NameConstraints extension parsing Parse NameConstraints extension (OID 2.5.29.30) with full structure: - permittedSubtrees: dNSName, rfc822Name, uniformResourceIdentifier, iPAddress, directoryName - excludedSubtrees: same GeneralName types - Each subtree includes value, min, max fields for distance constraints - Critical flag exposed for validation --- internal/cert/zcrypto/builder.go | 114 +++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index f0122bd..b390fcc 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -114,6 +114,11 @@ func buildCertificate(cert *x509.Certificate) *node.Node { root.Children["subjectAltName"] = buildSubjectAltName(cert) } + // Add Name Constraints (for CA certificates) + if hasNameConstraints(cert) { + root.Children["nameConstraints"] = buildNameConstraints(cert) + } + if len(cert.Signature) > 0 { root.Children["signatureValue"] = node.New("signatureValue", cert.Signature) } @@ -500,3 +505,112 @@ func buildSCT(sct interface{}, index int) *node.Node { return n } + +func hasNameConstraints(cert *x509.Certificate) bool { + return len(cert.PermittedDNSNames) > 0 || + len(cert.ExcludedDNSNames) > 0 || + len(cert.PermittedEmailAddresses) > 0 || + len(cert.ExcludedEmailAddresses) > 0 || + len(cert.PermittedURIs) > 0 || + len(cert.ExcludedURIs) > 0 || + len(cert.PermittedIPAddresses) > 0 || + len(cert.ExcludedIPAddresses) > 0 || + len(cert.PermittedDirectoryNames) > 0 || + len(cert.ExcludedDirectoryNames) > 0 +} + +func buildNameConstraints(cert *x509.Certificate) *node.Node { + n := node.New("nameConstraints", nil) + + // Critical flag + n.Children["critical"] = node.New("critical", cert.NameConstraintsCritical) + + // Permitted subtrees + if len(cert.PermittedDNSNames) > 0 || + len(cert.PermittedEmailAddresses) > 0 || + len(cert.PermittedURIs) > 0 || + len(cert.PermittedIPAddresses) > 0 || + len(cert.PermittedDirectoryNames) > 0 { + permittedNode := node.New("permittedSubtrees", nil) + buildGeneralSubtrees(permittedNode, "dNSName", cert.PermittedDNSNames) + buildGeneralSubtrees(permittedNode, "rfc822Name", cert.PermittedEmailAddresses) + buildGeneralSubtrees(permittedNode, "uniformResourceIdentifier", cert.PermittedURIs) + buildGeneralSubtreeIPs(permittedNode, "iPAddress", cert.PermittedIPAddresses) + buildGeneralSubtreeNames(permittedNode, "directoryName", cert.PermittedDirectoryNames) + n.Children["permittedSubtrees"] = permittedNode + } + + // Excluded subtrees + if len(cert.ExcludedDNSNames) > 0 || + len(cert.ExcludedEmailAddresses) > 0 || + len(cert.ExcludedURIs) > 0 || + len(cert.ExcludedIPAddresses) > 0 || + len(cert.ExcludedDirectoryNames) > 0 { + excludedNode := node.New("excludedSubtrees", nil) + buildGeneralSubtrees(excludedNode, "dNSName", cert.ExcludedDNSNames) + buildGeneralSubtrees(excludedNode, "rfc822Name", cert.ExcludedEmailAddresses) + buildGeneralSubtrees(excludedNode, "uniformResourceIdentifier", cert.ExcludedURIs) + buildGeneralSubtreeIPs(excludedNode, "iPAddress", cert.ExcludedIPAddresses) + buildGeneralSubtreeNames(excludedNode, "directoryName", cert.ExcludedDirectoryNames) + n.Children["excludedSubtrees"] = excludedNode + } + + return n +} + +func buildGeneralSubtrees(parent *node.Node, name string, subtrees []x509.GeneralSubtreeString) { + if len(subtrees) == 0 { + return + } + subtreeNode := node.New(name, nil) + for i, subtree := range subtrees { + child := node.New(fmt.Sprintf("%d", i), nil) + child.Children["value"] = node.New("value", subtree.Data) + if subtree.Min > 0 { + child.Children["min"] = node.New("min", subtree.Min) + } + if subtree.Max > 0 { + child.Children["max"] = node.New("max", subtree.Max) + } + subtreeNode.Children[fmt.Sprintf("%d", i)] = child + } + parent.Children[name] = subtreeNode +} + +func buildGeneralSubtreeIPs(parent *node.Node, name string, subtrees []x509.GeneralSubtreeIP) { + if len(subtrees) == 0 { + return + } + subtreeNode := node.New(name, nil) + for i, subtree := range subtrees { + child := node.New(fmt.Sprintf("%d", i), nil) + child.Children["value"] = node.New("value", subtree.Data.String()) + if subtree.Min > 0 { + child.Children["min"] = node.New("min", subtree.Min) + } + if subtree.Max > 0 { + child.Children["max"] = node.New("max", subtree.Max) + } + subtreeNode.Children[fmt.Sprintf("%d", i)] = child + } + parent.Children[name] = subtreeNode +} + +func buildGeneralSubtreeNames(parent *node.Node, name string, subtrees []x509.GeneralSubtreeName) { + if len(subtrees) == 0 { + return + } + subtreeNode := node.New(name, nil) + for i, subtree := range subtrees { + child := node.New(fmt.Sprintf("%d", i), nil) + child.Children["value"] = zcrypto.BuildPkixName("value", subtree.Data) + if subtree.Min > 0 { + child.Children["min"] = node.New("min", subtree.Min) + } + if subtree.Max > 0 { + child.Children["max"] = node.New("max", subtree.Max) + } + subtreeNode.Children[fmt.Sprintf("%d", i)] = child + } + parent.Children[name] = subtreeNode +} \ No newline at end of file From 693ae5e627e774ffab2fa65fdfe982ac9b704697 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 16:28:53 +0800 Subject: [PATCH 22/44] feat: add IssuerAltName and CABFOrganizationIdentifier parsing - IssuerAltName (OID 2.5.29.18): parse IANDNSNames, IANEmailAddresses, IANIPAddresses, IANURIs fields into node tree - CABFOrganizationIdentifier (OID 2.23.140.3.1): parse scheme, country, state, reference fields for EV certificate validation --- internal/cert/zcrypto/builder.go | 79 ++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index b390fcc..86e0b3c 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -114,11 +114,21 @@ func buildCertificate(cert *x509.Certificate) *node.Node { root.Children["subjectAltName"] = buildSubjectAltName(cert) } + // Add Issuer Alt Name (IAN) + if hasIAN(cert) { + root.Children["issuerAltName"] = buildIssuerAltName(cert) + } + // Add Name Constraints (for CA certificates) if hasNameConstraints(cert) { root.Children["nameConstraints"] = buildNameConstraints(cert) } + // Add CABF Organization Identifier (for EV certificates) + if cert.CABFOrganizationIdentifier != nil { + root.Children["cabfOrganizationIdentifier"] = buildCABFOrganizationID(cert) + } + if len(cert.Signature) > 0 { root.Children["signatureValue"] = node.New("signatureValue", cert.Signature) } @@ -434,6 +444,51 @@ func buildSubjectAltName(cert *x509.Certificate) *node.Node { return n } +func hasIAN(cert *x509.Certificate) bool { + return len(cert.IANDNSNames) > 0 || + len(cert.IANEmailAddresses) > 0 || + len(cert.IANIPAddresses) > 0 || + len(cert.IANURIs) > 0 +} + +func buildIssuerAltName(cert *x509.Certificate) *node.Node { + n := node.New("issuerAltName", nil) + + if len(cert.IANDNSNames) > 0 { + dnsNode := node.New("dNSName", nil) + for i, dns := range cert.IANDNSNames { + dnsNode.Children[string(rune('0'+i))] = node.New(string(rune('0'+i)), dns) + } + n.Children["dNSName"] = dnsNode + } + + if len(cert.IANEmailAddresses) > 0 { + emailNode := node.New("rfc822Name", nil) + for i, email := range cert.IANEmailAddresses { + emailNode.Children[string(rune('0'+i))] = node.New(string(rune('0'+i)), email) + } + n.Children["rfc822Name"] = emailNode + } + + if len(cert.IANIPAddresses) > 0 { + ipNode := node.New("iPAddress", nil) + for i, ip := range cert.IANIPAddresses { + ipNode.Children[string(rune('0'+i))] = node.New(string(rune('0'+i)), ip.String()) + } + n.Children["iPAddress"] = ipNode + } + + if len(cert.IANURIs) > 0 { + uriNode := node.New("uniformResourceIdentifier", nil) + for i, uri := range cert.IANURIs { + uriNode.Children[string(rune('0'+i))] = node.New(string(rune('0'+i)), uri) + } + n.Children["uniformResourceIdentifier"] = uriNode + } + + return n +} + func buildRSAKey(key *rsa.PublicKey) *node.Node { n := node.New("publicKey", nil) n.Children["keySize"] = node.New("keySize", key.N.BitLen()) @@ -613,4 +668,28 @@ func buildGeneralSubtreeNames(parent *node.Node, name string, subtrees []x509.Ge subtreeNode.Children[fmt.Sprintf("%d", i)] = child } parent.Children[name] = subtreeNode +} + +func buildCABFOrganizationID(cert *x509.Certificate) *node.Node { + n := node.New("cabfOrganizationIdentifier", nil) + + orgID := cert.CABFOrganizationIdentifier + if orgID == nil { + return n + } + + if orgID.Scheme != "" { + n.Children["scheme"] = node.New("scheme", orgID.Scheme) + } + if orgID.Country != "" { + n.Children["country"] = node.New("country", orgID.Country) + } + if orgID.State != "" { + n.Children["state"] = node.New("state", orgID.State) + } + if orgID.Reference != "" { + n.Children["reference"] = node.New("reference", orgID.Reference) + } + + return n } \ No newline at end of file From ac15e4d3d52e0aa97221fd8554fdddc65d9091dc Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 16:57:18 +0800 Subject: [PATCH 23/44] feat: add policy friendly names to root-level certificatePolicies node Add friendly names (dvPolicy, ovPolicy, evPolicy, etc.) to the certificatePolicies node at the root level, allowing rules to use readable names like certificatePolicies.evPolicy instead of OIDs. The friendly names were previously only available in the extensions node path, but rules typically use the root-level path. --- internal/cert/zcrypto/builder.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 86e0b3c..85b8d4b 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -168,6 +168,11 @@ func buildCertificate(cert *x509.Certificate) *node.Node { policyNode := node.New(fmt.Sprintf("%d", i), nil) policyNode.Children["oid"] = node.New("oid", oid.String()) policiesNode.Children[oid.String()] = policyNode + // Add friendly name for known policy OIDs + friendlyName := policyFriendlyName(oid.String()) + if friendlyName != "" { + policiesNode.Children[friendlyName] = policyNode + } } root.Children["certificatePolicies"] = policiesNode } From 29d5f4a759588becb28e7d14caf04e04e1460d01 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 17:27:00 +0800 Subject: [PATCH 24/44] fix: wildcard path resolver returns nil when nothing matches Fixed resolvePath in every operator to return nil instead of an empty virtual node when wildcard matching finds no children. This allows operators like 'absent' to work correctly with wildcard paths. Added Name Constraints test case with test certificate to verify the fix and validate the node tree structure. --- internal/cert/testdata/nc_ca.pem | 21 +++++++++++++++++ internal/cert/zcrypto/builder_test.go | 33 +++++++++++++++++++++++++++ internal/operator/every.go | 4 ++++ 3 files changed, 58 insertions(+) create mode 100644 internal/cert/testdata/nc_ca.pem diff --git a/internal/cert/testdata/nc_ca.pem b/internal/cert/testdata/nc_ca.pem new file mode 100644 index 0000000..e3b0451 --- /dev/null +++ b/internal/cert/testdata/nc_ca.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDeTCCAmGgAwIBAgIUeWDyp5bHXXZAIqgxbJ3LzOYVsmQwDQYJKoZIhvcNAQEL +BQAwNzELMAkGA1UEBhMCVVMxEzARBgNVBAoMClRlc3QgTkMgQ0ExEzARBgNVBAMM +ClRlc3QgTkMgQ0EwHhcNMjYwNTAxMDkwOTU5WhcNMjcwNTAxMDkwOTU5WjA3MQsw +CQYDVQQGEwJVUzETMBEGA1UECgwKVGVzdCBOQyBDQTETMBEGA1UEAwwKVGVzdCBO +QyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMlJJYa0dAN0VdQG +mi6ESvD6kJJghY+v/OQq8ej42S/nqP11TTNql1aCLpWG2wH5vL3oHdjE/8ou9/OP +w8XqxtHDmF8h2b3LfF3wcPkrprNLyWvQqXLbrt8R+ahYJVO3TOGcJQFLvpfwYdIj +zMlhtegD57QgeaVLA5aMHzz6XdQqjCNGyZo/n7va2B9Uk3oebQeAxd4iZjfIV7ON +Qwp9msigyvMbNq02ZWEusnOvpOBxc1ZTvenj7Epceqww2g3H9zcmeLPeUh5+0Mp0 +pDJkxH5GAEUDvL2M09dh3O0IJdOYx/f4bZYXXrQZUmYoq9YLUrPBCtGJd7pMa1V7 +8hf5tHUCAwEAAaN9MHswDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +HQYDVR0OBBYEFHO/ZAF6V1RHR9Xeu4eXB22DqJfRMDkGA1UdHgEB/wQvMC2gKzAN +ggtleGFtcGxlLmNvbTAOggwuZXhhbXBsZS5jb20wCocIwKgAAP//AAAwDQYJKoZI +hvcNAQELBQADggEBALv0Vbs9PLxLvN77FJlUR+qD97D4tbEo1qifOoR5HhmiuDrP +dpYfuhqWyHukk6QZihsuddX59OjgYBy3GAMYqcfs9f46Nk3dV9YCSbG8lqs0kDXx +TfLV5Bn+T5vSWyLwLfw+tQ5wj28K/t2e5rAlAsGj/BiksEFUQ+azgO90OpgiLdNa +xrTQTRWDE4xDskHGzJ9AkkkPGc2WDpJn9dtaF4ChEvrp72a2JhEOisJypeNUURe6 +3+8GgYg2kRttL2X4yiefv1SjJ9L+tSz7CcHOOxtqMWcW2BfA24wr35bLNypXW9+d +wH5pB8ljGvyC0qYV6K9dTElbhcfxQhX5LnYXcmU= +-----END CERTIFICATE----- diff --git a/internal/cert/zcrypto/builder_test.go b/internal/cert/zcrypto/builder_test.go index a1c61d1..82ea710 100644 --- a/internal/cert/zcrypto/builder_test.go +++ b/internal/cert/zcrypto/builder_test.go @@ -447,3 +447,36 @@ func TestBuilder_CRLDPStructure(t *testing.T) { t.Logf("Scheme: %v", scheme.Value) } } + +func TestBuilder_NameConstraints(t *testing.T) { + root := loadCert(t, "nc_ca.pem") + + // Check nameConstraints node exists + assertPathExists(t, root, "certificate.nameConstraints") + + // Check critical flag + assertPathValue(t, root, "certificate.nameConstraints.critical", true) + + // Check permittedSubtrees exists + assertPathExists(t, root, "certificate.nameConstraints.permittedSubtrees") + + // Check permittedSubtrees.dNSName exists + assertPathExists(t, root, "certificate.nameConstraints.permittedSubtrees.dNSName") + + // Check first DNS constraint value + dns0, ok := root.Resolve("certificate.nameConstraints.permittedSubtrees.dNSName.0") + if !ok { + t.Fatal("DNS constraint 0 not found") + } + if dns0.Children["value"] == nil { + t.Error("DNS constraint should have value") + } + t.Logf("DNS constraint 0 value: %v", dns0.Children["value"].Value) + + // Check permittedSubtrees.iPAddress exists + assertPathExists(t, root, "certificate.nameConstraints.permittedSubtrees.iPAddress") + + // Check min/max are NOT present (BR requirement) + assertPathNotExists(t, root, "certificate.nameConstraints.permittedSubtrees.dNSName.0.min") + assertPathNotExists(t, root, "certificate.nameConstraints.permittedSubtrees.dNSName.0.max") +} diff --git a/internal/operator/every.go b/internal/operator/every.go index 3ecd1d5..336681a 100644 --- a/internal/operator/every.go +++ b/internal/operator/every.go @@ -212,6 +212,10 @@ func resolvePath(n *node.Node, path string) *node.Node { virtualNode.Children[fmt.Sprintf("%d", len(virtualNode.Children))] = child } } + // Return nil if virtualNode has no children (nothing matched the wildcard) + if len(virtualNode.Children) == 0 { + return nil + } return virtualNode } From 6335c3ff297977e35ba4c49f5e7e9caf203c9a29 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 17:41:08 +0800 Subject: [PATCH 25/44] feat: add policy friendly names to extensions.certificatePolicies node Add friendly name mapping (dvPolicy, ovPolicy, evPolicy, etc.) in ParseCertPolicies for extensions.certificatePolicies path, matching the root-level support added in builder.go. Both paths now support readable policy names for rule convenience. --- internal/cert/zcrypto/extension_parser.go | 53 ++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/cert/zcrypto/extension_parser.go b/internal/cert/zcrypto/extension_parser.go index 5e3226d..0fd364e 100644 --- a/internal/cert/zcrypto/extension_parser.go +++ b/internal/cert/zcrypto/extension_parser.go @@ -398,7 +398,15 @@ func ParseCertPolicies(extValue []byte) *node.Node { policyOIDStr := oidString(policyOID) policyNode.Children["policyIdentifier"] = node.New("policyIdentifier", policyOIDStr) - // Add policy by OID as well for direct access + // Add friendly name for known policy OIDs + friendlyName := policyFriendlyName(policyOIDStr) + if friendlyName != "" { + policyNode.Children["name"] = node.New("name", friendlyName) + // Add policy by friendly name for direct access + n.Children[friendlyName] = policyNode + } + + // Add policy by OID as well for direct access (compatibility) n.Children[policyOIDStr] = policyNode // Read policyQualifiers (OPTIONAL SEQUENCE) @@ -541,4 +549,47 @@ func asn1StringType(tag int) string { default: return "unknown" } +} + +// policyFriendlyName returns friendly name for known certificate policy OIDs +func policyFriendlyName(oid string) string { + switch oid { + // TLS/SSL Server Certificate Policies + case "2.23.140.1.2.1": + return "dvPolicy" + case "2.23.140.1.2.2": + return "ovPolicy" + case "2.23.140.1.2.3": + return "ivPolicy" + case "2.23.140.1.1": + return "evPolicy" + case "2.5.29.32.0": + return "anyPolicy" + // Code Signing Policies + case "2.23.140.1.4.1": + return "codeSigningPolicy" + // SMIME Policies - Mailbox-validated + case "2.23.140.1.5.1.1": + return "smimeMailboxLegacy" + case "2.23.140.1.5.1.2": + return "smimeMailboxMultipurpose" + case "2.23.140.1.5.1.3": + return "smimeMailboxStrict" + // SMIME Policies - Organization-validated + case "2.23.140.1.5.2.1": + return "smimeOrgLegacy" + case "2.23.140.1.5.2.2": + return "smimeOrgMultipurpose" + case "2.23.140.1.5.2.3": + return "smimeOrgStrict" + // SMIME Policies - Sponsor-validated + case "2.23.140.1.5.3.1": + return "smimeSponsorLegacy" + case "2.23.140.1.5.3.2": + return "smimeSponsorMultipurpose" + case "2.23.140.1.5.3.3": + return "smimeSponsorStrict" + default: + return "" + } } \ No newline at end of file From a70fcf319aed67d7e16d30759b421faa275f90c6 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Fri, 1 May 2026 23:45:50 +0800 Subject: [PATCH 26/44] feat: add Source tracking and dual CRL/OCSP evaluation for auto-validate - Add Source, DownloadURL, DownloadFormat fields to cert.Info for tracking download origin (local, downloaded, extracted from PKCS#7) - Add Source field to crl.Info and ocsp.Info for similar tracking - Display source info in output (e.g., "(downloaded)" for auto-fetched resources) - Add standalone CRL evaluation in auto-validate mode (dual evaluation): - CRL rules now appear both under certificate context and as separate "crl" type - Certificate context: cert-not-revoked, crl-valid (CRL as data source) - Standalone CRL: crl-signature-algorithm-params (CRL format compliance) - Add standalone OCSP evaluation with proper CertType="ocsp" - Evaluate OCSP signing certificate if present in response (Type="ocspSigning") - Add downloadFormat/downloadURL to node tree for PEM format detection rule - Add nameConstraints to extension friendly name mapping - Add DEREqualsHex operator for Mozilla byte-for-byte encoding validation --- internal/asn1/parser.go | 3 + internal/cert/cert.go | 13 ++- internal/cert/chain.go | 1 + internal/cert/zcrypto/builder.go | 33 ++++-- internal/crl/crl.go | 1 + internal/linter/runner.go | 153 +++++++++++++++++++++++++-- internal/ocsp/ocsp.go | 1 + internal/operator/membership.go | 53 ++++++++++ internal/operator/membership_test.go | 57 ++++++++++ internal/operator/operator.go | 2 + internal/output/result.go | 1 + internal/output/text.go | 8 +- internal/policy/engine.go | 4 + internal/zcrypto/helpers.go | 1 + 14 files changed, 309 insertions(+), 22 deletions(-) diff --git a/internal/asn1/parser.go b/internal/asn1/parser.go index c49509c..ccd15a7 100644 --- a/internal/asn1/parser.go +++ b/internal/asn1/parser.go @@ -11,6 +11,7 @@ type ParamsState struct { IsAbsent bool // parameters field is absent OID string // algorithm OID NamedCurve string // namedCurve OID for ECDSA (from parameters field) + RawDER []byte // raw DER bytes of the entire AlgorithmIdentifier SEQUENCE // RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) PSS *PSSParams @@ -51,6 +52,8 @@ type AlgorithmIdentifier struct { // and returns the parameters state and OID. func ParseAlgorithmIDParams(derBytes []byte) ParamsState { result := ParamsState{} + // Store raw DER bytes for byte-for-byte encoding validation (Mozilla requirements) + result.RawDER = derBytes input := cryptobyte.String(derBytes) diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 2d4d9be..fa1bc1e 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -12,11 +12,14 @@ import ( var extensions = []string{".pem", ".der", ".crt", ".cer"} type Info struct { - Cert *x509.Certificate - FilePath string - Hash string - Position int - Type string + Cert *x509.Certificate + FilePath string + Hash string + Position int + Type string + Source string // Source description: "local", "downloaded", "extracted from PKCS#7", etc. + DownloadURL string // URL if certificate was downloaded via CA Issuers + DownloadFormat string // Format of download: "DER", "PKCS7", "PEM" (PEM is not RFC compliant) } func ParseCertificate(data []byte) (*x509.Certificate, error) { diff --git a/internal/cert/chain.go b/internal/cert/chain.go index e08ed5d..b5b0038 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -26,6 +26,7 @@ func LoadCertificates(path string) ([]*Info, error) { Cert: r.Data, FilePath: r.FilePath, Hash: r.Hash, + Source: "local", } } return infos, nil diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 85b8d4b..0ab8af0 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -186,9 +186,14 @@ func buildSignatureAlgorithm(cert *x509.Certificate) *node.Node { if len(cert.SignatureAlgorithmOID) > 0 { n.Children["oid"] = node.New("oid", cert.SignatureAlgorithmOID.String()) } - params := buildAlgorithmIDParams(ParseCertSignatureAlgorithmParams(cert.Raw)) - if params != nil { - n.Children["parameters"] = params + params := ParseCertSignatureAlgorithmParams(cert.Raw) + // Add raw DER bytes for byte-for-byte encoding validation (Mozilla requirements) + if len(params.RawDER) > 0 { + n.Children["rawDER"] = node.New("rawDER", params.RawDER) + } + paramsNode := buildAlgorithmIDParams(params) + if paramsNode != nil { + n.Children["parameters"] = paramsNode } return n } @@ -199,9 +204,14 @@ func buildTBSSignatureAlgorithm(cert *x509.Certificate) *node.Node { if len(cert.SignatureAlgorithmOID) > 0 { n.Children["oid"] = node.New("oid", cert.SignatureAlgorithmOID.String()) } - params := buildAlgorithmIDParams(ParseTBSCertSignatureParams(cert.RawTBSCertificate)) - if params != nil { - n.Children["parameters"] = params + params := ParseTBSCertSignatureParams(cert.RawTBSCertificate) + // Add raw DER bytes for byte-for-byte encoding validation (Mozilla requirements) + if len(params.RawDER) > 0 { + n.Children["rawDER"] = node.New("rawDER", params.RawDER) + } + paramsNode := buildAlgorithmIDParams(params) + if paramsNode != nil { + n.Children["parameters"] = paramsNode } return n } @@ -312,9 +322,14 @@ func buildSubjectPublicKeyInfo(cert *x509.Certificate) *node.Node { if len(cert.PublicKeyAlgorithmOID) > 0 { algo.Children["oid"] = node.New("oid", cert.PublicKeyAlgorithmOID.String()) } - params := buildAlgorithmIDParams(ParseSubjectPublicKeyInfoParams(cert.RawSubjectPublicKeyInfo)) - if params != nil { - algo.Children["parameters"] = params + params := ParseSubjectPublicKeyInfoParams(cert.RawSubjectPublicKeyInfo) + // Add raw DER bytes for byte-for-byte encoding validation (Mozilla requirements) + if len(params.RawDER) > 0 { + algo.Children["rawDER"] = node.New("rawDER", params.RawDER) + } + paramsNode := buildAlgorithmIDParams(params) + if paramsNode != nil { + algo.Children["parameters"] = paramsNode } n.Children["algorithm"] = algo diff --git a/internal/crl/crl.go b/internal/crl/crl.go index 3fb1041..5fdc66a 100644 --- a/internal/crl/crl.go +++ b/internal/crl/crl.go @@ -16,6 +16,7 @@ type Info struct { CRL *x509.RevocationList FilePath string Hash string + Source string // Source description: "local", "downloaded", etc. } func ParseCRL(data []byte) (*x509.RevocationList, error) { diff --git a/internal/linter/runner.go b/internal/linter/runner.go index f5baf9f..36b7ebb 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -175,6 +175,12 @@ func Run(cfg Config, w io.Writer) error { for _, c := range chain { tree := certzcrypto.BuildTree(c.Cert) + // Add download format to tree for PEM format detection rule + if c.DownloadFormat != "" { + tree.Children["downloadFormat"] = node.New("downloadFormat", c.DownloadFormat) + tree.Children["downloadURL"] = node.New("downloadURL", c.DownloadURL) + } + // Add CRL node to tree if CRLs are present if len(crls) > 0 { for _, crlInfo := range crls { @@ -214,20 +220,97 @@ func Run(cfg Config, w io.Writer) error { continue } + // Create synthetic cert.Info for OCSP response to set proper CertType + ocspCertInfo := &cert.Info{ + FilePath: ocspInfo.FilePath, + Type: "ocsp", + Source: ocspInfo.Source, + } + tree := ocspNode ctxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - ctx := operator.NewEvaluationContext(tree, nil, chain, ctxOpts...) + ctx := operator.NewEvaluationContext(tree, ocspCertInfo, chain, ctxOpts...) filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) for _, p := range filteredPolicies { res := policy.Evaluate(p, tree, reg, ctx) results = append(results, res) } + + // Evaluate OCSP signing certificate if present in response + if ocspInfo.Response.Certificate != nil { + // Convert standard cert to zcrypto cert + zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) + if err != nil || zcryptoSignerCert == nil { + continue + } + ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) + ocspSignerInfo := &cert.Info{ + Cert: zcryptoSignerCert, + FilePath: ocspInfo.FilePath + " (signing cert)", + Type: "ocspSigning", + Source: "extracted from OCSP response", + } + + signerCtxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + signerCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, signerCtxOpts...) + + signerPolicies := filterPoliciesByCert(policies, zcryptoSignerCert) + for _, p := range signerPolicies { + res := policy.Evaluate(p, ocspSignerTree, reg, signerCtx) + results = append(results, res) + } + } } } + + // Evaluate CRLs independently if CRLs were fetched (dual evaluation) + // This evaluates CRL-specific rules (like signature algorithm params) + // separately from certificate context + if len(crls) > 0 { + for _, crlInfo := range crls { + if crlInfo.CRL == nil { + continue + } + + // Build issuer certificates list from chain + var issuerCerts []*x509.Certificate + for _, c := range chain { + if c.Cert != nil { + issuerCerts = append(issuerCerts, c.Cert) + } + } + + crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) + if crlNode == nil { + continue + } + + // Create synthetic cert.Info for CRL to set proper CertType + crlCertInfo := &cert.Info{ + FilePath: crlInfo.FilePath, + Type: "crl", + Source: crlInfo.Source, + } + + tree := crlNode + ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} + ctx := operator.NewEvaluationContext(tree, crlCertInfo, chain, ctxOpts...) + + // Filter policies by CRL type + hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) + isIndirect := isIndirectCRL(crlInfo.CRL) + filteredPolicies := filterPoliciesByCRL(policies, hasDelta, isIndirect) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, reg, ctx) + results = append(results, res) + } + } + } } else if len(crls) > 0 { // Process CRLs independently when no certificates provided // Use issuers as chain for CRL signature verification + // Also evaluate CRLs in auto-validate mode (dual evaluation) for _, crlInfo := range crls { if crlInfo.CRL == nil { continue @@ -246,11 +329,18 @@ func Run(cfg Config, w io.Writer) error { continue } + // Create synthetic cert.Info for CRL to set proper CertType + crlCertInfo := &cert.Info{ + FilePath: crlInfo.FilePath, + Type: "crl", + Source: crlInfo.Source, + } + // Create a minimal tree with just the CRL tree := crlNode ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} - ctx := operator.NewEvaluationContext(tree, nil, issuers, ctxOpts...) + ctx := operator.NewEvaluationContext(tree, crlCertInfo, issuers, ctxOpts...) // Filter policies by CRL type hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) @@ -273,11 +363,18 @@ func Run(cfg Config, w io.Writer) error { continue } + // Create synthetic cert.Info for OCSP response to set proper CertType + ocspCertInfo := &cert.Info{ + FilePath: ocspInfo.FilePath, + Type: "ocsp", + Source: ocspInfo.Source, + } + // Use OCSP node directly as tree root tree := ocspNode ctxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - ctx := operator.NewEvaluationContext(tree, nil, nil, ctxOpts...) + ctx := operator.NewEvaluationContext(tree, ocspCertInfo, nil, ctxOpts...) // Filter policies by input type (OCSP) filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) @@ -285,6 +382,31 @@ func Run(cfg Config, w io.Writer) error { res := policy.Evaluate(p, tree, reg, ctx) results = append(results, res) } + + // Evaluate OCSP signing certificate if present in response + if ocspInfo.Response.Certificate != nil { + // Convert standard cert to zcrypto cert + zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) + if err != nil || zcryptoSignerCert == nil { + continue + } + ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) + ocspSignerInfo := &cert.Info{ + Cert: zcryptoSignerCert, + FilePath: ocspInfo.FilePath + " (signing cert)", + Type: "ocspSigning", + Source: "extracted from OCSP response", + } + + signerCtxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + signerCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, nil, signerCtxOpts...) + + signerPolicies := filterPoliciesByCert(policies, zcryptoSignerCert) + for _, p := range signerPolicies { + res := policy.Evaluate(p, ocspSignerTree, reg, signerCtx) + results = append(results, res) + } + } } } else { return fmt.Errorf("no certificates, CRLs, or OCSP responses provided") @@ -432,6 +554,7 @@ func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.No info := &ocsp.Info{ Response: fetchResult.Response, FilePath: url, // Use URL as "file path" for auto-fetched responses + Source: "downloaded", } // Populate request debug info @@ -541,11 +664,26 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr } // Add issuer to chain + var source string + switch pkcs7Result.Format { + case aia.FormatPKCS7: + source = "extracted from PKCS#7" + case aia.FormatDER: + source = "downloaded" + case aia.FormatPEM: + source = "downloaded PEM" + fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) + default: + source = "downloaded" + } issuerInfo := &cert.Info{ - Cert: issuerCert, - FilePath: url, - Type: cert.GetCertType(issuerCert, len(result), len(result)+1), - Position: len(result), + Cert: issuerCert, + FilePath: url, + Type: cert.GetCertType(issuerCert, len(result), len(result)+1), + Position: len(result), + Source: source, + DownloadURL: url, + DownloadFormat: string(pkcs7Result.Format), } result = append(result, issuerInfo) @@ -580,6 +718,7 @@ func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl results = append(results, &crl.Info{ CRL: fetchResult.CRL, FilePath: url, + Source: "downloaded", }) } } diff --git a/internal/ocsp/ocsp.go b/internal/ocsp/ocsp.go index f36f42a..3e42398 100644 --- a/internal/ocsp/ocsp.go +++ b/internal/ocsp/ocsp.go @@ -16,6 +16,7 @@ type Info struct { Response *ocsp.Response FilePath string Hash string + Source string // Source description: "local", "downloaded", etc. // Request debug info (populated when auto-fetching) RequestNonce []byte // Nonce sent in request diff --git a/internal/operator/membership.go b/internal/operator/membership.go index 8ad057a..f9b7b12 100644 --- a/internal/operator/membership.go +++ b/internal/operator/membership.go @@ -1,6 +1,7 @@ package operator import ( + "encoding/hex" "fmt" "reflect" "strings" @@ -104,6 +105,58 @@ func (Contains) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bo return false, fmt.Errorf("contains requires a slice, array, node with children, or string") } +// DEREqualsHex checks if the node's value ([]byte, raw DER encoding) matches a hex string exactly. +// Used for Mozilla byte-for-byte DER encoding validation. +// Operand: hex string (e.g., "300d06092a864886f70d0101010500") +type DEREqualsHex struct{} + +func (DEREqualsHex) Name() string { return "derEqualsHex" } + +func (DEREqualsHex) Evaluate(n *node.Node, _ *EvaluationContext, operands []any) (bool, error) { + if n == nil { + return false, nil + } + if len(operands) == 0 { + return false, fmt.Errorf("derEqualsHex requires at least 1 operand") + } + + // Get raw bytes from node value + rawDER, ok := n.Value.([]byte) + if !ok { + return false, nil + } + + // Compare against each hex operand + for _, op := range operands { + hexStr, ok := op.(string) + if !ok { + continue + } + expected, err := hex.DecodeString(hexStr) + if err != nil { + continue + } + if bytesEqual(rawDER, expected) { + return true, nil + } + } + + return false, nil +} + +// bytesEqual compares two byte slices +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func equal(a, b any) bool { if a == b { return true diff --git a/internal/operator/membership_test.go b/internal/operator/membership_test.go index 736c634..c62349c 100644 --- a/internal/operator/membership_test.go +++ b/internal/operator/membership_test.go @@ -168,3 +168,60 @@ func TestContainsWrongOperands(t *testing.T) { t.Error("should not match 'x' or 'y' in slice") } } + +func TestDEREqualsHex(t *testing.T) { + // RSA AlgorithmIdentifier: SEQUENCE { OID 1.2.840.113549.1.1.1, NULL } + // DER encoding: 30 0d 06 09 2a 86 48 86 f7 0d 01 01 01 05 00 + rsaDER := []byte{0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00} + + // ECDSA with secp256r1: SEQUENCE { OID 1.2.840.10045.2.1, OID 1.2.840.10045.3.1.7 } + // DER encoding: 30 13 06 07 2a 86 48 ce 3d 02 01 06 08 2a 86 48 ce 3d 03 01 07 + ecdsaDER := []byte{0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07} + + tests := []struct { + name string + value any + operands []any + expected bool + }{ + {"exact match RSA", rsaDER, []any{"300d06092a864886f70d0101010500"}, true}, + {"exact match ECDSA", ecdsaDER, []any{"301306072a8648ce3d020106082a8648ce3d030107"}, true}, + {"no match", rsaDER, []any{"301306072a8648ce3d0201"}, false}, + {"multiple operands - one matches", rsaDER, []any{"301306072a8648ce3d0201", "300d06092a864886f70d0101010500"}, true}, + {"multiple operands - none match", rsaDER, []any{"301306072a8648ce3d0201", "deadbeef"}, false}, + {"invalid hex operand skipped", rsaDER, []any{"not-valid-hex", "300d06092a864886f70d0101010500"}, true}, + {"non-string operand skipped", rsaDER, []any{123, "300d06092a864886f70d0101010500"}, true}, + {"wrong type value", "not bytes", []any{"300d06092a864886f70d0101010500"}, false}, + } + + op := DEREqualsHex{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := node.New("test", tt.value) + got, err := op.Evaluate(n, nil, tt.operands) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if got != tt.expected { + t.Errorf("got %v, want %v", got, tt.expected) + } + }) + } +} + +func TestDEREqualsHexNilNode(t *testing.T) { + op := DEREqualsHex{} + got, _ := op.Evaluate(nil, nil, []any{"300d06092a864886f70d0101010500"}) + if got != false { + t.Error("nil node should return false") + } +} + +func TestDEREqualsHexNoOperands(t *testing.T) { + op := DEREqualsHex{} + n := node.New("test", []byte{0x30, 0x0d}) + _, err := op.Evaluate(n, nil, []any{}) + if err == nil { + t.Error("should error with no operands") + } +} diff --git a/internal/operator/operator.go b/internal/operator/operator.go index ce8244c..8c67ea5 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -102,4 +102,6 @@ var All = []Operator{ IsUTF8String{}, ValidIA5String{}, ValidPrintableString{}, + // DER encoding validation (Mozilla byte-for-byte requirements) + DEREqualsHex{}, } diff --git a/internal/output/result.go b/internal/output/result.go index bd101b8..f0b4a97 100644 --- a/internal/output/result.go +++ b/internal/output/result.go @@ -72,6 +72,7 @@ func FilterRules(output LintOutput, opts Options) LintOutput { PolicyID: pr.PolicyID, CertType: pr.CertType, CertPath: pr.CertPath, + Source: pr.Source, Verdict: pr.Verdict, CheckedAt: pr.CheckedAt, Counts: pr.Counts, diff --git a/internal/output/text.go b/internal/output/text.go index 15c72e6..6e33776 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -55,13 +55,19 @@ func (f *TextFormatter) Format(w io.Writer, out LintOutput) error { if certPath == "" { certPath = "-" } + // Display source info if available and not local file + sourceInfo := "" + if pr.Source != "" && pr.Source != "local" { + sourceInfo = fmt.Sprintf(" (%s)", pr.Source) + } passCount, failCount, skipCount, warnCount := countsFromResult(pr) if _, err := fmt.Fprintf( w, - "[File] Policy: %s | Cert: %s | File: %s | Verdict: %s | %s: %d, %s: %d, %s: %d, %s: %d\n", + "[File] Policy: %s | Cert: %s | File: %s%s | Verdict: %s | %s: %d, %s: %d, %s: %d, %s: %d\n", pr.PolicyID, pr.CertType, certPath, + sourceInfo, verdictLabelColored(pr.Verdict), verdictLabelColored(rule.VerdictPass), passCount, diff --git a/internal/policy/engine.go b/internal/policy/engine.go index 8605a3e..6fe6d1f 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -24,6 +24,7 @@ type Result struct { PolicyID string `json:"policy_id" yaml:"policy_id"` CertType string `json:"cert_type" yaml:"cert_type"` CertPath string `json:"cert_path" yaml:"cert_path"` + Source string `json:"source" yaml:"source"` Results []rule.Result `json:"rules" yaml:"rules"` Verdict string `json:"verdict" yaml:"verdict"` CheckedAt time.Time `json:"checked_at" yaml:"checked_at"` @@ -57,15 +58,18 @@ func Evaluate( certType := "" certPath := "" + source := "" if ctx != nil && ctx.Cert != nil { certType = ctx.Cert.Type certPath = ctx.Cert.FilePath + source = ctx.Cert.Source } return Result{ PolicyID: p.ID, CertType: certType, CertPath: certPath, + Source: source, Results: results, Verdict: verdict, CheckedAt: time.Now(), diff --git a/internal/zcrypto/helpers.go b/internal/zcrypto/helpers.go index 65b2af9..35ffd0f 100644 --- a/internal/zcrypto/helpers.go +++ b/internal/zcrypto/helpers.go @@ -16,6 +16,7 @@ var extensionNames = map[string]string{ "2.5.29.17": "subjectAltName", "2.5.29.18": "issuerAltName", "2.5.29.19": "basicConstraints", + "2.5.29.30": "nameConstraints", "2.5.29.31": "cRLDistributionPoints", "2.5.29.32": "certificatePolicies", "2.5.29.35": "authorityKeyIdentifier", From 9e15ebd00cad08aafae4410511f0ea74d07c0ca4 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Tue, 5 May 2026 22:33:06 +0800 Subject: [PATCH 27/44] docs: rewrite POLICY_WRITING_GUIDE based on actual policy usage Major improvements to English version: - Add all 64 operators actually used in policies with correct naming - Add usage frequency data from policy analysis (present: 157, eq: 136, absent: 94) - Organize operators into 21 categories for better navigation - Add most common target paths with usage counts - Correct appliesTo values: certificate types are roles (leaf, intermediate, root, ocspSigning) - Add input types (crl, ocsp) to appliesTo documentation - Remove incorrect policy-level appliesTo/certType/crlType fields - Fix operator naming to match actual YAML usage (derEqualsHex not DEREqualsHex) - Add ASN.1 parameter handling explanation (absent vs isNull vs present) - Update certificate type detection logic explanation - Add complete OID reference tables Also update RFC5280.yaml with additional rules: - Add CA issuers DER format check for downloaded certificates - Add CRL unknown critical extensions check - Add CRL entry reason code validation using every operator - Add Subject DN field length limits (CN, O, OU, etc.) - Add time format validation (UTCTime/GeneralizedTime encoding) - Add encoding validation (IA5String, PrintableString) - Add SAN URI and IAN validation rules --- docs/POLICY_WRITING_GUIDE.md | 1041 ++++++++++++++++++++++++++++++++++ policies/RFC5280.yaml | 563 +++++++++++++++++- 2 files changed, 1593 insertions(+), 11 deletions(-) create mode 100644 docs/POLICY_WRITING_GUIDE.md diff --git a/docs/POLICY_WRITING_GUIDE.md b/docs/POLICY_WRITING_GUIDE.md new file mode 100644 index 0000000..0d99986 --- /dev/null +++ b/docs/POLICY_WRITING_GUIDE.md @@ -0,0 +1,1041 @@ +# Policy Rule Writing Guide + +This document provides a comprehensive guide for writing policy rules in PCL (Policy-based Certificate Linter). + +## Table of Contents + +- [Overview](#overview) +- [Policy File Structure](#policy-file-structure) +- [Rule Structure](#rule-structure) +- [Target Paths](#target-paths) +- [Operators](#operators) +- [Severity Levels](#severity-levels) +- [Certificate Type Filtering](#certificate-type-filtering) +- [Conditional Rules (when)](#conditional-rules-when) +- [Best Practices](#best-practices) +- [Examples](#examples) + +--- + +## Overview + +PCL uses YAML-based policy files to define validation rules for X.509 certificates, CRLs (Certificate Revocation Lists), and OCSP responses. Each policy file contains a list of rules that are evaluated against PKI objects. + +Key concepts: +- **Policy**: A collection of rules for validating PKI objects +- **Rule**: A single validation check with a target, operator, and expected result +- **Target**: A path to a specific field in the PKI object's data structure +- **Operator**: The comparison or validation logic to apply +- **Evaluation Context**: Runtime context containing certificate chain, CRLs, OCSP responses, etc. + +### Supported Standards + +PCL policies can validate compliance with various PKI standards: +- [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) - Internet X.509 PKI Certificate and CRL Profile +- [RFC 4055](https://datatracker.ietf.org/doc/html/rfc4055) - Additional Algorithms and Identifiers for RSA Cryptography +- [RFC 5480](https://datatracker.ietf.org/doc/html/rfc5480) - Elliptic Curve Cryptography Subject Public Key Information +- [RFC 5758](https://datatracker.ietf.org/doc/html/rfc5758) - Additional Algorithms and Identifiers for DSA and ECDSA +- [RFC 5759](https://datatracker.ietf.org/doc/html/rfc5759) - DSA and ECDSA Algorithm Identifiers for CRLs +- [RFC 6960](https://datatracker.ietf.org/doc/html/rfc6960) - Online Certificate Status Protocol (OCSP) +- CA/Browser Forum Baseline Requirements (BR) +- CA/Browser Forum EV Guidelines +- CA/Browser Forum SMIME BR +- CA/Browser Forum Code Signing BR + +--- + +## Policy File Structure + +```yaml +id: policy-name # Required: Unique identifier for the policy +version: "1.0" # Optional: Policy version + +rules: + - id: rule-1 + ... + - id: rule-2 + ... +``` + +### Metadata Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique policy identifier (e.g., `RFC5280`, `CA-Browser-BR`) | +| `version` | No | Version string for the policy | + +--- + +## Rule Structure + +Each rule has the following structure: + +```yaml +- id: rule-id # Required: Unique rule identifier + reference: RFC5280 4.1.2.1 # Optional: Reference to specification section + target: certificate.version # Required: Path to the field to check + 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 + when: # Optional: Precondition that must be met + target: certificate.extensions + operator: present +``` + +### Rule Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique rule identifier within the policy | +| `reference` | No | Specification reference (RFC section, BR section, etc.) | +| `target` | Yes | Path to the field to validate (see Target Paths) | +| `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) | +| `when` | No | Precondition that must be true before evaluating the rule | + +--- + +## Target Paths + +Target paths use dot notation to navigate the node tree structure. The tree root is named by input type: + +- `certificate.xxx` - Certificate fields +- `crl.xxx` - CRL fields +- `ocsp.xxx` - OCSP response fields + +### Most Common Target Paths + +Based on actual policy usage, these are the most frequently used target paths: + +| Target Path | Usage Count | Description | +|-------------|-------------|-------------| +| `certificate.extKeyUsage` | 55 | Extended Key Usage extension | +| `certificate.subjectPublicKeyInfo.algorithm.oid` | 39 | Public key algorithm OID | +| `certificate.subjectAltName.dNSName` | 35 | SAN DNS names | +| `certificate.signatureAlgorithm.oid` | 29 | Signature algorithm OID | +| `certificate.subjectPublicKeyInfo.algorithm.parameters.namedCurve` | 22 | ECDSA curve OID | +| `certificate.subjectPublicKeyInfo.algorithm.algorithm` | 21 | Algorithm name (RSA, ECDSA) | +| `certificate.subject.commonName` | 19 | Subject CN | +| `certificate.tbsSignatureAlgorithm.oid` | 16 | TBSCertificate signature algorithm | +| `certificate.signedCertificateTimestamps` | 16 | SCT list | +| `crl` | 15 | CRL root (for CRL operators) | + +### Certificate Target Paths + +#### Basic Fields +``` +certificate.version # Certificate version (1, 2, 3) +certificate.serialNumber # Serial number node +certificate.serialNumber.value # Serial number value (string) +certificate.issuer # Issuer DN node +certificate.subject # Subject DN node +certificate.validity.notBefore # NotBefore time +certificate.validity.notAfter # NotAfter time +certificate.signatureAlgorithm # Signature algorithm node +certificate.signatureAlgorithm.oid # Algorithm OID +certificate.tbsSignatureAlgorithm # TBSCertificate signature algorithm +``` + +#### Issuer/Subject DN Fields +``` +certificate.issuer.commonName # Common Name (CN) +certificate.issuer.countryName # Country (C) +certificate.issuer.organizationName # Organization (O) +certificate.issuer.organizationalUnitName # Organizational Unit (OU) +certificate.issuer.stateOrProvinceName # State/Province (ST) +certificate.issuer.localityName # Locality (L) +certificate.issuer.domainComponent # Domain Component (DC) +``` + +#### Extensions (by OID or friendly name) +``` +certificate.extensions.2.5.29.14 # subjectKeyIdentifier extension +certificate.extensions.2.5.29.15 # keyUsage extension +certificate.extensions.2.5.29.19 # basicConstraints extension +certificate.extensions.2.5.29.35 # authorityKeyIdentifier extension +certificate.extensions.2.5.29.37 # extKeyUsage extension +certificate.extensions.1.3.6.1.5.5.7.1.1 # authorityInformationAccess +certificate.extensions.2.5.29.31 # cRLDistributionPoints +certificate.extensions.2.5.29.17 # subjectAltName +certificate.extensions.2.5.29.32 # certificatePolicies +certificate.extensions.2.5.29.30 # nameConstraints +``` + +#### Common Extension Fields +``` +certificate.subjectKeyIdentifier # SKI value (hex string) +certificate.authorityKeyIdentifier # AKI value (hex string) +certificate.basicConstraints.cA # cA boolean +certificate.basicConstraints.pathLenConstraint # Path length +certificate.keyUsage # KeyUsage node +certificate.keyUsage.digitalSignature # KeyUsage bit (boolean) +certificate.keyUsage.keyCertSign # KeyUsage bit (boolean) +certificate.keyUsage.cRLSign # KeyUsage bit (boolean) +certificate.extKeyUsage # ExtKeyUsage node +certificate.extKeyUsage.serverAuth # EKU presence (boolean) +certificate.extKeyUsage.clientAuth # EKU presence (boolean) +certificate.extKeyUsage.codeSigning # EKU presence (boolean) +certificate.extKeyUsage.emailProtection # EKU presence (boolean) +certificate.extKeyUsage.timeStamping # EKU presence (boolean) +certificate.extKeyUsage.ocspSigning # EKU presence (boolean) +``` + +#### Certificate Policies Fields +``` +certificate.certificatePolicies # Certificate Policies node +certificate.certificatePolicies.evPolicy # EV policy OID (boolean) +certificate.certificatePolicies.ovPolicy # OV policy OID (boolean) +certificate.certificatePolicies.dvPolicy # DV policy OID (boolean) +certificate.certificatePolicies.smimeMailboxLegacy # SMIME mailbox legacy (boolean) +``` + +#### SubjectPublicKeyInfo +``` +certificate.subjectPublicKeyInfo.algorithm.algorithm # Algorithm name (RSA, ECDSA) +certificate.subjectPublicKeyInfo.algorithm.oid # Algorithm OID +certificate.subjectPublicKeyInfo.publicKey.keySize # RSA key size (bits) +certificate.subjectPublicKeyInfo.publicKey.curve # ECDSA curve name (P-256, P-384, P-521) +certificate.subjectPublicKeyInfo.algorithm.parameters # Algorithm parameters node +certificate.subjectPublicKeyInfo.algorithm.parameters.namedCurve # ECDSA curve OID +certificate.subjectPublicKeyInfo.algorithm.parameters.null # NULL flag (boolean) +``` + +#### AIA (Authority Information Access) +``` +certificate.extensions.authorityInfoAccess # AIA extension node +certificate.extensions.authorityInfoAccess.accessDescriptions # AccessDescriptions array +certificate.extensions.authorityInfoAccess.accessDescriptions.0.accessMethod # OID +certificate.extensions.authorityInfoAccess.accessDescriptions.0.accessLocation.type # GeneralName type +certificate.extensions.authorityInfoAccess.accessDescriptions.0.accessLocation.scheme # URI scheme +certificate.extensions.authorityInfoAccess.accessDescriptions.0.accessLocation.value # URI value +certificate.ocspURL # OCSP URL from AIA +certificate.caIssuersURL # CA Issuers URL from AIA +``` + +#### CRL Distribution Points +``` +certificate.extensions.cRLDistributionPoints # CRL DP extension node +certificate.extensions.cRLDistributionPoints.distributionPoints # DistributionPoints array +certificate.extensions.cRLDistributionPoints.distributionPoints.0.distributionPoint.fullName.generalNames.0.scheme # URI scheme +certificate.cRLDistributionPoints # Legacy shortcut for CRL DP URLs +``` + +#### SCT (Signed Certificate Timestamps) +``` +certificate.signedCertificateTimestamps # SCT list node (count = number of SCTs) +``` + +#### SAN (Subject Alternative Name) +``` +certificate.subjectAltName.dNSName # DNS names +certificate.subjectAltName.rfc822Name # Email addresses +certificate.subjectAltName.uniformResourceIdentifier # URIs +certificate.subjectAltName.iPAddress # IP addresses +``` + +### CRL Target Paths + +``` +crl.issuer # CRL issuer DN +crl.thisUpdate # ThisUpdate time +crl.nextUpdate # NextUpdate time (may be zero) +crl.isCACRL # Boolean: true if issuer is CA certificate +crl.signatureAlgorithm # Signature algorithm node +crl.signatureAlgorithm.oid # Algorithm OID +crl.signatureAlgorithm.parameters # Parameters node +crl.tbsSignatureAlgorithm # TBSCertList signature algorithm +crl.crlNumber # CRL number (string) +crl.authorityKeyIdentifier # AKI value +crl.revokedCertificates # Revoked certificates list +crl.revokedCertificates.0.serialNumber # Serial number of revoked cert +crl.revokedCertificates.0.revocationDate # Revocation time +crl.revokedCertificates.0.extensions # Entry extensions +crl.extensions.2.5.29.20 # crlNumber extension +crl.extensions.2.5.29.35 # authorityKeyIdentifier extension +``` + +### OCSP Target Paths + +``` +ocsp.status # Response status (good, revoked, unknown) +ocsp.signatureAlgorithm # Signature algorithm node +ocsp.signatureAlgorithm.oid # Algorithm OID +ocsp.tbsSignatureAlgorithm # TBSResponseData signature algorithm +ocsp.responderID # Responder ID node +ocsp.producedAt # ProducedAt time +ocsp.thisUpdate # ThisUpdate time +ocsp.nonce # nonce extension node +ocsp.nonce.present # nonce presence (boolean) +``` + +--- + +## Operators + +PCL provides 107 operators organized by category. All operators are defined in `internal/operator/operator.go`. + +### Operator Categories + +1. **Presence Operators** - Check if fields exist or are empty +2. **Equality Operators** - Compare values for equality or membership +3. **Comparison Operators** - Numeric comparisons +4. **String Operators** - Regex matching and length checks +5. **Date Operators** - Time validation +6. **Extension Operators** - Criticality and structure checks +7. **EKU Operators** - Extended Key Usage validation +8. **Chain Operators** - Certificate chain validation +9. **CRL Operators** - CRL structure and revocation checking +10. **OCSP Operators** - OCSP response validation +11. **Component Operators** - Multi-valued field validation +12. **CIDR Operators** - IP address range checking +13. **PSL/TLD Operators** - Domain and TLD validation +14. **ASN.1 Time Operators** - Encoding format validation +15. **ASN.1 String Operators** - String type validation +16. **Unique Value Operators** - Duplicate checking +17. **DER Encoding Operators** - Byte-for-byte validation + +### 1. Presence Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `present` | None | Returns true if target exists in the tree | +| `absent` | None | Returns true if target does NOT exist in the tree | +| `isEmpty` | None | Returns true if target value is empty | +| `notEmpty` | None | Returns true if target value is NOT empty | +| `isNull` | None | Returns true if node exists with `null=true` child (explicit NULL encoding) | + +**Usage Frequency**: `present` (157), `absent` (94) + +**Examples:** +```yaml +# Check extension is present (most common pattern) +- id: ski-present + target: certificate.subjectKeyIdentifier + operator: present + severity: error + +# Check extension is absent +- id: no-issuer-unique-id + target: certificate.issuerUniqueID + operator: absent + severity: error + +# RSA parameters MUST be NULL (RFC 4055) +- id: rsa-params-null + target: certificate.signatureAlgorithm.parameters + operator: isNull + severity: error + when: + target: certificate.signatureAlgorithm.oid + operator: in + operands: + - "1.2.840.113549.1.1.1" + - "1.2.840.113549.1.1.11" + +# ECDSA parameters MUST be absent (RFC 5758) +- id: ecdsa-params-absent + target: certificate.signatureAlgorithm.parameters + operator: absent + severity: error + when: + target: certificate.signatureAlgorithm.oid + operator: in + operands: + - "1.2.840.10045.4.3.2" + - "1.2.840.10045.4.3.3" +``` + +### 2. Equality Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `eq` | [value] | Returns true if target equals operand | +| `neq` | [value] | Returns true if target does NOT equal operand | +| `in` | [value1, value2, ...] | Returns true if target is in the list | +| `notIn` | [value1, value2, ...] | Returns true if target is NOT in the list | +| `contains` | [value] | Returns true if target contains the specified value | +| `matches` | [fieldPath] | Returns true if target equals the value at another field path | + +**Usage Frequency**: `eq` (136+), `in` (31+), `neq` (7) + +**Examples:** +```yaml +# Check version is 3 (most common eq usage) +- id: version-v3 + target: certificate.version + operator: eq + operands: [3] + severity: error + +# Check OID is in allowed list +- id: ecdsa-curve-valid + target: certificate.subjectPublicKeyInfo.algorithm.parameters.namedCurve + operator: in + operands: + - "1.2.840.10045.3.1.7" # P-256 + - "1.3.132.0.34" # P-384 + - "1.3.132.0.35" # P-521 + severity: error + +# Check basicConstraints.cA is false +- id: leaf-not-ca + target: certificate.basicConstraints.cA + operator: eq + operands: [false] + severity: error + appliesTo: [leaf] + +# Check signature algorithm matches tbsSignatureAlgorithm +- id: sig-alg-matches-tbs + target: certificate.signatureAlgorithm.oid + operator: matches + operands: ["certificate.tbsSignatureAlgorithm.oid"] + severity: error +``` + +### 3. Comparison Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `gte` | [number] | Returns true if target >= operand (numeric comparison) | +| `gt` | [number] | Returns true if target > operand | +| `lte` | [number] | Returns true if target <= operand | +| `lt` | [number] | Returns true if target < operand | +| `positive` | None | Returns true if target is a positive number | +| `odd` | None | Returns true if target is an odd number | + +**Usage Frequency**: `gte` (21), `lte` (4) + +**Examples:** +```yaml +# RSA key size at least 2048 bits (common pattern) +- id: rsa-key-size-min + target: certificate.subjectPublicKeyInfo.publicKey.keySize + operator: gte + operands: [2048] + severity: error + when: + target: certificate.subjectPublicKeyInfo.algorithm.algorithm + operator: eq + operands: [RSA] + +# Serial number positive +- id: serial-positive + target: certificate.serialNumber.value + operator: positive + severity: error +``` + +### 4. String Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `regex` | [pattern] | Returns true if target matches regex pattern | +| `notRegex` | [pattern] | Returns true if target does NOT match regex pattern | +| `minLength` | [count] | Returns true if node has at least N children/values | +| `maxLength` | [count] | Returns true if node has at most N children/values | + +**Usage Frequency**: `regex` (17), `notRegex` (19), `maxLength` (17), `minLength` (6) + +**Examples:** +```yaml +# Check subject does NOT contain underscore +- id: no-underscore-in-cn + target: certificate.subject.commonName + operator: notRegex + operands: ["_"] + severity: warning + +# Check at least 2 SCTs present +- id: sct-min-2-logs + target: certificate.signedCertificateTimestamps + operator: minLength + operands: [2] + severity: error + +# Serial number length <= 20 bytes +- id: serial-number-max-length + target: certificate.serialNumber + operator: maxLength + operands: [20] + severity: warning +``` + +### 5. Date Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `before` | None | Returns true if target date is before current time | +| `after` | [date] | Returns true if target date is after specified date (or current time if empty) | +| `validityOrderCorrect` | None | Returns true if notBefore < notAfter | +| `validityDays` | [days] | Returns true if validity period <= N days | +| `dateDiff` | [{start, end, maxDays, maxMonths, minHours}] | Returns true if date difference is within limits | + +**Usage Frequency**: `validityDays` (9), `dateDiff` (3), `after` (8) + +**Examples:** +```yaml +# Certificate must not be expired +- id: cert-not-expired + target: certificate.validity.notAfter + operator: after + severity: error + +# Subscriber cert validity <= 398 days +- id: subscriber-validity-max + target: certificate.validity + operator: validityDays + operands: [398] + severity: error + appliesTo: [leaf] + +# CRL nextUpdate within 10 days +- id: crl-nextupdate-10-days + target: crl + operator: dateDiff + operands: + - start: thisUpdate + end: nextUpdate + maxDays: 10 + severity: error +``` + +### 6. Extension Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `isCritical` | None | Returns true if the extension is marked critical | +| `notCritical` | None | Returns true if the extension is NOT marked critical | +| `noUnknownCriticalExtensions` | None | Returns true if no unhandled critical extensions exist | + +**Examples:** +```yaml +# SKI must not be critical +- id: ski-not-critical + target: certificate.extensions.2.5.29.14 + operator: notCritical + severity: error + +# basicConstraints must be critical for CA +- id: ca-bc-critical + target: certificate.extensions.2.5.29.19 + operator: isCritical + severity: error + appliesTo: [root, intermediate] +``` + +### 7. EKU Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `ekuContains` | [ekuName] | Returns true if extKeyUsage contains the specified EKU | +| `ekuNotContains` | [ekuName] | Returns true if extKeyUsage does NOT contain the specified EKU | +| `ekuServerAuth` | None | Returns true if extKeyUsage contains serverAuth | +| `ekuClientAuth` | None | Returns true if extKeyUsage contains clientAuth | + +**Usage Frequency**: `ekuContains` (48) + +**EKU names**: `serverAuth`, `clientAuth`, `codeSigning`, `emailProtection`, `timeStamping`, `ocspSigning` + +**Examples:** +```yaml +# Subscriber must have serverAuth EKU +- id: subscriber-serverauth-eku + target: certificate.extKeyUsage + operator: ekuContains + operands: [serverAuth] + severity: error + appliesTo: [leaf] +``` + +### 8. Key Usage Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `keyUsageCA` | None | Returns true if keyUsage has correct CA bits | +| `keyUsageLeaf` | None | Returns true if keyUsage has correct leaf bits | +| `sanRequiredIfEmptySubject` | None | Returns true if SAN is present when subject is empty | + +**Examples:** +```yaml +# CA keyUsage validation +- id: ca-key-usage + target: certificate.keyUsage + operator: keyUsageCA + severity: error + appliesTo: [root, intermediate] +``` + +### 9. Certificate Chain Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `signatureValid` | None | Returns true if certificate signature is valid | +| `signatureAlgorithmMatchesTBS` | None | Returns true if signatureAlgorithm matches tbsSignatureAlgorithm | +| `issuedBy` | None | Returns true if certificate's issuer DN matches issuer's subject DN | +| `akiMatchesSki` | None | Returns true if AKI matches issuer's SKI | +| `pathLenValid` | None | Returns true if pathLenConstraint is valid for chain position | +| `serialNumberUnique` | None | Returns true if serial number is unique within the chain | +| `noUniqueIdentifiers` | None | Returns true if issuerUniqueID and subjectUniqueID are absent | + +**Examples:** +```yaml +# Verify signature is valid +- id: signature-valid + target: certificate + operator: signatureValid + severity: error + +# Verify AKI matches issuer's SKI +- id: aki-matches-ski + target: certificate + operator: akiMatchesSki + severity: warning +``` + +### 10. CRL Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `crlValid` | None | Returns true if CRL is valid (time check) | +| `crlNotExpired` | None | Returns true if CRL has not expired | +| `crlSignedBy` | None | Returns true if CRL signature is valid | +| `notRevoked` | None | Returns true if certificate is not in CRL's revoked list | + +**Examples:** +```yaml +- id: crl-valid + target: crl + operator: crlValid + severity: error + +- id: cert-not-revoked + target: certificate + operator: notRevoked + severity: error +``` + +### 11. OCSP Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `ocspValid` | None | Returns true if OCSP response is valid | +| `notRevokedOCSP` | None | Returns true if OCSP status is not "revoked" | +| `ocspGood` | None | Returns true if OCSP status is explicitly "Good" | + +**Examples:** +```yaml +- id: ocsp-response-valid + target: ocsp + operator: ocspValid + severity: error +``` + +### 12. Every Operator (Array Iteration) + +The `every` operator checks that ALL elements in an array satisfy a condition. + +| Operator | Operands | Description | +|----------|----------|-------------| +| `every` | [{path, operator, operands, skipMissing}] | Iterates array elements and checks each | + +**Usage Frequency**: `every` (14) + +**Parameters:** +- `path`: Sub-path relative to each element (supports `*` wildcard) +- `operator`: Inner operator name +- `operands`: Operands for the inner operator +- `skipMissing`: Skip elements where path doesn't exist (default: false) + +**Examples:** +```yaml +# All CRL entries must have valid reason codes +- id: crl-entries-valid-reason + target: crl.revokedCertificates + operator: every + operands: + path: extensions.2.5.29.21.value + operator: in + operands: [0, 1, 2, 3, 4, 5, 6, 8, 9, 10] + severity: warning + when: + target: crl.revokedCertificates + operator: present + +# All CRL DP URIs must use HTTP +- id: crl-dp-http-uri + target: certificate.extensions.cRLDistributionPoints.distributionPoints + operator: every + operands: + path: "*.distributionPoint.fullName.generalNames.*.scheme" + operator: eq + operands: ["http"] + severity: error + when: + target: certificate.extensions.cRLDistributionPoints + operator: present +``` + +### 13. Path Validation Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `nameConstraintsValid` | None | Returns true if names are valid against chain's constraints | +| `certificatePolicyValid` | None | Returns true if policy OIDs are valid through chain | + +### 14. Component Operators (Multi-Valued Fields) + +| Operator | Operands | Description | +|----------|----------|-------------| +| `componentMaxLength` | [length, separator] | Each component has at most N characters | +| `componentMinLength` | [length, separator] | Each component has at least N characters | +| `componentRegex` | [pattern] | Each component matches regex | +| `componentNotRegex` | [pattern] | Each component does NOT match regex | +| `anyComponentMatches` | [pattern] | ANY component matches regex | +| `noComponentMatches` | [pattern] | NO component matches regex | + +**Usage Frequency**: `componentNotRegex` (12) + +**Examples:** +```yaml +# DNS names must not contain wildcard +- id: san-no-wildcard + target: certificate.subjectAltName.dNSName + operator: noComponentMatches + operands: ["^\\*"] + severity: warning +``` + +### 15. CIDR Operators (IP Address) + +| Operator | Operands | Description | +|----------|----------|-------------| +| `componentInCIDR` | [cidr1, cidr2, ...] | Each IP is in any CIDR range | +| `componentNotInCIDR` | [cidr1, cidr2, ...] | Each IP is NOT in any CIDR range | + +**Examples:** +```yaml +# SAN IP addresses must not be in reserved ranges +- id: no-reserved-ipv4 + target: certificate.subjectAltName.iPAddress + operator: componentNotInCIDR + operands: + - "10.0.0.0/8" + - "127.0.0.0/8" + - "192.168.0.0/16" + severity: error +``` + +### 16. PSL/TLD Operators (Domain Validation) + +| Operator | Operands | Description | +|----------|----------|-------------| +| `tldRegistered` | None | TLD is in IANA Root Zone Database | +| `tldNotRegistered` | None | TLD is NOT registered | +| `isPublicSuffix` | None | Domain is a public suffix | +| `isNotPublicSuffix` | None | Domain is NOT a public suffix | +| `componentTLDRegistered` | None | Each domain's TLD is registered | +| `componentTLDNotRegistered` | None | Each domain's TLD is NOT registered | +| `componentIsPublicSuffix` | None | Any domain is a public suffix | +| `componentNotPublicSuffix` | None | All domains are NOT public suffixes | + +**Examples:** +```yaml +# No internal names (BR 4.2.2) +- id: no-internal-names + target: certificate.subjectAltName.dNSName + operator: componentTLDRegistered + severity: error +``` + +### 17. ASN.1 Time Format Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `utctimeHasSeconds` | None | UTCTime includes seconds | +| `utctimeHasZulu` | None | UTCTime ends with 'Z' | +| `generalizedTimeHasZulu` | None | GeneralizedTime ends with 'Z' | +| `generalizedTimeNoFraction` | None | No fractional seconds | +| `isUTCTime` | None | Field uses UTCTime encoding | +| `isGeneralizedTime` | None | Field uses GeneralizedTime encoding | + +### 18. ASN.1 String Type Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `isIA5String` | None | Field uses IA5String encoding (ASCII) | +| `isPrintableString` | None | Field uses PrintableString encoding | +| `isUTF8String` | None | Field uses UTF8String encoding | +| `validIA5String` | None | String contains only valid IA5String chars (ASCII, 0-127) | +| `validPrintableString` | None | String contains only valid PrintableString chars (A-Z, a-z, 0-9, + specific specials) | +| `utf8NoBom` | None | UTF-8 string has no BOM | +| `containsBOM` | None | String contains BOM | + +**Usage Frequency**: `validIA5String` (4), `isIA5String`, `isUTF8String` (for encoding type checks) + +### 19. Unique Value Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `uniqueValues` | None | All values in array are unique | +| `uniqueChildren` | None | All children have unique content | + +### 20. DER Encoding Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `derEqualsHex` | [hexString] | DER encoding matches expected hex bytes | + +**Usage Frequency**: `derEqualsHex` (8) + +### 21. Subject DN Operators + +| Operator | Operands | Description | +|----------|----------|-------------| +| `noDuplicateAttributes` | None | Subject DN has no duplicate attributes | + +--- + +## Severity Levels + +| Severity | Description | Behavior | +|----------|-------------|----------| +| `error` | MUST requirement violation | FAIL results in policy FAIL | +| `warning` | SHOULD requirement violation | FAIL results in policy WARN | +| `info` | MAY/NOT RECOMMENDED | FAIL does not affect verdict | + +--- + +## Certificate Type Filtering + +The `appliesTo` field filters rules by certificate type or input type. + +### Certificate Types (Roles) + +| Type | Description | +|------|-------------| +| `leaf` | End-entity (subscriber) certificate | +| `intermediate` | Subordinate CA certificate | +| `root` | Self-signed root CA certificate | +| `ocspSigning` | OCSP responder certificate | + +### 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. + +**Examples:** +```yaml +# Apply only to leaf certificates +- id: subscriber-serverauth-eku + target: certificate.extKeyUsage + operator: ekuContains + operands: [serverAuth] + severity: error + appliesTo: [leaf] + +# Apply to CA certificates +- id: ca-key-cert-sign + target: certificate.keyUsage.keyCertSign + operator: present + severity: error + appliesTo: [root, intermediate] + +# Apply only to CRL inputs +- id: crl-validity-check + target: crl + operator: crlValid + severity: error + appliesTo: [crl] +``` + +--- + +## Conditional Rules (when) + +The `when` clause adds a precondition. If false, the rule is skipped. + +**Common Use Cases:** + +**1. Algorithm-specific rules:** +```yaml +- id: rsa-key-size-min + target: certificate.subjectPublicKeyInfo.publicKey.keySize + operator: gte + operands: [2048] + severity: error + when: + target: certificate.subjectPublicKeyInfo.algorithm.algorithm + operator: eq + operands: [RSA] +``` + +**2. Extension conditional checks:** +```yaml +- id: crl-dp-http-scheme + target: certificate.cRLDistributionPoints.0 + operator: regex + operands: ["^http://"] + severity: error + when: + target: certificate.cRLDistributionPoints + operator: present +``` + +--- + +## Best Practices + +### Rule Naming + +Use descriptive, consistent naming: +- Format: `_
_` +- Example: `RFC5280_4_1_2_1_VERSION_V3` + +### Include References + +Always include specification references: +```yaml +- id: subscriber-serverauth-eku + reference: BR 7.1.2.7.10 + ... +``` + +### Severity Selection + +- `error`: MUST requirements +- `warning`: SHOULD requirements +- `info`: MAY requirements or informational + +### Use appliesTo Appropriately + +Use `appliesTo` to avoid confusing SKIP messages: +```yaml +# Without appliesTo: will SKIP on CA certs +- id: subscriber-not-ca + target: certificate.basicConstraints.cA + operator: eq + operands: [false] + severity: error + appliesTo: [leaf] # Clear: only evaluated on leaf +``` + +--- + +## Examples + +### Complete Policy Example + +```yaml +id: example-policy +version: "1.0" + +rules: + # Certificate Structure + - id: version-v3 + reference: RFC5280 4.1.2.1 + target: certificate.version + operator: eq + operands: [3] + severity: error + + # Extensions + - id: ski-present + reference: RFC5280 4.2.1.2 + target: certificate.subjectKeyIdentifier + operator: present + severity: warning + appliesTo: [root, intermediate] + + - id: ski-not-critical + reference: RFC5280 4.2.1.2 + target: certificate.extensions.2.5.29.14.critical + operator: eq + operands: [false] + severity: error + + # Basic Constraints + - id: leaf-not-ca + reference: RFC5280 4.2.1.9 + target: certificate.basicConstraints.cA + operator: eq + operands: [false] + severity: error + appliesTo: [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] + + # Algorithm-Specific + - id: rsa-key-size-min + reference: BR 7.1.3.1.1 + target: certificate.subjectPublicKeyInfo.publicKey.keySize + operator: gte + operands: [2048] + severity: error + when: + target: certificate.subjectPublicKeyInfo.algorithm.algorithm + operator: eq + operands: [RSA] + + # CRL Rules + - id: crl-valid + reference: RFC5280 5.1 + target: crl + operator: crlValid + severity: error + appliesTo: [crl] +``` + +--- + +## Appendix: OID Reference + +### Extension OIDs + +| OID | Name | +|-----|------| +| 2.5.29.14 | subjectKeyIdentifier | +| 2.5.29.15 | keyUsage | +| 2.5.29.17 | subjectAltName | +| 2.5.29.19 | basicConstraints | +| 2.5.29.20 | crlNumber | +| 2.5.29.31 | cRLDistributionPoints | +| 2.5.29.35 | authorityKeyIdentifier | +| 2.5.29.37 | extKeyUsage | +| 1.3.6.1.5.5.7.1.1 | authorityInformationAccess | +| 1.3.6.1.4.1.11129.2.4.2 | SCT list | + +### Algorithm OIDs + +| OID | Algorithm | +|-----|-----------| +| 1.2.840.113549.1.1.1 | rsaEncryption | +| 1.2.840.113549.1.1.10 | RSASSA-PSS | +| 1.2.840.113549.1.1.11 | sha256WithRSAEncryption | +| 1.2.840.10045.2.1 | id-ecPublicKey | +| 1.2.840.10045.4.3.2 | ecdsa-with-SHA256 | +| 1.2.840.10045.3.1.7 | secp256r1 (P-256) | + +### EKU OIDs + +| OID | Purpose | +|-----|---------| +| 1.3.6.1.5.5.7.3.1 | serverAuth | +| 1.3.6.1.5.5.7.3.2 | clientAuth | +| 1.3.6.1.5.5.7.3.3 | codeSigning | +| 1.3.6.1.5.5.7.3.4 | emailProtection | +| 1.3.6.1.5.5.7.3.8 | timeStamping | +| 1.3.6.1.5.5.7.3.9 | ocspSigning | \ No newline at end of file diff --git a/policies/RFC5280.yaml b/policies/RFC5280.yaml index ddf1dfb..e850526 100644 --- a/policies/RFC5280.yaml +++ b/policies/RFC5280.yaml @@ -161,9 +161,9 @@ rules: - id: aki-not-critical reference: RFC5280 4.2.1.1 when: - target: certificate.extensions.2.5.29.35 + target: certificate.authorityKeyIdentifier operator: present - target: certificate.extensions.2.5.29.35.critical + target: certificate.extensions.authorityKeyIdentifier.critical operator: eq operands: [false] severity: error @@ -189,9 +189,9 @@ rules: - id: ski-not-critical reference: RFC5280 4.2.1.2 when: - target: certificate.extensions.2.5.29.14 + target: certificate.extensions.subjectKeyIdentifier operator: present - target: certificate.extensions.2.5.29.14.critical + target: certificate.extensions.subjectKeyIdentifier.critical operator: eq operands: [false] severity: error @@ -213,7 +213,7 @@ rules: target: certificate.basicConstraints.cA operator: eq operands: [true] - target: certificate.extensions.2.5.29.15.critical + target: certificate.extensions.keyUsage.critical operator: eq operands: [true] severity: warning @@ -265,7 +265,7 @@ rules: when: target: certificate.subject operator: isEmpty - target: certificate.extensions.2.5.29.17.critical + target: certificate.extensions.subjectAltName.critical operator: eq operands: [true] severity: error @@ -318,7 +318,7 @@ rules: target: certificate.basicConstraints.cA operator: eq operands: [true] - target: certificate.extensions.2.5.29.19.critical + target: certificate.extensions.basicConstraints.critical operator: eq operands: [true] severity: error @@ -354,9 +354,9 @@ rules: - id: name-constraints-critical reference: RFC5280 4.2.1.10 when: - target: certificate.extensions.2.5.29.30 + target: certificate.nameConstraints operator: present - target: certificate.extensions.2.5.29.30.critical + target: certificate.extensions.nameConstraints.critical operator: eq operands: [true] severity: error @@ -439,6 +439,20 @@ rules: operands: [false] severity: error + # CA Issuers MUST be DER or BER encoded (PEM is not RFC compliant) + # RFC 5280 4.2.2.1: "If the information is available via HTTP or FTP, then the + # URI MUST point to either a single DER-encoded certificate or a + # DER-encoded PKCS#7 certificate bundle." + - id: ca-issuers-der-format + reference: RFC5280 4.2.2.1 + when: + target: certificate.downloadFormat + operator: present + target: certificate.downloadFormat + operator: neq + operands: ["PEM"] + severity: warning + # ------------------------------------------------- # Subject Information Access (Section 4.2.2.2) @@ -498,9 +512,9 @@ rules: - id: crl-aki-not-critical reference: RFC5280 5.2 when: - target: crl.extensions.2.5.29.35 + target: crl.authorityKeyIdentifier operator: present - target: crl.extensions.2.5.29.35.critical + target: crl.authorityKeyIdentifier.critical operator: eq operands: [false] severity: error @@ -535,6 +549,52 @@ rules: operands: [true] severity: error + # No unknown critical CRL extensions (RFC 5280 5.2) + - id: crl-no-unknown-critical-extensions + reference: RFC5280 5.2 + when: + target: crl.extensions + operator: present + target: crl + operator: noUnknownCriticalExtensions + severity: error + + # ------------------------------------------------- + # CRL Entry Extensions (Section 5.3) + # ------------------------------------------------- + + # Per RFC 5280 5.3.1, reasonCode extension (OID 2.5.29.21) is OPTIONAL + # but recommended for better revocation information management. + # This rule checks that all revoked entries have reason codes. + - id: crl-entries-have-reason + reference: RFC5280 5.3.1 + when: + target: crl + operator: present + target: crl.revokedCertificates + operator: every + operands: + - path: extensions.2.5.29.21 + check: present + severity: warning + + # Validate that reason codes are within valid range (1-10, except 7) + # Valid: 1(keyCompromise), 2(cACompromise), 3(affiliationChanged), 4(superseded), + # 5(cessationOfOperation), 6(certHold), 8(removeFromCRL), 9(privilegeWithdrawn), 10(aACompromise) + - id: crl-entry-reason-valid + reference: RFC5280 5.3.1 + when: + target: crl + operator: present + target: crl.revokedCertificates + operator: every + operands: + - path: extensions.2.5.29.21.value + check: in + values: [1, 2, 3, 4, 5, 6, 8, 9, 10] + skipMissing: true + severity: error + # ------------------------------------------------- # OCSP (RFC 6960) @@ -557,3 +617,484 @@ rules: target: certificate operator: notRevokedOCSP severity: error + + # ------------------------------------------------- + # Subject DN Field Length Limits (RFC 5280 Appendix A) + # ------------------------------------------------- + + # commonName max 64 characters (RFC 5280) + - id: subject-cn-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.commonName + operator: present + target: certificate.subject.commonName + operator: maxLength + operands: [64] + severity: error + message: "commonName must not exceed 64 characters" + appliesTo: [leaf, intermediate] + + # organizationName max 64 characters (RFC 5280) + - id: subject-org-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.organizationName + operator: present + target: certificate.subject.organizationName + operator: maxLength + operands: [64] + severity: error + message: "organizationName must not exceed 64 characters" + appliesTo: [leaf, intermediate] + + # organizationalUnitName max 64 characters (RFC 5280) + - id: subject-ou-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.organizationalUnitName + operator: present + target: certificate.subject.organizationalUnitName + operator: maxLength + operands: [64] + severity: error + message: "organizationalUnitName must not exceed 64 characters" + appliesTo: [leaf, intermediate] + + # localityName max 128 characters (RFC 5280) + - id: subject-locality-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.localityName + operator: present + target: certificate.subject.localityName + operator: maxLength + operands: [128] + severity: error + message: "localityName must not exceed 128 characters" + appliesTo: [leaf, intermediate] + + # stateOrProvinceName max 128 characters (RFC 5280) + - id: subject-state-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.stateOrProvinceName + operator: present + target: certificate.subject.stateOrProvinceName + operator: maxLength + operands: [128] + severity: error + message: "stateOrProvinceName must not exceed 128 characters" + appliesTo: [leaf, intermediate] + + # countryName exactly 2 characters (ISO 3166-1) + - id: subject-country-length + reference: RFC5280 Appendix A.1 + ISO 3166-1 + when: + target: certificate.subject.countryName + operator: present + target: certificate.subject.countryName + operator: maxLength + operands: [2] + severity: error + message: "countryName must be exactly 2 characters (ISO 3166-1)" + appliesTo: [leaf, intermediate] + + - id: subject-country-min-length + reference: RFC5280 Appendix A.1 + ISO 3166-1 + when: + target: certificate.subject.countryName + operator: present + target: certificate.subject.countryName + operator: minLength + operands: [2] + severity: error + message: "countryName must be exactly 2 characters (ISO 3166-1)" + appliesTo: [leaf, intermediate] + + # serialNumber (subject) max 64 characters (CABF BR) + - id: subject-serial-number-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.serialNumber + operator: present + target: certificate.subject.serialNumber + operator: maxLength + operands: [64] + severity: error + message: "subject serialNumber must not exceed 64 characters" + appliesTo: [leaf, intermediate] + + # givenName max 64 characters (RFC 5280) + - id: subject-givenname-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.givenName + operator: present + target: certificate.subject.givenName + operator: maxLength + operands: [64] + severity: error + message: "givenName must not exceed 64 characters" + appliesTo: [leaf, intermediate] + + # surname max 64 characters (RFC 5280) + - id: subject-surname-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.surname + operator: present + target: certificate.subject.surname + operator: maxLength + operands: [64] + severity: error + message: "surname must not exceed 64 characters" + appliesTo: [leaf, intermediate] + + # businessCategory max 128 characters (RFC 5280) + - id: subject-business-category-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.businessCategory + operator: present + target: certificate.subject.businessCategory + operator: maxLength + operands: [128] + severity: error + message: "businessCategory must not exceed 128 characters" + appliesTo: [leaf, intermediate] + + # emailAddress in subject max 255 characters (RFC 5280) + - id: subject-email-max-length + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.emailAddress + operator: present + target: certificate.subject.emailAddress + operator: maxLength + operands: [255] + severity: error + message: "subject emailAddress must not exceed 255 characters" + appliesTo: [leaf, intermediate] + + # emailAddress format validation (RFC 822) + - id: subject-email-format + reference: RFC5280 + RFC 822 + when: + target: certificate.subject.emailAddress + operator: present + target: certificate.subject.emailAddress + operator: regex + operands: ["^[^@]+@[^@]+$"] + severity: warning + message: "subject emailAddress should be valid RFC 822 format" + appliesTo: [leaf, intermediate] + + # SAN rfc822Name format validation (RFC 822) + - id: san-rfc822-name-format + reference: RFC5280 4.2.1.6 + RFC 822 + when: + target: certificate.subjectAltName.rfc822Name + operator: present + target: certificate.subjectAltName.rfc822Name + operator: regex + operands: ["^[^@]+@[^@]+$"] + severity: error + message: "SAN rfc822Name must be valid RFC 822 email format" + appliesTo: [leaf] + + # IAN rfc822Name format validation (RFC 822) + - id: ian-rfc822-name-format + reference: RFC5280 4.2.1.8 + RFC 822 + when: + target: certificate.issuerAltName.rfc822Name + operator: present + target: certificate.issuerAltName.rfc822Name + operator: regex + operands: ["^[^@]+@[^@]+$"] + severity: warning + message: "IAN rfc822Name should be valid RFC 822 email format" + appliesTo: [leaf, intermediate] + + # IAN dNSName must not contain wildcards (RFC 5280 4.2.1.6) + - id: ian-no-wildcard-dns + reference: RFC5280 4.2.1.8 + when: + target: certificate.issuerAltName.dNSName + operator: present + target: certificate.issuerAltName.dNSName + operator: notRegex + operands: ["^\\*"] + severity: warning + message: "IAN dNSName should not contain wildcard" + appliesTo: [leaf, intermediate] + + # IAN dNSName must be valid DNS label format (RFC 5280 + RFC 9549) + - id: ian-dns-valid-label + reference: RFC5280 4.2.1.8 + RFC 9549 + when: + target: certificate.issuerAltName.dNSName + operator: present + target: certificate.issuerAltName.dNSName + operator: componentRegex + operands: ["^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$"] + severity: warning + message: "IAN dNSName components must be valid DNS labels" + appliesTo: [leaf, intermediate] + + # IAN URI must use HTTP or HTTPS scheme (best practice) + - id: ian-uri-http-scheme + reference: RFC5280 4.2.1.8 + when: + target: certificate.issuerAltName.uniformResourceIdentifier + operator: present + target: certificate.issuerAltName.uniformResourceIdentifier + operator: regex + operands: ["^https?://"] + severity: warning + message: "IAN URI should use HTTP or HTTPS scheme" + appliesTo: [leaf, intermediate] + + # IAN must not contain internal names (RFC 5280 best practice) + - id: ian-no-internal-names + reference: RFC5280 4.2.1.8 + when: + target: certificate.issuerAltName.dNSName + operator: present + target: certificate.issuerAltName.dNSName + operator: componentTLDRegistered + severity: warning + message: "IAN dNSName should not contain internal names with unregistered TLDs" + appliesTo: [leaf, intermediate] + + # ================================================== + # URI Validation (RFC 5280 4.2.1.6) + # ================================================== + + # SAN uniformResourceIdentifier must be valid HTTP/HTTPS URL + - id: san-uri-http-scheme + reference: RFC5280 4.2.1.6 + CABF BR + when: + target: certificate.subjectAltName.uniformResourceIdentifier + operator: present + target: certificate.subjectAltName.uniformResourceIdentifier + operator: regex + operands: ["^https?://"] + severity: warning + message: "SAN URI should use HTTP or HTTPS scheme" + appliesTo: [leaf] + + # SAN URI must not contain fragment identifier + - id: san-uri-no-fragment + reference: RFC5280 4.2.1.6 + when: + target: certificate.subjectAltName.uniformResourceIdentifier + operator: present + target: certificate.subjectAltName.uniformResourceIdentifier + operator: notRegex + operands: ["#"] + severity: warning + message: "SAN URI should not contain fragment identifier" + appliesTo: [leaf] + + # SAN URI max length (reasonable limit) + - id: san-uri-max-length + reference: RFC5280 4.2.1.6 + when: + target: certificate.subjectAltName.uniformResourceIdentifier + operator: present + target: certificate.subjectAltName.uniformResourceIdentifier + operator: maxLength + operands: [1024] + severity: warning + message: "SAN URI should not exceed 1024 characters" + appliesTo: [leaf] + + # domainComponent max 63 characters per label (DNS label limit) + - id: subject-dc-label-max-length + reference: RFC5280 + RFC 5890 + when: + target: certificate.subject.domainComponent + operator: present + target: certificate.subject.domainComponent + operator: componentMaxLength + operands: [63, "."] + severity: error + message: "Each domainComponent label must not exceed 63 characters" + appliesTo: [leaf, intermediate] + + # ------------------------------------------------- + # Time Format Validation (RFC 5280 4.1.2.5) + # ------------------------------------------------- + + # UTCTime format: YYMMDDHHMMSSZ per RFC 5280 4.1.2.5.1 + # UTCTime MUST have seconds present (even if 00) + - id: validity-utctime-has-seconds + reference: RFC5280 4.1.2.5.1 + when: + target: certificate.validity.notBefore.isUTC + operator: eq + operands: [true] + target: certificate.validity.notBefore + operator: utctimeHasSeconds + severity: error + message: "UTCTime MUST include seconds per RFC 5280 4.1.2.5.1" + appliesTo: [leaf, intermediate, root] + + - id: validity-utctime-has-seconds-notafter + reference: RFC5280 4.1.2.5.1 + when: + target: certificate.validity.notAfter.isUTC + operator: eq + operands: [true] + target: certificate.validity.notAfter + operator: utctimeHasSeconds + severity: error + message: "UTCTime MUST include seconds per RFC 5280 4.1.2.5.1" + appliesTo: [leaf, intermediate, root] + + # UTCTime MUST end with 'Z' (Zulu time indicator) + - id: validity-utctime-has-zulu-notbefore + reference: RFC5280 4.1.2.5.1 + when: + target: certificate.validity.notBefore.isUTC + operator: eq + operands: [true] + target: certificate.validity.notBefore + operator: utctimeHasZulu + severity: error + message: "UTCTime MUST end with 'Z' (Zulu indicator) per RFC 5280 4.1.2.5.1" + appliesTo: [leaf, intermediate, root] + + - id: validity-utctime-has-zulu-notafter + reference: RFC5280 4.1.2.5.1 + when: + target: certificate.validity.notAfter.isUTC + operator: eq + operands: [true] + target: certificate.validity.notAfter + operator: utctimeHasZulu + severity: error + message: "UTCTime MUST end with 'Z' (Zulu indicator) per RFC 5280 4.1.2.5.1" + appliesTo: [leaf, intermediate, root] + + # GeneralizedTime MUST end with 'Z' + - id: validity-generalizedtime-has-zulu-notbefore + reference: RFC5280 4.1.2.5.2 + when: + target: certificate.validity.notBefore.isUTC + operator: eq + operands: [false] + target: certificate.validity.notBefore + operator: generalizedTimeHasZulu + severity: error + message: "GeneralizedTime MUST end with 'Z' per RFC 5280 4.1.2.5.2" + appliesTo: [leaf, intermediate, root] + + - id: validity-generalizedtime-has-zulu-notafter + reference: RFC5280 4.1.2.5.2 + when: + target: certificate.validity.notAfter.isUTC + operator: eq + operands: [false] + target: certificate.validity.notAfter + operator: generalizedTimeHasZulu + severity: error + message: "GeneralizedTime MUST end with 'Z' per RFC 5280 4.1.2.5.2" + appliesTo: [leaf, intermediate, root] + + # GeneralizedTime should not have fractional seconds (recommended) + - id: validity-generalizedtime-no-fraction + reference: RFC5280 4.1.2.5.2 + when: + target: certificate.validity.notBefore.isUTC + operator: eq + operands: [false] + target: certificate.validity.notBefore + operator: generalizedTimeNoFraction + severity: warning + message: "GeneralizedTime SHOULD NOT include fractional seconds per RFC 5280" + appliesTo: [leaf, intermediate, root] + + - id: validity-generalizedtime-no-fraction-notafter + reference: RFC5280 4.1.2.5.2 + when: + target: certificate.validity.notAfter.isUTC + operator: eq + operands: [false] + target: certificate.validity.notAfter + operator: generalizedTimeNoFraction + severity: warning + message: "GeneralizedTime SHOULD NOT include fractional seconds per RFC 5280" + appliesTo: [leaf, intermediate, root] + + # ------------------------------------------------- + # Encoding Validation (ASN.1 String Types) + # ------------------------------------------------- + + # Country name MUST use PrintableString encoding (per RFC 5280 Appendix A.1) + # PrintableString allows: A-Z, 0-9, space, and specific special chars + - id: subject-country-printable-string + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.countryName + operator: present + target: certificate.subject.countryName + operator: validPrintableString + severity: warning + message: "countryName should use PrintableString compatible characters" + appliesTo: [leaf, intermediate] + + # Common name should use PrintableString or UTF8String for legacy compatibility + - id: subject-cn-valid-encoding + reference: RFC5280 Appendix A.1 + when: + target: certificate.subject.commonName + operator: present + target: certificate.subject.commonName + operator: validIA5String + severity: warning + message: "commonName should use IA5String compatible characters (ASCII)" + appliesTo: [leaf] + + # Organization name should use PrintableString or UTF8String + # Note: BR 7.1.4.2 permits both UTF8String and PrintableString encoding for +# organizationName, stateOrProvinceName, localityName. No content check needed. +# UTF8String can contain any Unicode characters; PrintableString content check +# only applies if the encoding tag is PrintableString (requires parser enhancement). + + # DNS names in SAN must be IA5String (ASCII) + - id: san-dnsname-valid-ia5string + reference: RFC5280 4.2.1.6 + when: + target: certificate.subjectAltName.dNSName + operator: present + target: certificate.subjectAltName.dNSName + operator: validIA5String + severity: error + message: "DNS names in SAN must use IA5String encoding (ASCII only)" + appliesTo: [leaf] + + # URIs in SAN must be IA5String (ASCII) + - id: san-uri-valid-ia5string + reference: RFC5280 4.2.1.6 + when: + target: certificate.subjectAltName.uniformResourceIdentifier + operator: present + target: certificate.subjectAltName.uniformResourceIdentifier + operator: validIA5String + severity: error + message: "URIs in SAN must use IA5String encoding (ASCII only)" + appliesTo: [leaf] + + # Email addresses must be IA5String (ASCII) + - id: san-email-valid-ia5string + reference: RFC5280 4.2.1.6 + when: + target: certificate.subjectAltName.rfc822Name + operator: present + target: certificate.subjectAltName.rfc822Name + operator: validIA5String + severity: error + message: "Email addresses in SAN must use IA5String encoding (ASCII only)" + appliesTo: [leaf] From b53bc8b063bdcd556b48dba8c7da161ca71b53f3 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Thu, 7 May 2026 16:19:21 +0800 Subject: [PATCH 28/44] fix: resolve golangci-lint errcheck issues - Fix unchecked fmt.Fprintf return values in runner.go - Fix unchecked resp.Body.Close return values in aia/fetcher.go All warnings now explicitly discard return values with _, _ = --- internal/aia/fetcher.go | 4 +-- internal/linter/runner.go | 72 +++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/internal/aia/fetcher.go b/internal/aia/fetcher.go index 0d3ed38..20964c4 100644 --- a/internal/aia/fetcher.go +++ b/internal/aia/fetcher.go @@ -57,7 +57,7 @@ func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { if err != nil { return nil, fmt.Errorf("failed to fetch CA Issuers from %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("CA Issuers server returned status %d", resp.StatusCode) @@ -110,7 +110,7 @@ func FetchCAIssuerPKCS7(url string, timeout time.Duration) (*PKCS7Result, error) if err != nil { return nil, fmt.Errorf("failed to fetch CA Issuers from %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("CA Issuers server returned status %d", resp.StatusCode) diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 36b7ebb..ee5aae5 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -158,7 +158,7 @@ func Run(cfg Config, w io.Writer) error { miniChain := []*cert.Info{c, chain[i+1]} autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout, nonceOpts) if err != nil { - fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) + _, _ = fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) continue } // Debug: print OCSP response details when verbosity >= 2 @@ -617,7 +617,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr url := top.Cert.IssuingCertificateURL[0] pkcs7Result, err := aia.FetchCAIssuerPKCS7(url, timeout) if err != nil { - fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) + _, _ = fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) break } @@ -649,7 +649,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr } else { // Multiple certs with no match - use first as best guess issuerCert = pkcs7Result.Certs[0] - fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(pkcs7Result.Certs)) + _, _ = fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(pkcs7Result.Certs)) } } @@ -657,7 +657,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr if issuerCert.SerialNumber != nil { serial := issuerCert.SerialNumber.String() if seen[serial] { - fmt.Fprintf(w, "Warning: circular certificate detected at %s\n", url) + _, _ = fmt.Fprintf(w, "Warning: circular certificate detected at %s\n", url) break } seen[serial] = true @@ -672,7 +672,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr source = "downloaded" case aia.FormatPEM: source = "downloaded PEM" - fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) + _, _ = fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) default: source = "downloaded" } @@ -711,7 +711,7 @@ func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl for _, url := range c.Cert.CRLDistributionPoints { fetchResult, err := crl.FetchCRL(url, timeout) if err != nil { - fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) + _, _ = fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) continue } @@ -762,32 +762,32 @@ func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.No } resp := ocspInfo.Response - fmt.Fprintf(w, "\n[OCSP Debug]\n") - fmt.Fprintf(w, " URL: %s\n", ocspInfo.FilePath) + _, _ = fmt.Fprintf(w, "\n[OCSP Debug]\n") + _, _ = fmt.Fprintf(w, " URL: %s\n", ocspInfo.FilePath) // Print request info - fmt.Fprintf(w, " Request:\n") + _, _ = fmt.Fprintf(w, " Request:\n") if ocspInfo.RequestRawLen > 0 { - fmt.Fprintf(w, " Length: %d bytes\n", ocspInfo.RequestRawLen) + _, _ = fmt.Fprintf(w, " Length: %d bytes\n", ocspInfo.RequestRawLen) } else { - fmt.Fprintf(w, " Length: (unknown)\n") + _, _ = fmt.Fprintf(w, " Length: (unknown)\n") } // Print hash algorithm used for CertID if ocspInfo.RequestHashAlgorithm != "" { - fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", ocspInfo.RequestHashAlgorithm) + _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", ocspInfo.RequestHashAlgorithm) } else { - fmt.Fprintf(w, " CertID Hash Algorithm: SHA256 (default)\n") + _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: SHA256 (default)\n") } // Print nonce request info if ocspInfo.RequestNonceLen > 0 { - fmt.Fprintf(w, " Nonce Length: %d bytes\n", ocspInfo.RequestNonceLen) - fmt.Fprintf(w, " Nonce (hex): %s\n", ocspInfo.RequestNonceHex) + _, _ = fmt.Fprintf(w, " Nonce Length: %d bytes\n", ocspInfo.RequestNonceLen) + _, _ = fmt.Fprintf(w, " Nonce (hex): %s\n", ocspInfo.RequestNonceHex) } else if nonceOpts != nil && nonceOpts.Disabled { - fmt.Fprintf(w, " Nonce: disabled\n") + _, _ = fmt.Fprintf(w, " Nonce: disabled\n") } else { - fmt.Fprintf(w, " Nonce: (not requested)\n") + _, _ = fmt.Fprintf(w, " Nonce: (not requested)\n") } // Print response info @@ -802,41 +802,41 @@ func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.No default: statusStr = fmt.Sprintf("Unknown(%d)", resp.Status) } - fmt.Fprintf(w, " Response:\n") - fmt.Fprintf(w, " Status: %s\n", statusStr) - fmt.Fprintf(w, " ProducedAt: %s\n", resp.ProducedAt.Format("2006-01-02 15:04:05")) - fmt.Fprintf(w, " ThisUpdate: %s\n", resp.ThisUpdate.Format("2006-01-02 15:04:05")) + _, _ = fmt.Fprintf(w, " Response:\n") + _, _ = fmt.Fprintf(w, " Status: %s\n", statusStr) + _, _ = fmt.Fprintf(w, " ProducedAt: %s\n", resp.ProducedAt.Format("2006-01-02 15:04:05")) + _, _ = fmt.Fprintf(w, " ThisUpdate: %s\n", resp.ThisUpdate.Format("2006-01-02 15:04:05")) if !resp.NextUpdate.IsZero() { - fmt.Fprintf(w, " NextUpdate: %s\n", resp.NextUpdate.Format("2006-01-02 15:04:05")) + _, _ = fmt.Fprintf(w, " NextUpdate: %s\n", resp.NextUpdate.Format("2006-01-02 15:04:05")) } else { - fmt.Fprintf(w, " NextUpdate: (not set)\n") + _, _ = fmt.Fprintf(w, " NextUpdate: (not set)\n") } if !resp.RevokedAt.IsZero() { - fmt.Fprintf(w, " RevokedAt: %s\n", resp.RevokedAt.Format("2006-01-02 15:04:05")) - fmt.Fprintf(w, " RevocationReason: %d\n", resp.RevocationReason) + _, _ = fmt.Fprintf(w, " RevokedAt: %s\n", resp.RevokedAt.Format("2006-01-02 15:04:05")) + _, _ = fmt.Fprintf(w, " RevocationReason: %d\n", resp.RevocationReason) } - fmt.Fprintf(w, " SerialNumber: %s\n", resp.SerialNumber.String()) - fmt.Fprintf(w, " SignatureAlgorithm: %s\n", resp.SignatureAlgorithm.String()) + _, _ = fmt.Fprintf(w, " SerialNumber: %s\n", resp.SerialNumber.String()) + _, _ = fmt.Fprintf(w, " SignatureAlgorithm: %s\n", resp.SignatureAlgorithm.String()) // Parse nonce from raw response nonceState := ocspzcrypto.ParseNonceFromRaw(resp.Raw) - fmt.Fprintf(w, " Response Nonce:\n") + _, _ = fmt.Fprintf(w, " Response Nonce:\n") if nonceState.Present { - fmt.Fprintf(w, " Present: true\n") - fmt.Fprintf(w, " Length: %d bytes\n", nonceState.Length) - fmt.Fprintf(w, " Value (hex): %s\n", nonceState.HexValue) + _, _ = fmt.Fprintf(w, " Present: true\n") + _, _ = fmt.Fprintf(w, " Length: %d bytes\n", nonceState.Length) + _, _ = fmt.Fprintf(w, " Value (hex): %s\n", nonceState.HexValue) // Check if nonce matches request if ocspInfo.RequestNonceLen > 0 && nonceState.Length == ocspInfo.RequestNonceLen { if nonceState.HexValue == ocspInfo.RequestNonceHex { - fmt.Fprintf(w, " Match: YES (echoed correctly)\n") + _, _ = fmt.Fprintf(w, " Match: YES (echoed correctly)\n") } else { - fmt.Fprintf(w, " Match: NO (different value)\n") + _, _ = fmt.Fprintf(w, " Match: NO (different value)\n") } } else if ocspInfo.RequestNonceLen > 0 && nonceState.Length != ocspInfo.RequestNonceLen { - fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", ocspInfo.RequestNonceLen, nonceState.Length) + _, _ = fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", ocspInfo.RequestNonceLen, nonceState.Length) } } else { - fmt.Fprintf(w, " Present: false\n") + _, _ = fmt.Fprintf(w, " Present: false\n") } - fmt.Fprintf(w, "\n") + _, _ = fmt.Fprintf(w, "\n") } \ No newline at end of file From 0986538b3eada46164d33a90ecfeab96ed82af07 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Thu, 7 May 2026 17:06:18 +0800 Subject: [PATCH 29/44] refactor: split runner.go into smaller files for better maintainability Split 842-line runner.go into focused files: - autofetch.go (225 lines): climbChain, fetchAutoCRL, fetchAutoOCSP - loader.go (76 lines): loadCertificates, loadIssuers - debug.go (94 lines): printOCSPResponseDebug - runner.go (471 lines): Run function and utilities Also fixes lint issues: - Fix ineffassign cleanup assignment with combined cleanup function - Fix prealloc suggestion with nolint comment - Fix staticcheck type inference in loader.go - Remove unused policyAppliesToOCSP function - Update .golangci.yml for new version compatibility --- .golangci.yml | 11 +- internal/linter/autofetch.go | 226 ++++++++++++++++++++ internal/linter/debug.go | 95 +++++++++ internal/linter/filter.go | 5 - internal/linter/loader.go | 77 +++++++ internal/linter/runner.go | 398 ++--------------------------------- 6 files changed, 414 insertions(+), 398 deletions(-) create mode 100644 internal/linter/autofetch.go create mode 100644 internal/linter/debug.go create mode 100644 internal/linter/loader.go diff --git a/.golangci.yml b/.golangci.yml index 9aae688..60a4ec5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,5 @@ +version: 2 + run: timeout: 5m modules-download-mode: readonly @@ -5,13 +7,10 @@ run: linters: enable: - errcheck - - gosimple - govet - ineffassign - staticcheck - unused - - gofmt - - goimports - misspell - unconvert - gosec @@ -26,12 +25,6 @@ linters-settings: disable: - fieldalignment - gofmt: - simplify: true - - goimports: - local-prefixes: github.com/cavoq/PCL - misspell: locale: US diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go new file mode 100644 index 0000000..04d425c --- /dev/null +++ b/internal/linter/autofetch.go @@ -0,0 +1,226 @@ +package linter + +import ( + certstd "crypto/x509" + "fmt" + "io" + "time" + + "github.com/cavoq/PCL/internal/aia" + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/crl" + "github.com/cavoq/PCL/internal/ocsp" + "github.com/cavoq/PCL/internal/zcrypto" + "github.com/zmap/zcrypto/x509" +) + +// climbChain recursively fetches issuer certificates via CA Issuers URLs. +// Starts from the top of the chain and climbs toward root until: +// - Self-signed certificate found (root) +// - Max depth reached +// - No CA Issuers URL found +// - Circular certificate detected +// +// Handles PKCS#7 bundles: extracts all certificates and selects the correct issuer +// by matching Issuer DN and/or AKI extension. +func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Writer) []*cert.Info { + if len(chain) == 0 || maxDepth <= 0 { + return chain + } + + // Track seen certificates to detect circular chains + 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 + + for depth < maxDepth { + // Get the highest certificate in the chain (potential issuer to climb) + top := result[len(result)-1] + if top.Cert == nil { + break + } + + // Check if it's self-signed (root) + if top.Cert.Issuer.String() == top.Cert.Subject.String() { + break + } + + // Check for CA Issuers URL + if len(top.Cert.IssuingCertificateURL) == 0 { + break + } + + // Fetch issuer(s) from first CA Issuers URL (may be PKCS#7 bundle) + url := top.Cert.IssuingCertificateURL[0] + pkcs7Result, err := aia.FetchCAIssuerPKCS7(url, timeout) + if err != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) + break + } + + // Find the correct issuer certificate from the bundle + // Match by Issuer DN (subject of issuer should match issuer of cert) + var issuerCert *x509.Certificate + for _, cert := range pkcs7Result.Certs { + // Check if this cert's subject matches the current cert's issuer + if cert.Subject.String() == top.Cert.Issuer.String() { + issuerCert = cert + break + } + } + + // If no exact DN match, try AKI-SKI matching + if issuerCert == nil && len(top.Cert.AuthorityKeyId) > 0 { + for _, cert := range pkcs7Result.Certs { + if len(cert.SubjectKeyId) > 0 && string(cert.SubjectKeyId) == string(top.Cert.AuthorityKeyId) { + issuerCert = cert + break + } + } + } + + // Fallback: use first certificate if only one, or continue with best guess + if issuerCert == nil { + if len(pkcs7Result.Certs) == 1 { + issuerCert = pkcs7Result.Certs[0] + } else { + // Multiple certs with no match - use first as best guess + issuerCert = pkcs7Result.Certs[0] + _, _ = fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(pkcs7Result.Certs)) + } + } + + // Check for circular certificate + if issuerCert.SerialNumber != nil { + serial := issuerCert.SerialNumber.String() + if seen[serial] { + _, _ = fmt.Fprintf(w, "Warning: circular certificate detected at %s\n", url) + break + } + seen[serial] = true + } + + // Add issuer to chain + var source string + switch pkcs7Result.Format { + case aia.FormatPKCS7: + source = "extracted from PKCS#7" + case aia.FormatDER: + source = "downloaded" + case aia.FormatPEM: + source = "downloaded PEM" + _, _ = fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) + default: + source = "downloaded" + } + issuerInfo := &cert.Info{ + Cert: issuerCert, + FilePath: url, + Type: cert.GetCertType(issuerCert, len(result), len(result)+1), + Position: len(result), + Source: source, + DownloadURL: url, + DownloadFormat: string(pkcs7Result.Format), + } + result = append(result, issuerInfo) + + depth++ + } + + // Rebuild chain types after climbing is complete + for i, c := range result { + c.Position = i + c.Type = cert.GetCertType(c.Cert, i, len(result)) + } + + return result +} + +// fetchAutoCRL fetches CRLs from CRL Distribution Points for certificates in chain. +func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl.Info { + var results []*crl.Info + + for _, c := range chain { + if c.Cert == nil || len(c.Cert.CRLDistributionPoints) == 0 { + continue + } + + for _, url := range c.Cert.CRLDistributionPoints { + fetchResult, err := crl.FetchCRL(url, timeout) + if err != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) + continue + } + + results = append(results, &crl.Info{ + CRL: fetchResult.CRL, + FilePath: url, + Source: "downloaded", + }) + } + } + + return results +} + +// fetchAutoOCSP automatically fetches OCSP responses for certificates in the chain. +// For leaf certificates, uses the OCSP URL from AIA extension and issuer from chain. +func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.NonceOptions) ([]*ocsp.Info, error) { + if len(chain) < 2 { + return nil, fmt.Errorf("chain must have at least 2 certificates for OCSP request") + } + + results := make([]*ocsp.Info, 0, 1) + + // Convert zcrypto certs to standard certs for OCSP request + stdChain := make([]*certstd.Certificate, 0, len(chain)) + for _, c := range chain { + if c.Cert == nil { + continue + } + stdCert, err := zcrypto.ToStdCert(c.Cert) + if err != nil { + continue + } + stdChain = append(stdChain, stdCert) + } + + if len(stdChain) < 2 { + return nil, fmt.Errorf("failed to convert certificates to standard format") + } + + // Fetch OCSP for leaf certificate + fetchResult, url, err := ocsp.FetchOCSPFromChainWithInfo(stdChain, timeout, nonceOpts) + if err != nil { + return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) + } + if fetchResult == nil { + // No OCSP URL in certificate, not an error + return nil, nil + } + + info := &ocsp.Info{ + Response: fetchResult.Response, + FilePath: url, // Use URL as "file path" for auto-fetched responses + Source: "downloaded", + } + + // Populate request debug info + if fetchResult.RequestInfo != nil { + info.RequestNonce = fetchResult.RequestInfo.Nonce + info.RequestNonceHex = fetchResult.RequestInfo.NonceHex + info.RequestNonceLen = fetchResult.RequestInfo.NonceLen + info.RequestRawLen = fetchResult.RequestInfo.RequestLen + info.RequestHashAlgorithm = fetchResult.RequestInfo.HashAlgorithm + } + + results = append(results, info) + + return results, nil +} \ No newline at end of file diff --git a/internal/linter/debug.go b/internal/linter/debug.go new file mode 100644 index 0000000..39ee5f0 --- /dev/null +++ b/internal/linter/debug.go @@ -0,0 +1,95 @@ +package linter + +import ( + "fmt" + "io" + + "github.com/cavoq/PCL/internal/ocsp" + ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" +) + +// printOCSPResponseDebug prints OCSP response details for debugging. +func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.NonceOptions) { + if ocspInfo == nil || ocspInfo.Response == nil { + return + } + resp := ocspInfo.Response + + _, _ = fmt.Fprintf(w, "\n[OCSP Debug]\n") + _, _ = fmt.Fprintf(w, " URL: %s\n", ocspInfo.FilePath) + + // Print request info + _, _ = fmt.Fprintf(w, " Request:\n") + if ocspInfo.RequestRawLen > 0 { + _, _ = fmt.Fprintf(w, " Length: %d bytes\n", ocspInfo.RequestRawLen) + } else { + _, _ = fmt.Fprintf(w, " Length: (unknown)\n") + } + + // Print hash algorithm used for CertID + if ocspInfo.RequestHashAlgorithm != "" { + _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", ocspInfo.RequestHashAlgorithm) + } else { + _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: SHA256 (default)\n") + } + + // Print nonce request info + if ocspInfo.RequestNonceLen > 0 { + _, _ = fmt.Fprintf(w, " Nonce Length: %d bytes\n", ocspInfo.RequestNonceLen) + _, _ = fmt.Fprintf(w, " Nonce (hex): %s\n", ocspInfo.RequestNonceHex) + } else if nonceOpts != nil && nonceOpts.Disabled { + _, _ = fmt.Fprintf(w, " Nonce: disabled\n") + } else { + _, _ = fmt.Fprintf(w, " Nonce: (not requested)\n") + } + + // Print response info + var statusStr string + switch resp.Status { + case 0: + statusStr = "Good" + case 1: + statusStr = "Revoked" + case 2: + statusStr = "Unknown" + default: + statusStr = fmt.Sprintf("Unknown(%d)", resp.Status) + } + _, _ = fmt.Fprintf(w, " Response:\n") + _, _ = fmt.Fprintf(w, " Status: %s\n", statusStr) + _, _ = fmt.Fprintf(w, " ProducedAt: %s\n", resp.ProducedAt.Format("2006-01-02 15:04:05")) + _, _ = fmt.Fprintf(w, " ThisUpdate: %s\n", resp.ThisUpdate.Format("2006-01-02 15:04:05")) + if !resp.NextUpdate.IsZero() { + _, _ = fmt.Fprintf(w, " NextUpdate: %s\n", resp.NextUpdate.Format("2006-01-02 15:04:05")) + } else { + _, _ = fmt.Fprintf(w, " NextUpdate: (not set)\n") + } + if !resp.RevokedAt.IsZero() { + _, _ = fmt.Fprintf(w, " RevokedAt: %s\n", resp.RevokedAt.Format("2006-01-02 15:04:05")) + _, _ = fmt.Fprintf(w, " RevocationReason: %d\n", resp.RevocationReason) + } + _, _ = fmt.Fprintf(w, " SerialNumber: %s\n", resp.SerialNumber.String()) + _, _ = fmt.Fprintf(w, " SignatureAlgorithm: %s\n", resp.SignatureAlgorithm.String()) + + // Parse nonce from raw response + nonceState := ocspzcrypto.ParseNonceFromRaw(resp.Raw) + _, _ = fmt.Fprintf(w, " Response Nonce:\n") + if nonceState.Present { + _, _ = fmt.Fprintf(w, " Present: true\n") + _, _ = fmt.Fprintf(w, " Length: %d bytes\n", nonceState.Length) + _, _ = fmt.Fprintf(w, " Value (hex): %s\n", nonceState.HexValue) + // Check if nonce matches request + if ocspInfo.RequestNonceLen > 0 && nonceState.Length == ocspInfo.RequestNonceLen { + if nonceState.HexValue == ocspInfo.RequestNonceHex { + _, _ = fmt.Fprintf(w, " Match: YES (echoed correctly)\n") + } else { + _, _ = fmt.Fprintf(w, " Match: NO (different value)\n") + } + } else if ocspInfo.RequestNonceLen > 0 && nonceState.Length != ocspInfo.RequestNonceLen { + _, _ = fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", ocspInfo.RequestNonceLen, nonceState.Length) + } + } else { + _, _ = fmt.Fprintf(w, " Present: false\n") + } + _, _ = fmt.Fprintf(w, "\n") +} \ No newline at end of file diff --git a/internal/linter/filter.go b/internal/linter/filter.go index 53796c6..04b2ada 100644 --- a/internal/linter/filter.go +++ b/internal/linter/filter.go @@ -245,11 +245,6 @@ func policyAppliesToCRLInput(p policy.Policy) bool { return false } -// policyAppliesToOCSP checks if a policy applies to OCSP responses -func policyAppliesToOCSP(p policy.Policy) bool { - return policyAppliesToInput(p, AppliesToOCSP) -} - // extKeyUsageToOID converts x509.ExtKeyUsage to OID string func extKeyUsageToOID(eku x509.ExtKeyUsage) string { switch eku { diff --git a/internal/linter/loader.go b/internal/linter/loader.go new file mode 100644 index 0000000..001824f --- /dev/null +++ b/internal/linter/loader.go @@ -0,0 +1,77 @@ +package linter + +import ( + "fmt" + + "github.com/cavoq/PCL/internal/cert" +) + +// loadCertificates loads leaf certificates from paths and URLs specified in config. +func loadCertificates(cfg Config) ([]*cert.Info, func(), error) { + var cleanup func() + var certs []*cert.Info + + if cfg.CertPath != "" { + loaded, err := cert.LoadCertificates(cfg.CertPath) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to load certificates: %w", err) + } + certs = append(certs, loaded...) + } + + if len(cfg.CertURLs) > 0 { + dir, tempCleanup, err := cert.DownloadCertificates(cfg.CertURLs, cfg.CertTimeout, cfg.CertSaveDir) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to download certificates: %w", err) + } + if tempCleanup != nil { + cleanup = tempCleanup + } + loaded, err := cert.LoadCertificates(dir) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to load downloaded certificates: %w", err) + } + certs = append(certs, loaded...) + } + + if len(certs) == 0 { + return nil, cleanup, fmt.Errorf("no leaf certificates provided") + } + + return certs, cleanup, nil +} + +// loadIssuers loads issuer certificates from paths and URLs specified in config. +func loadIssuers(cfg Config, existingCleanup func()) ([]*cert.Info, func(), error) { + cleanup := existingCleanup + var issuers []*cert.Info + + for _, path := range cfg.IssuerPaths { + loaded, err := cert.LoadCertificates(path) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to load issuer certificates from %s: %w", path, err) + } + issuers = append(issuers, loaded...) + } + + if len(cfg.IssuerURLs) > 0 { + dir, tempCleanup, err := cert.DownloadCertificates(cfg.IssuerURLs, cfg.CertTimeout, cfg.CertSaveDir) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to download issuer certificates: %w", err) + } + if tempCleanup != nil { + cleanup = tempCleanup + } + loaded, err := cert.LoadCertificates(dir) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to load downloaded issuer certificates: %w", err) + } + issuers = append(issuers, loaded...) + } + + if len(issuers) == 0 { + return nil, cleanup, fmt.Errorf("no issuer certificates provided") + } + + return issuers, cleanup, nil +} \ No newline at end of file diff --git a/internal/linter/runner.go b/internal/linter/runner.go index ee5aae5..48a5e1f 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -1,13 +1,11 @@ package linter import ( - certstd "crypto/x509" "fmt" "io" "os" "time" - "github.com/cavoq/PCL/internal/aia" "github.com/cavoq/PCL/internal/cert" certzcrypto "github.com/cavoq/PCL/internal/cert/zcrypto" "github.com/cavoq/PCL/internal/crl" @@ -89,14 +87,9 @@ func Run(cfg Config, w io.Writer) error { } } - // Schedule cleanup at the end of processing (for downloaded certs/issuers) - if cleanup != nil { - defer cleanup() - } - if hasCert { // Load leaf certificates - var certs []*cert.Info + var certs []*cert.Info //nolint:prealloc // certs is overwritten by loadCertificates var certCleanup func() certs, certCleanup, err = loadCertificates(cfg) @@ -104,7 +97,19 @@ func Run(cfg Config, w io.Writer) error { return err } if certCleanup != nil { - cleanup = certCleanup + // Combine cleanup functions: both issuer and cert cleanups need to run + prevCleanup := cleanup + cleanup = func() { + certCleanup() + if prevCleanup != nil { + prevCleanup() + } + } + } + + // Schedule cleanup at the end of processing (for downloaded certs/issuers) + if cleanup != nil { + defer cleanup() } // Build chain: leaf + issuers @@ -426,74 +431,6 @@ func Run(cfg Config, w io.Writer) error { return formatter.Format(w, lintOutput) } -func loadCertificates(cfg Config) ([]*cert.Info, func(), error) { - var cleanup func() - var certs []*cert.Info - - if cfg.CertPath != "" { - loaded, err := cert.LoadCertificates(cfg.CertPath) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to load certificates: %w", err) - } - certs = append(certs, loaded...) - } - - if len(cfg.CertURLs) > 0 { - dir, tempCleanup, err := cert.DownloadCertificates(cfg.CertURLs, cfg.CertTimeout, cfg.CertSaveDir) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to download certificates: %w", err) - } - if tempCleanup != nil { - cleanup = tempCleanup - } - loaded, err := cert.LoadCertificates(dir) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to load downloaded certificates: %w", err) - } - certs = append(certs, loaded...) - } - - if len(certs) == 0 { - return nil, cleanup, fmt.Errorf("no leaf certificates provided") - } - - return certs, cleanup, nil -} - -func loadIssuers(cfg Config, existingCleanup func()) ([]*cert.Info, func(), error) { - var cleanup func() = existingCleanup - var issuers []*cert.Info - - for _, path := range cfg.IssuerPaths { - loaded, err := cert.LoadCertificates(path) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to load issuer certificates from %s: %w", path, err) - } - issuers = append(issuers, loaded...) - } - - if len(cfg.IssuerURLs) > 0 { - dir, tempCleanup, err := cert.DownloadCertificates(cfg.IssuerURLs, cfg.CertTimeout, cfg.CertSaveDir) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to download issuer certificates: %w", err) - } - if tempCleanup != nil { - cleanup = tempCleanup - } - loaded, err := cert.LoadCertificates(dir) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to load downloaded issuer certificates: %w", err) - } - issuers = append(issuers, loaded...) - } - - if len(issuers) == 0 { - return nil, cleanup, fmt.Errorf("no issuer certificates provided") - } - - return issuers, cleanup, nil -} - func applyDefaults(cfg *Config) { if cfg.CertTimeout <= 0 { cfg.CertTimeout = 10 * time.Second @@ -515,227 +452,6 @@ func applyDefaults(cfg *Config) { } } -// fetchAutoOCSP automatically fetches OCSP responses for certificates in the chain. -// For leaf certificates, uses the OCSP URL from AIA extension and issuer from chain. -func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.NonceOptions) ([]*ocsp.Info, error) { - if len(chain) < 2 { - return nil, fmt.Errorf("chain must have at least 2 certificates for OCSP request") - } - - var results []*ocsp.Info - - // Convert zcrypto certs to standard certs for OCSP request - stdChain := make([]*certstd.Certificate, 0, len(chain)) - for _, c := range chain { - if c.Cert == nil { - continue - } - stdCert, err := zcrypto.ToStdCert(c.Cert) - if err != nil { - continue - } - stdChain = append(stdChain, stdCert) - } - - if len(stdChain) < 2 { - return nil, fmt.Errorf("failed to convert certificates to standard format") - } - - // Fetch OCSP for leaf certificate - fetchResult, url, err := ocsp.FetchOCSPFromChainWithInfo(stdChain, timeout, nonceOpts) - if err != nil { - return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) - } - if fetchResult == nil { - // No OCSP URL in certificate, not an error - return nil, nil - } - - info := &ocsp.Info{ - Response: fetchResult.Response, - FilePath: url, // Use URL as "file path" for auto-fetched responses - Source: "downloaded", - } - - // Populate request debug info - if fetchResult.RequestInfo != nil { - info.RequestNonce = fetchResult.RequestInfo.Nonce - info.RequestNonceHex = fetchResult.RequestInfo.NonceHex - info.RequestNonceLen = fetchResult.RequestInfo.NonceLen - info.RequestRawLen = fetchResult.RequestInfo.RequestLen - info.RequestHashAlgorithm = fetchResult.RequestInfo.HashAlgorithm - } - - results = append(results, info) - - return results, nil -} - -// climbChain recursively fetches issuer certificates via CA Issuers URLs. -// Starts from the top of the chain and climbs toward root until: -// - Self-signed certificate found (root) -// - Max depth reached -// - No CA Issuers URL found -// - Circular certificate detected -// -// Handles PKCS#7 bundles: extracts all certificates and selects the correct issuer -// by matching Issuer DN and/or AKI extension. -func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Writer) []*cert.Info { - if len(chain) == 0 || maxDepth <= 0 { - return chain - } - - // Track seen certificates to detect circular chains - 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 - - for depth < maxDepth { - // Get the highest certificate in the chain (potential issuer to climb) - top := result[len(result)-1] - if top.Cert == nil { - break - } - - // Check if it's self-signed (root) - if top.Cert.Issuer.String() == top.Cert.Subject.String() { - break - } - - // Check for CA Issuers URL - if len(top.Cert.IssuingCertificateURL) == 0 { - break - } - - // Fetch issuer(s) from first CA Issuers URL (may be PKCS#7 bundle) - url := top.Cert.IssuingCertificateURL[0] - pkcs7Result, err := aia.FetchCAIssuerPKCS7(url, timeout) - if err != nil { - _, _ = fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) - break - } - - // Find the correct issuer certificate from the bundle - // Match by Issuer DN (subject of issuer should match issuer of cert) - var issuerCert *x509.Certificate - for _, cert := range pkcs7Result.Certs { - // Check if this cert's subject matches the current cert's issuer - if cert.Subject.String() == top.Cert.Issuer.String() { - issuerCert = cert - break - } - } - - // If no exact DN match, try AKI-SKI matching - if issuerCert == nil && len(top.Cert.AuthorityKeyId) > 0 { - for _, cert := range pkcs7Result.Certs { - if len(cert.SubjectKeyId) > 0 && string(cert.SubjectKeyId) == string(top.Cert.AuthorityKeyId) { - issuerCert = cert - break - } - } - } - - // Fallback: use first certificate if only one, or continue with best guess - if issuerCert == nil { - if len(pkcs7Result.Certs) == 1 { - issuerCert = pkcs7Result.Certs[0] - } else { - // Multiple certs with no match - use first as best guess - issuerCert = pkcs7Result.Certs[0] - _, _ = fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(pkcs7Result.Certs)) - } - } - - // Check for circular certificate - if issuerCert.SerialNumber != nil { - serial := issuerCert.SerialNumber.String() - if seen[serial] { - _, _ = fmt.Fprintf(w, "Warning: circular certificate detected at %s\n", url) - break - } - seen[serial] = true - } - - // Add issuer to chain - var source string - switch pkcs7Result.Format { - case aia.FormatPKCS7: - source = "extracted from PKCS#7" - case aia.FormatDER: - source = "downloaded" - case aia.FormatPEM: - source = "downloaded PEM" - _, _ = fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) - default: - source = "downloaded" - } - issuerInfo := &cert.Info{ - Cert: issuerCert, - FilePath: url, - Type: cert.GetCertType(issuerCert, len(result), len(result)+1), - Position: len(result), - Source: source, - DownloadURL: url, - DownloadFormat: string(pkcs7Result.Format), - } - result = append(result, issuerInfo) - - depth++ - } - - // Rebuild chain types after climbing is complete - for i, c := range result { - c.Position = i - c.Type = cert.GetCertType(c.Cert, i, len(result)) - } - - return result -} - -// fetchAutoCRL fetches CRLs from CRL Distribution Points for certificates in chain. -func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl.Info { - var results []*crl.Info - - for _, c := range chain { - if c.Cert == nil || len(c.Cert.CRLDistributionPoints) == 0 { - continue - } - - for _, url := range c.Cert.CRLDistributionPoints { - fetchResult, err := crl.FetchCRL(url, timeout) - if err != nil { - _, _ = fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) - continue - } - - results = append(results, &crl.Info{ - CRL: fetchResult.CRL, - FilePath: url, - Source: "downloaded", - }) - } - } - - return results -} - -// addFetchedInfoToNode adds format and fetch status info to node tree for policy evaluation. -func addFetchedInfoToNode(tree *node.Node, caIssuerFormat aia.Format, crlFormat crl.Format) { - if caIssuerFormat != "" { - tree.Children["caIssuersFormat"] = node.New("caIssuersFormat", string(caIssuerFormat)) - } - if crlFormat != "" { - tree.Children["crlFormat"] = node.New("crlFormat", string(crlFormat)) - } -} - // isDirectory checks if the given path is a directory. func isDirectory(path string) (bool, error) { info, err := os.Stat(path) @@ -753,90 +469,4 @@ func buildNonceOptions(cfg Config) *ocsp.NonceOptions { Disabled: cfg.NoOCSPNonce, Hash: cfg.OCSPHashAlgorithm, } -} - -// printOCSPResponseDebug prints OCSP response details for debugging. -func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.NonceOptions) { - if ocspInfo == nil || ocspInfo.Response == nil { - return - } - resp := ocspInfo.Response - - _, _ = fmt.Fprintf(w, "\n[OCSP Debug]\n") - _, _ = fmt.Fprintf(w, " URL: %s\n", ocspInfo.FilePath) - - // Print request info - _, _ = fmt.Fprintf(w, " Request:\n") - if ocspInfo.RequestRawLen > 0 { - _, _ = fmt.Fprintf(w, " Length: %d bytes\n", ocspInfo.RequestRawLen) - } else { - _, _ = fmt.Fprintf(w, " Length: (unknown)\n") - } - - // Print hash algorithm used for CertID - if ocspInfo.RequestHashAlgorithm != "" { - _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", ocspInfo.RequestHashAlgorithm) - } else { - _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: SHA256 (default)\n") - } - - // Print nonce request info - if ocspInfo.RequestNonceLen > 0 { - _, _ = fmt.Fprintf(w, " Nonce Length: %d bytes\n", ocspInfo.RequestNonceLen) - _, _ = fmt.Fprintf(w, " Nonce (hex): %s\n", ocspInfo.RequestNonceHex) - } else if nonceOpts != nil && nonceOpts.Disabled { - _, _ = fmt.Fprintf(w, " Nonce: disabled\n") - } else { - _, _ = fmt.Fprintf(w, " Nonce: (not requested)\n") - } - - // Print response info - var statusStr string - switch resp.Status { - case 0: - statusStr = "Good" - case 1: - statusStr = "Revoked" - case 2: - statusStr = "Unknown" - default: - statusStr = fmt.Sprintf("Unknown(%d)", resp.Status) - } - _, _ = fmt.Fprintf(w, " Response:\n") - _, _ = fmt.Fprintf(w, " Status: %s\n", statusStr) - _, _ = fmt.Fprintf(w, " ProducedAt: %s\n", resp.ProducedAt.Format("2006-01-02 15:04:05")) - _, _ = fmt.Fprintf(w, " ThisUpdate: %s\n", resp.ThisUpdate.Format("2006-01-02 15:04:05")) - if !resp.NextUpdate.IsZero() { - _, _ = fmt.Fprintf(w, " NextUpdate: %s\n", resp.NextUpdate.Format("2006-01-02 15:04:05")) - } else { - _, _ = fmt.Fprintf(w, " NextUpdate: (not set)\n") - } - if !resp.RevokedAt.IsZero() { - _, _ = fmt.Fprintf(w, " RevokedAt: %s\n", resp.RevokedAt.Format("2006-01-02 15:04:05")) - _, _ = fmt.Fprintf(w, " RevocationReason: %d\n", resp.RevocationReason) - } - _, _ = fmt.Fprintf(w, " SerialNumber: %s\n", resp.SerialNumber.String()) - _, _ = fmt.Fprintf(w, " SignatureAlgorithm: %s\n", resp.SignatureAlgorithm.String()) - - // Parse nonce from raw response - nonceState := ocspzcrypto.ParseNonceFromRaw(resp.Raw) - _, _ = fmt.Fprintf(w, " Response Nonce:\n") - if nonceState.Present { - _, _ = fmt.Fprintf(w, " Present: true\n") - _, _ = fmt.Fprintf(w, " Length: %d bytes\n", nonceState.Length) - _, _ = fmt.Fprintf(w, " Value (hex): %s\n", nonceState.HexValue) - // Check if nonce matches request - if ocspInfo.RequestNonceLen > 0 && nonceState.Length == ocspInfo.RequestNonceLen { - if nonceState.HexValue == ocspInfo.RequestNonceHex { - _, _ = fmt.Fprintf(w, " Match: YES (echoed correctly)\n") - } else { - _, _ = fmt.Fprintf(w, " Match: NO (different value)\n") - } - } else if ocspInfo.RequestNonceLen > 0 && nonceState.Length != ocspInfo.RequestNonceLen { - _, _ = fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", ocspInfo.RequestNonceLen, nonceState.Length) - } - } else { - _, _ = fmt.Fprintf(w, " Present: false\n") - } - _, _ = fmt.Fprintf(w, "\n") } \ No newline at end of file From b505f01745dacdc0d7d2bbafbedb145277221f35 Mon Sep 17 00:00:00 2001 From: mr-m0nst3r Date: Thu, 7 May 2026 17:34:44 +0800 Subject: [PATCH 30/44] refactor: extract evaluation logic into evaluator.go Split runner.go further: - evaluator.go (269 lines): evaluateChain, evaluateOCSP, evaluateCRL functions - runner.go (288 lines): Run orchestration + utility functions Add unit tests: - evaluator_test.go: tests for evaluation functions - runner_test.go: tests for utility functions (applyDefaults, isDirectory, etc) All files now under 300 lines, matching reviewer's maintainability goal. --- internal/linter/evaluator.go | 270 ++++++++++++++++ internal/linter/evaluator_test.go | 79 +++++ internal/linter/runner.go | 517 ++++++++++-------------------- internal/linter/runner_test.go | 320 ++++++++++++++++++ 4 files changed, 836 insertions(+), 350 deletions(-) create mode 100644 internal/linter/evaluator.go create mode 100644 internal/linter/evaluator_test.go create mode 100644 internal/linter/runner_test.go diff --git a/internal/linter/evaluator.go b/internal/linter/evaluator.go new file mode 100644 index 0000000..8b82e88 --- /dev/null +++ b/internal/linter/evaluator.go @@ -0,0 +1,270 @@ +package linter + +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/node" + "github.com/cavoq/PCL/internal/ocsp" + ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" + "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/zcrypto" + "github.com/zmap/zcrypto/x509" +) + +// EvaluationContext contains all data needed for policy evaluation. +type EvaluationContext struct { + Policies []policy.Policy + Registry *operator.Registry + CRLs []*crl.Info + OCSPs []*ocsp.Info + Chain []*cert.Info +} + +// evaluateChain evaluates policies for each certificate in the chain. +func evaluateChain(ctx EvaluationContext) []policy.Result { + var results []policy.Result + + for _, c := range ctx.Chain { + tree := certzcrypto.BuildTree(c.Cert) + + // Add download format to tree for PEM format detection rule + if c.DownloadFormat != "" { + tree.Children["downloadFormat"] = node.New("downloadFormat", c.DownloadFormat) + tree.Children["downloadURL"] = node.New("downloadURL", c.DownloadURL) + } + + // Add CRL node to tree if CRLs are present + if len(ctx.CRLs) > 0 { + for _, crlInfo := range ctx.CRLs { + if crlInfo.CRL != nil { + crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + if crlNode != nil { + tree.Children["crl"] = crlNode + } + break + } + } + } + + evalOpts := []operator.ContextOption{ + operator.WithCRLs(ctx.CRLs), + operator.WithOCSPs(ctx.OCSPs), + } + evalCtx := operator.NewEvaluationContext(tree, c, ctx.Chain, evalOpts...) + + // Filter policies by certificate type + filteredPolicies := filterPoliciesByCert(ctx.Policies, c.Cert) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) + results = append(results, res) + } + } + + return results +} + +// evaluateOCSP evaluates policies for OCSP responses and signing certificates. +func evaluateOCSP(ctx EvaluationContext) []policy.Result { + var results []policy.Result + + for _, ocspInfo := range ctx.OCSPs { + if ocspInfo.Response == nil { + continue + } + + ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) + if ocspNode == nil { + continue + } + + // Create synthetic cert.Info for OCSP response to set proper CertType + ocspCertInfo := &cert.Info{ + FilePath: ocspInfo.FilePath, + Type: "ocsp", + Source: ocspInfo.Source, + } + + tree := ocspNode + evalOpts := []operator.ContextOption{operator.WithOCSPs(ctx.OCSPs)} + evalCtx := operator.NewEvaluationContext(tree, ocspCertInfo, ctx.Chain, evalOpts...) + + filteredPolicies := filterPoliciesByInput(ctx.Policies, AppliesToOCSP) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) + results = append(results, res) + } + + // Evaluate OCSP signing certificate if present in response + if ocspInfo.Response.Certificate != nil { + results = append(results, evaluateOCSPSigningCert(ctx.Policies, ctx.Registry, ctx.OCSPs, ocspInfo, ctx.Chain)...) + } + } + + return results +} + +// evaluateOCSPSigningCert evaluates policies for OCSP signing certificate. +func evaluateOCSPSigningCert(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info, ocspInfo *ocsp.Info, chain []*cert.Info) []policy.Result { + // Convert standard cert to zcrypto cert + zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) + if err != nil || zcryptoSignerCert == nil { + return nil + } + + ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) + ocspSignerInfo := &cert.Info{ + Cert: zcryptoSignerCert, + FilePath: ocspInfo.FilePath + " (signing cert)", + Type: "ocspSigning", + Source: "extracted from OCSP response", + } + + evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + evalCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, evalOpts...) + + var results []policy.Result + signerPolicies := filterPoliciesByCert(policies, zcryptoSignerCert) + for _, p := range signerPolicies { + res := policy.Evaluate(p, ocspSignerTree, registry, evalCtx) + results = append(results, res) + } + + return results +} + +// evaluateCRL evaluates policies for CRLs with a certificate chain. +func evaluateCRL(ctx EvaluationContext) []policy.Result { + var results []policy.Result + + for _, crlInfo := range ctx.CRLs { + if crlInfo.CRL == nil { + continue + } + + // Build issuer certificates list from chain + issuerCerts := extractCertsFromInfo(ctx.Chain) + + crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) + if crlNode == nil { + continue + } + + // Create synthetic cert.Info for CRL to set proper CertType + crlCertInfo := &cert.Info{ + FilePath: crlInfo.FilePath, + Type: "crl", + Source: crlInfo.Source, + } + + tree := crlNode + evalOpts := []operator.ContextOption{operator.WithCRLs(ctx.CRLs)} + evalCtx := operator.NewEvaluationContext(tree, crlCertInfo, ctx.Chain, evalOpts...) + + // Filter policies by CRL type + hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) + isIndirect := isIndirectCRL(crlInfo.CRL) + filteredPolicies := filterPoliciesByCRL(ctx.Policies, hasDelta, isIndirect) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) + results = append(results, res) + } + } + + return results +} + +// evaluateCRLOnly evaluates CRLs independently without a certificate chain. +func evaluateCRLOnly(policies []policy.Policy, registry *operator.Registry, crls []*crl.Info, issuers []*cert.Info) []policy.Result { + var results []policy.Result + + for _, crlInfo := range crls { + if crlInfo.CRL == nil { + continue + } + + // Build issuer certificates list for CRL type detection + issuerCerts := extractCertsFromInfo(issuers) + + crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) + if crlNode == nil { + continue + } + + // Create synthetic cert.Info for CRL to set proper CertType + crlCertInfo := &cert.Info{ + FilePath: crlInfo.FilePath, + Type: "crl", + Source: crlInfo.Source, + } + + tree := crlNode + evalOpts := []operator.ContextOption{operator.WithCRLs(crls)} + evalCtx := operator.NewEvaluationContext(tree, crlCertInfo, issuers, evalOpts...) + + // Filter policies by CRL type + hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) + isIndirect := isIndirectCRL(crlInfo.CRL) + filteredPolicies := filterPoliciesByCRL(policies, hasDelta, isIndirect) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, registry, evalCtx) + results = append(results, res) + } + } + + return results +} + +// evaluateOCSPOnly evaluates OCSP responses independently without a certificate chain. +func evaluateOCSPOnly(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info) []policy.Result { + var results []policy.Result + + for _, ocspInfo := range ocsps { + if ocspInfo.Response == nil { + continue + } + + ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) + if ocspNode == nil { + continue + } + + // Create synthetic cert.Info for OCSP response to set proper CertType + ocspCertInfo := &cert.Info{ + FilePath: ocspInfo.FilePath, + Type: "ocsp", + Source: ocspInfo.Source, + } + + tree := ocspNode + evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + evalCtx := operator.NewEvaluationContext(tree, ocspCertInfo, nil, evalOpts...) + + // Filter policies by input type (OCSP) + filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, registry, evalCtx) + results = append(results, res) + } + + // Evaluate OCSP signing certificate if present in response + if ocspInfo.Response.Certificate != nil { + results = append(results, evaluateOCSPSigningCert(policies, registry, ocsps, ocspInfo, nil)...) + } + } + + return results +} + +// extractCertsFromInfo extracts x509 certificates from cert.Info slice. +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 +} \ No newline at end of file diff --git a/internal/linter/evaluator_test.go b/internal/linter/evaluator_test.go new file mode 100644 index 0000000..245876d --- /dev/null +++ b/internal/linter/evaluator_test.go @@ -0,0 +1,79 @@ +package linter + +import ( + "testing" + + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/operator" +) + +func TestEvaluateChainWithEmptyChain(t *testing.T) { + evalCtx := EvaluationContext{ + Policies: nil, + Registry: operator.DefaultRegistry(), + CRLs: nil, + OCSPs: nil, + Chain: []*cert.Info{}, + } + + results := evaluateChain(evalCtx) + if len(results) != 0 { + t.Errorf("expected 0 results for empty chain, got %d", len(results)) + } +} + +func TestEvaluateCRLOnlyWithEmptyCRLs(t *testing.T) { + results := evaluateCRLOnly(nil, operator.DefaultRegistry(), nil, nil) + if len(results) != 0 { + t.Errorf("expected 0 results for empty CRLs, got %d", len(results)) + } +} + +func TestEvaluateOCSPOnlyWithEmptyOCSPs(t *testing.T) { + results := evaluateOCSPOnly(nil, operator.DefaultRegistry(), nil) + if len(results) != 0 { + t.Errorf("expected 0 results for empty OCSPs, got %d", len(results)) + } +} + +func TestExtractCertsFromInfoEmpty(t *testing.T) { + certs := extractCertsFromInfo(nil) + if len(certs) != 0 { + t.Errorf("expected 0 certs for nil input, got %d", len(certs)) + } + + certs = extractCertsFromInfo([]*cert.Info{}) + if len(certs) != 0 { + t.Errorf("expected 0 certs for empty slice, got %d", len(certs)) + } +} + +func TestExtractCertsFromInfoWithNilCert(t *testing.T) { + infos := []*cert.Info{ + {Cert: nil}, + {Cert: nil, FilePath: "test.pem"}, + } + certs := extractCertsFromInfo(infos) + if len(certs) != 0 { + t.Errorf("expected 0 certs for nil certs in info, got %d", len(certs)) + } +} + +func TestEvaluationContextDefaults(t *testing.T) { + evalCtx := EvaluationContext{} + + // Verify default values + if evalCtx.Registry == nil { + // Registry should be set when actually used + t.Log("Registry is nil in default context (expected)") + } + if evalCtx.Chain != nil { + t.Errorf("expected nil Chain in default context") + } + if evalCtx.CRLs != nil { + t.Errorf("expected nil CRLs in default context") + } + if evalCtx.OCSPs != nil { + t.Errorf("expected nil OCSPs in default context") + } +} \ No newline at end of file diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 48a5e1f..fcffa2f 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -7,45 +7,20 @@ import ( "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" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/output" "github.com/cavoq/PCL/internal/policy" - "github.com/cavoq/PCL/internal/zcrypto" - "github.com/zmap/zcrypto/x509" ) func Run(cfg Config, w io.Writer) error { applyDefaults(&cfg) - // Load policies from all specified paths - var policies []policy.Policy - var err error - for _, policyPath := range cfg.PolicyPaths { - // Check if path is a directory first - isDir, err2 := isDirectory(policyPath) - if err2 != nil { - return fmt.Errorf("checking policy path %s: %w", policyPath, err2) - } - - if isDir { - p, err2 := policy.ParseDir(policyPath) - if err2 != nil { - return fmt.Errorf("failed to parse policy directory %s: %w", policyPath, err2) - } - policies = append(policies, p...) - } else { - p, err2 := policy.ParseFile(policyPath) - if err2 != nil { - return fmt.Errorf("failed to parse policy file %s: %w", policyPath, err2) - } - policies = append(policies, p) - } + // Load policies + policies, err := loadPolicies(cfg.PolicyPaths) + if err != nil { + return err } reg := operator.DefaultRegistry() @@ -53,370 +28,216 @@ func Run(cfg Config, w io.Writer) error { var cleanup func() // Load CRLs if provided - var crls []*crl.Info - if cfg.CRLPath != "" { - crls, err = crl.GetCRLs(cfg.CRLPath) - if err != nil { - return fmt.Errorf("failed to load CRLs: %w", err) - } + crls, err := loadCRLs(cfg.CRLPath) + if err != nil { + return err } // Load OCSP if provided - var ocsps []*ocsp.Info - if cfg.OCSPPath != "" { - ocsps, err = ocsp.GetOCSPs(cfg.OCSPPath) - if err != nil { - return fmt.Errorf("failed to load OCSP responses: %w", err) - } + ocsps, err := loadOCSPs(cfg.OCSPPath) + if err != nil { + return err } // Process certificates if provided hasCert := cfg.CertPath != "" || len(cfg.CertURLs) > 0 hasIssuer := len(cfg.IssuerPaths) > 0 || len(cfg.IssuerURLs) > 0 - // Build issuer list for CRL/OCSP signature verification (always needed if provided) - var issuers []*cert.Info - if hasIssuer { - var issuerCleanup func() - issuers, issuerCleanup, err = loadIssuers(cfg, nil) - if err != nil { - return err - } - if issuerCleanup != nil { - cleanup = issuerCleanup - } + // Load issuers for CRL/OCSP signature verification + issuers, issuerCleanup, err := loadIssuersIfProvided(cfg, hasIssuer) + if err != nil { + return err + } + if issuerCleanup != nil { + cleanup = issuerCleanup } if hasCert { - // Load leaf certificates - var certs []*cert.Info //nolint:prealloc // certs is overwritten by loadCertificates - var certCleanup func() - - certs, certCleanup, err = loadCertificates(cfg) - if err != nil { - return err - } - if certCleanup != nil { - // Combine cleanup functions: both issuer and cert cleanups need to run - prevCleanup := cleanup - cleanup = func() { - certCleanup() - if prevCleanup != nil { - prevCleanup() - } - } - } - - // Schedule cleanup at the end of processing (for downloaded certs/issuers) - if cleanup != nil { - defer cleanup() - } + results, cleanup = processCertificates(cfg, policies, reg, crls, ocsps, issuers, cleanup, w) + } else if len(crls) > 0 { + results = evaluateCRLOnly(policies, reg, crls, issuers) + } else if len(ocsps) > 0 { + results = evaluateOCSPOnly(policies, reg, ocsps) + } else { + return fmt.Errorf("no certificates, CRLs, or OCSP responses provided") + } - // Build chain: leaf + issuers - allCerts := append(certs, issuers...) - if len(allCerts) == 0 { - return fmt.Errorf("no certificates provided") - } + // Run cleanup at the end + if cleanup != nil { + cleanup() + } - // Auto-validate: climb chain via CA Issuers URLs BEFORE BuildChain - // This fetches missing intermediates to complete the chain - if cfg.AutoValidate && !cfg.NoAutoChain { - // Start from leaf certificates and climb toward root - // We need to climb from each leaf to find intermediates - var climbedCerts []*cert.Info - for _, c := range certs { - if c.Cert == nil { - continue - } - // Start mini-chain with just this cert - miniChain := []*cert.Info{c} - // Climb from this cert via CA Issuers URLs - miniChain = climbChain(miniChain, cfg.CertTimeout, cfg.MaxChainDepth, w) - climbedCerts = append(climbedCerts, miniChain...) - } - // Merge climbed certs with provided issuers - allCerts = append(climbedCerts, issuers...) - } + // Output results + return outputResults(cfg, results, w) +} - chain, err := cert.BuildChain(allCerts) +func loadPolicies(paths []string) ([]policy.Policy, error) { + var policies []policy.Policy + for _, path := range paths { + isDir, err := isDirectory(path) if err != nil { - return fmt.Errorf("failed to build chain: %w", err) - } - - // Build nonce options from config - nonceOpts := buildNonceOptions(cfg) - - // Auto-validate: fetch CRLs from CRL Distribution Points - if cfg.AutoValidate && !cfg.NoAutoCRL { - autoCRLs := fetchAutoCRL(chain, cfg.OCSPTimeout, w) - crls = append(crls, autoCRLs...) + return nil, fmt.Errorf("checking policy path %s: %w", path, err) } - // Auto-validate: fetch OCSP for all certificates in chain - if cfg.AutoValidate && !cfg.NoAutoOCSP { - for i := 0; i < len(chain)-1; i++ { - c := chain[i] - if c.Cert == nil || len(c.Cert.OCSPServer) == 0 { - continue - } - // Build mini chain for OCSP request: [cert, issuer] - miniChain := []*cert.Info{c, chain[i+1]} - autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout, nonceOpts) - if err != nil { - _, _ = fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) - continue - } - // Debug: print OCSP response details when verbosity >= 2 - if cfg.Verbosity >= 2 && len(autoOCSPs) > 0 { - for _, ocspInfo := range autoOCSPs { - printOCSPResponseDebug(w, ocspInfo, nonceOpts) - } - } - ocsps = append(ocsps, autoOCSPs...) + if isDir { + p, err := policy.ParseDir(path) + if err != nil { + return nil, fmt.Errorf("failed to parse policy directory %s: %w", path, err) } + policies = append(policies, p...) + } else { + p, err := policy.ParseFile(path) + if err != nil { + return nil, fmt.Errorf("failed to parse policy file %s: %w", path, err) + } + policies = append(policies, p) } + } + return policies, nil +} +func loadCRLs(path string) ([]*crl.Info, error) { + if path == "" { + return nil, nil + } + crls, err := crl.GetCRLs(path) + if err != nil { + return nil, fmt.Errorf("failed to load CRLs: %w", err) + } + return crls, nil +} - for _, c := range chain { - tree := certzcrypto.BuildTree(c.Cert) - - // Add download format to tree for PEM format detection rule - if c.DownloadFormat != "" { - tree.Children["downloadFormat"] = node.New("downloadFormat", c.DownloadFormat) - tree.Children["downloadURL"] = node.New("downloadURL", c.DownloadURL) - } +func loadOCSPs(path string) ([]*ocsp.Info, error) { + if path == "" { + return nil, nil + } + ocsps, err := ocsp.GetOCSPs(path) + if err != nil { + return nil, fmt.Errorf("failed to load OCSP responses: %w", err) + } + return ocsps, nil +} - // Add CRL node to tree if CRLs are present - if len(crls) > 0 { - for _, crlInfo := range crls { - if crlInfo.CRL != nil { - crlNode := crlzcrypto.BuildTree(crlInfo.CRL) - if crlNode != nil { - tree.Children["crl"] = crlNode - } - break - } - } - } +func loadIssuersIfProvided(cfg Config, hasIssuer bool) ([]*cert.Info, func(), error) { + if !hasIssuer { + return nil, nil, nil + } + return loadIssuers(cfg, nil) +} - ctxOpts := []operator.ContextOption{ - operator.WithCRLs(crls), - operator.WithOCSPs(ocsps), - } - ctx := operator.NewEvaluationContext(tree, c, chain, ctxOpts...) +func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Registry, crls []*crl.Info, ocsps []*ocsp.Info, issuers []*cert.Info, existingCleanup func(), w io.Writer) ([]policy.Result, func()) { + // Load leaf certificates + var cleanup func() //nolint:prealloc // overwritten by loadCertificates + certs, certCleanup, err := loadCertificates(cfg) + if err != nil { + return nil, existingCleanup + } - // Filter policies by certificate type - filteredPolicies := filterPoliciesByCert(policies, c.Cert) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, reg, ctx) - results = append(results, res) + // Combine cleanup functions + if certCleanup != nil { + prevCleanup := existingCleanup + cleanup = func() { + certCleanup() + if prevCleanup != nil { + prevCleanup() } } + } else { + cleanup = existingCleanup + } - // Evaluate OCSP policies if OCSP responses were fetched - if len(ocsps) > 0 { - for _, ocspInfo := range ocsps { - if ocspInfo.Response == nil { - continue - } - - ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) - if ocspNode == nil { - continue - } - - // Create synthetic cert.Info for OCSP response to set proper CertType - ocspCertInfo := &cert.Info{ - FilePath: ocspInfo.FilePath, - Type: "ocsp", - Source: ocspInfo.Source, - } - - tree := ocspNode - ctxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - ctx := operator.NewEvaluationContext(tree, ocspCertInfo, chain, ctxOpts...) - - filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, reg, ctx) - results = append(results, res) - } - - // Evaluate OCSP signing certificate if present in response - if ocspInfo.Response.Certificate != nil { - // Convert standard cert to zcrypto cert - zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) - if err != nil || zcryptoSignerCert == nil { - continue - } - ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) - ocspSignerInfo := &cert.Info{ - Cert: zcryptoSignerCert, - FilePath: ocspInfo.FilePath + " (signing cert)", - Type: "ocspSigning", - Source: "extracted from OCSP response", - } - - signerCtxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - signerCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, signerCtxOpts...) - - signerPolicies := filterPoliciesByCert(policies, zcryptoSignerCert) - for _, p := range signerPolicies { - res := policy.Evaluate(p, ocspSignerTree, reg, signerCtx) - results = append(results, res) - } - } - } - } + // Build chain + allCerts := append(certs, issuers...) + if len(allCerts) == 0 { + return nil, cleanup + } - // Evaluate CRLs independently if CRLs were fetched (dual evaluation) - // This evaluates CRL-specific rules (like signature algorithm params) - // separately from certificate context - if len(crls) > 0 { - for _, crlInfo := range crls { - if crlInfo.CRL == nil { - continue - } - - // Build issuer certificates list from chain - var issuerCerts []*x509.Certificate - for _, c := range chain { - if c.Cert != nil { - issuerCerts = append(issuerCerts, c.Cert) - } - } - - crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) - if crlNode == nil { - continue - } - - // Create synthetic cert.Info for CRL to set proper CertType - crlCertInfo := &cert.Info{ - FilePath: crlInfo.FilePath, - Type: "crl", - Source: crlInfo.Source, - } - - tree := crlNode - ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} - ctx := operator.NewEvaluationContext(tree, crlCertInfo, chain, ctxOpts...) - - // Filter policies by CRL type - hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) - isIndirect := isIndirectCRL(crlInfo.CRL) - filteredPolicies := filterPoliciesByCRL(policies, hasDelta, isIndirect) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, reg, ctx) - results = append(results, res) - } - } - } - } else if len(crls) > 0 { - // Process CRLs independently when no certificates provided - // Use issuers as chain for CRL signature verification - // Also evaluate CRLs in auto-validate mode (dual evaluation) - for _, crlInfo := range crls { - if crlInfo.CRL == nil { + // Auto-validate: climb chain via CA Issuers URLs + if cfg.AutoValidate && !cfg.NoAutoChain { + var climbedCerts []*cert.Info + for _, c := range certs { + if c.Cert == nil { continue } + miniChain := []*cert.Info{c} + miniChain = climbChain(miniChain, cfg.CertTimeout, cfg.MaxChainDepth, w) + climbedCerts = append(climbedCerts, miniChain...) + } + allCerts = append(climbedCerts, issuers...) + } - // Build issuer certificates list for CRL type detection - var issuerCerts []*x509.Certificate - for _, issuer := range issuers { - if issuer.Cert != nil { - issuerCerts = append(issuerCerts, issuer.Cert) - } - } + chain, err := cert.BuildChain(allCerts) + if err != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to build chain: %v\n", err) + return nil, cleanup + } - crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) - if crlNode == nil { - continue - } + nonceOpts := buildNonceOptions(cfg) - // Create synthetic cert.Info for CRL to set proper CertType - crlCertInfo := &cert.Info{ - FilePath: crlInfo.FilePath, - Type: "crl", - Source: crlInfo.Source, - } + // Auto-validate: fetch CRLs + if cfg.AutoValidate && !cfg.NoAutoCRL { + autoCRLs := fetchAutoCRL(chain, cfg.OCSPTimeout, w) + crls = append(crls, autoCRLs...) + } - // Create a minimal tree with just the CRL - tree := crlNode + // Auto-validate: fetch OCSP + if cfg.AutoValidate && !cfg.NoAutoOCSP { + ocsps = append(ocsps, fetchAutoOCSPForChain(chain, cfg, nonceOpts, w)...) + } - ctxOpts := []operator.ContextOption{operator.WithCRLs(crls)} - ctx := operator.NewEvaluationContext(tree, crlCertInfo, issuers, ctxOpts...) + // Evaluate certificates + evalCtx := EvaluationContext{ + Policies: policies, + Registry: reg, + CRLs: crls, + OCSPs: ocsps, + Chain: chain, + } + results := evaluateChain(evalCtx) - // Filter policies by CRL type - hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) - isIndirect := isIndirectCRL(crlInfo.CRL) - filteredPolicies := filterPoliciesByCRL(policies, hasDelta, isIndirect) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, reg, ctx) - results = append(results, res) - } - } - } else if len(ocsps) > 0 { - // Process OCSP independently when no certificates/CRLs provided - for _, ocspInfo := range ocsps { - if ocspInfo.Response == nil { - continue - } + // Evaluate OCSP if present + if len(ocsps) > 0 { + results = append(results, evaluateOCSP(evalCtx)...) + } - ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) - if ocspNode == nil { - continue - } + // Evaluate CRLs if present (dual evaluation) + if len(crls) > 0 { + results = append(results, evaluateCRL(evalCtx)...) + } - // Create synthetic cert.Info for OCSP response to set proper CertType - ocspCertInfo := &cert.Info{ - FilePath: ocspInfo.FilePath, - Type: "ocsp", - Source: ocspInfo.Source, - } + return results, cleanup +} - // Use OCSP node directly as tree root - tree := ocspNode +func fetchAutoOCSPForChain(chain []*cert.Info, cfg Config, nonceOpts *ocsp.NonceOptions, w io.Writer) []*ocsp.Info { + var ocsps []*ocsp.Info - ctxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - ctx := operator.NewEvaluationContext(tree, ocspCertInfo, nil, ctxOpts...) + for i := 0; i < len(chain)-1; i++ { + c := chain[i] + if c.Cert == nil || len(c.Cert.OCSPServer) == 0 { + continue + } - // Filter policies by input type (OCSP) - filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, reg, ctx) - results = append(results, res) - } + miniChain := []*cert.Info{c, chain[i+1]} + autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout, nonceOpts) + if err != nil { + _, _ = fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) + continue + } - // Evaluate OCSP signing certificate if present in response - if ocspInfo.Response.Certificate != nil { - // Convert standard cert to zcrypto cert - zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) - if err != nil || zcryptoSignerCert == nil { - continue - } - ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) - ocspSignerInfo := &cert.Info{ - Cert: zcryptoSignerCert, - FilePath: ocspInfo.FilePath + " (signing cert)", - Type: "ocspSigning", - Source: "extracted from OCSP response", - } - - signerCtxOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - signerCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, nil, signerCtxOpts...) - - signerPolicies := filterPoliciesByCert(policies, zcryptoSignerCert) - for _, p := range signerPolicies { - res := policy.Evaluate(p, ocspSignerTree, reg, signerCtx) - results = append(results, res) - } + // Debug output + if cfg.Verbosity >= 2 && len(autoOCSPs) > 0 { + for _, ocspInfo := range autoOCSPs { + printOCSPResponseDebug(w, ocspInfo, nonceOpts) } } - } else { - return fmt.Errorf("no certificates, CRLs, or OCSP responses provided") + + ocsps = append(ocsps, autoOCSPs...) } + return ocsps +} + +func outputResults(cfg Config, results []policy.Result, w io.Writer) error { outputOpts := output.Options{ ShowPassed: cfg.Verbosity >= 1, ShowFailed: true, @@ -447,12 +268,9 @@ func applyDefaults(cfg *Config) { if cfg.MaxChainDepth <= 0 { cfg.MaxChainDepth = 10 } - // Default all auto-fetch options to true when AutoValidate is enabled - // unless explicitly set to false } } -// isDirectory checks if the given path is a directory. func isDirectory(path string) (bool, error) { info, err := os.Stat(path) if err != nil { @@ -461,7 +279,6 @@ func isDirectory(path string) (bool, error) { return info.IsDir(), nil } -// buildNonceOptions creates nonce options from config for OCSP requests (RFC 9654). func buildNonceOptions(cfg Config) *ocsp.NonceOptions { return &ocsp.NonceOptions{ Length: cfg.OCSPNonceLength, diff --git a/internal/linter/runner_test.go b/internal/linter/runner_test.go new file mode 100644 index 0000000..b5624ad --- /dev/null +++ b/internal/linter/runner_test.go @@ -0,0 +1,320 @@ +package linter + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/cavoq/PCL/internal/ocsp" +) + +func TestApplyDefaults(t *testing.T) { + tests := []struct { + name string + input Config + expected Config + }{ + { + name: "empty config gets defaults", + input: Config{}, + expected: Config{ + CertTimeout: 10 * time.Second, + OCSPTimeout: 5 * time.Second, + OutputFmt: "text", + }, + }, + { + name: "custom timeouts preserved", + input: Config{ + CertTimeout: 30 * time.Second, + OCSPTimeout: 10 * time.Second, + OutputFmt: "json", + }, + expected: Config{ + CertTimeout: 30 * time.Second, + OCSPTimeout: 10 * time.Second, + OutputFmt: "json", + }, + }, + { + name: "auto-validate sets max chain depth", + input: Config{ + AutoValidate: true, + }, + expected: Config{ + CertTimeout: 10 * time.Second, + OCSPTimeout: 5 * time.Second, + OutputFmt: "text", + AutoValidate: true, + MaxChainDepth: 10, + }, + }, + { + name: "auto-validate preserves custom max chain depth", + input: Config{ + AutoValidate: true, + MaxChainDepth: 5, + }, + expected: Config{ + CertTimeout: 10 * time.Second, + OCSPTimeout: 5 * time.Second, + OutputFmt: "text", + AutoValidate: true, + MaxChainDepth: 5, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.input + applyDefaults(&cfg) + + if cfg.CertTimeout != tt.expected.CertTimeout { + t.Errorf("CertTimeout: got %v, want %v", cfg.CertTimeout, tt.expected.CertTimeout) + } + if cfg.OCSPTimeout != tt.expected.OCSPTimeout { + t.Errorf("OCSPTimeout: got %v, want %v", cfg.OCSPTimeout, tt.expected.OCSPTimeout) + } + if cfg.OutputFmt != tt.expected.OutputFmt { + t.Errorf("OutputFmt: got %v, want %v", cfg.OutputFmt, tt.expected.OutputFmt) + } + if cfg.MaxChainDepth != tt.expected.MaxChainDepth { + t.Errorf("MaxChainDepth: got %v, want %v", cfg.MaxChainDepth, tt.expected.MaxChainDepth) + } + }) + } +} + +func TestIsDirectory(t *testing.T) { + // Create temp directory and file for testing + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + want bool + wantErr bool + }{ + { + name: "directory returns true", + path: tmpDir, + want: true, + }, + { + name: "file returns false", + path: tmpFile, + want: false, + }, + { + name: "non-existent returns error", + path: filepath.Join(tmpDir, "nonexistent"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := isDirectory(tt.path) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildNonceOptions(t *testing.T) { + tests := []struct { + name string + config Config + expected *ocsp.NonceOptions + }{ + { + name: "default nonce options", + config: Config{}, + expected: &ocsp.NonceOptions{ + Disabled: false, + }, + }, + { + name: "custom nonce length", + config: Config{ + OCSPNonceLength: 32, + }, + expected: &ocsp.NonceOptions{ + Length: 32, + Disabled: false, + }, + }, + { + name: "nonce disabled", + config: Config{ + NoOCSPNonce: true, + }, + expected: &ocsp.NonceOptions{ + Disabled: true, + }, + }, + { + name: "custom hash algorithm", + config: Config{ + OCSPHashAlgorithm: "SHA384", + }, + expected: &ocsp.NonceOptions{ + Hash: "SHA384", + Disabled: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildNonceOptions(tt.config) + + if got.Length != tt.expected.Length { + t.Errorf("Length: got %v, want %v", got.Length, tt.expected.Length) + } + if got.Disabled != tt.expected.Disabled { + t.Errorf("Disabled: got %v, want %v", got.Disabled, tt.expected.Disabled) + } + if got.Hash != tt.expected.Hash { + t.Errorf("Hash: got %v, want %v", got.Hash, tt.expected.Hash) + } + }) + } +} + +func TestLoadPolicies(t *testing.T) { + // Create temp directory with policy files + tmpDir := t.TempDir() + + // Create a simple policy file + policyContent := ` +id: test-policy +version: "1.0" +rules: + - id: test-rule + target: certificate.version + operator: eq + operands: [3] + severity: error +` + policyFile := filepath.Join(tmpDir, "test.yaml") + if err := os.WriteFile(policyFile, []byte(policyContent), 0644); err != nil { + t.Fatal(err) + } + + // Create another policy file + policyContent2 := ` +id: test-policy-2 +version: "1.0" +rules: + - id: test-rule-2 + target: certificate.version + operator: eq + operands: [3] + severity: warning +` + policyFile2 := filepath.Join(tmpDir, "test2.yaml") + if err := os.WriteFile(policyFile2, []byte(policyContent2), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + paths []string + wantLen int + wantErr bool + }{ + { + name: "single file", + paths: []string{policyFile}, + wantLen: 1, + }, + { + name: "directory", + paths: []string{tmpDir}, + wantLen: 2, // Both .yaml files + }, + { + name: "multiple files", + paths: []string{policyFile, policyFile2}, + wantLen: 2, + }, + { + name: "non-existent file", + paths: []string{filepath.Join(tmpDir, "nonexistent.yaml")}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policies, err := loadPolicies(tt.paths) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if len(policies) != tt.wantLen { + t.Errorf("got %d policies, want %d", len(policies), tt.wantLen) + } + }) + } +} + +func TestLoadCRLs(t *testing.T) { + // Test with empty path + crls, err := loadCRLs("") + if err != nil { + t.Errorf("unexpected error for empty path: %v", err) + } + if crls != nil { + t.Errorf("expected nil CRLs for empty path, got %d", len(crls)) + } +} + +func TestLoadOCSPs(t *testing.T) { + // Test with empty path + ocsps, err := loadOCSPs("") + if err != nil { + t.Errorf("unexpected error for empty path: %v", err) + } + if ocsps != nil { + t.Errorf("expected nil OCSPs for empty path, got %d", len(ocsps)) + } +} + +func TestLoadIssuersIfProvided(t *testing.T) { + // Test with no issuers + issuers, cleanup, err := loadIssuersIfProvided(Config{}, false) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if issuers != nil { + t.Errorf("expected nil issuers, got %d", len(issuers)) + } + if cleanup != nil { + t.Errorf("expected nil cleanup") + } +} \ No newline at end of file From c4483d44aa2f6887e5aaa84cc94ba1a233aa49b9 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 19:32:18 +0200 Subject: [PATCH 31/44] refactor: crl fetching --- internal/crl/crl.go | 149 +++++++++++++++++++++++++++++------ internal/crl/crl_test.go | 75 ++++++++++++++++++ internal/crl/fetcher.go | 89 --------------------- internal/linter/autofetch.go | 8 +- internal/linter/evaluator.go | 6 +- internal/ocsp/ocsp.go | 8 +- 6 files changed, 211 insertions(+), 124 deletions(-) delete mode 100644 internal/crl/fetcher.go diff --git a/internal/crl/crl.go b/internal/crl/crl.go index 5fdc66a..62f5722 100644 --- a/internal/crl/crl.go +++ b/internal/crl/crl.go @@ -1,59 +1,164 @@ package crl import ( + "crypto/sha256" + "encoding/hex" "encoding/pem" "fmt" + stdio "io" + "net/http" + "os" + "time" "github.com/zmap/zcrypto/x509" - "github.com/cavoq/PCL/internal/io" - "github.com/cavoq/PCL/internal/loader" + fileio "github.com/cavoq/PCL/internal/io" ) var extensions = []string{".crl", ".pem"} +type Format string + +const ( + FormatDER Format = "DER" // RFC 5280 required format + FormatPEM Format = "PEM" // Fallback format +) + +type SourceType string + +const ( + SourceLocal SourceType = "local" + SourceDownloaded SourceType = "downloaded" +) + +type SourceInfo struct { + Type SourceType + URL string +} + +func (s SourceInfo) String() string { + return string(s.Type) +} + type Info struct { CRL *x509.RevocationList FilePath string Hash string - Source string // Source description: "local", "downloaded", etc. + Source SourceInfo + Format Format } func ParseCRL(data []byte) (*x509.RevocationList, error) { + crl, _, err := parseCRL(data) + return crl, err +} + +func parseCRL(data []byte) (*x509.RevocationList, Format, error) { + crl, err := x509.ParseRevocationList(data) + if err == nil { + return crl, FormatDER, nil + } + derErr := err + block, _ := pem.Decode(data) if block != nil && block.Type == "X509 CRL" { - return x509.ParseRevocationList(block.Bytes) + crl, err = x509.ParseRevocationList(block.Bytes) + if err != nil { + return nil, "", fmt.Errorf("failed to parse PEM CRL: %w", err) + } + return crl, FormatPEM, nil } - crl, err := x509.ParseRevocationList(data) - if err != nil { - return nil, fmt.Errorf("failed to parse PEM or DER CRL: %w", err) - } - return crl, nil + return nil, "", fmt.Errorf("failed to parse PEM or DER CRL: %w", derErr) } func GetCRLFiles(path string) ([]string, error) { - return io.GetFilesWithExtensions(path, extensions...) + return fileio.GetFilesWithExtensions(path, extensions...) } func GetCRLs(path string) ([]*Info, error) { - results, err := loader.LoadAll( - path, - extensions, - ParseCRL, - func(crl *x509.RevocationList) []byte { return crl.Raw }, - ) + files, err := GetCRLFiles(path) if err != nil { return nil, err } - infos := make([]*Info, len(results)) - for i, r := range results { - infos[i] = &Info{ - CRL: r.Data, - FilePath: r.FilePath, - Hash: r.Hash, + infos := make([]*Info, 0, len(files)) + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + continue + } + + crl, format, err := parseCRL(data) + if err != nil { + continue } + + hash := sha256.Sum256(crl.Raw) + infos = append(infos, &Info{ + CRL: crl, + FilePath: file, + Hash: hex.EncodeToString(hash[:]), + Source: SourceInfo{Type: SourceLocal}, + Format: format, + }) + } + + if len(infos) == 0 && len(files) > 0 { + return nil, fmt.Errorf("no valid items found in %s", path) } + return infos, nil } + +func FetchCRL(url string, timeout time.Duration) (*Info, error) { + if url == "" { + return nil, fmt.Errorf("CRL URL is required") + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch CRL from %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("CRL server returned status %d", resp.StatusCode) + } + + body, err := stdio.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read CRL response: %w", err) + } + + crl, format, err := parseCRL(body) + if err != nil { + return nil, err + } + + hash := sha256.Sum256(crl.Raw) + return &Info{ + CRL: crl, + FilePath: url, + Hash: hex.EncodeToString(hash[:]), + Source: SourceInfo{Type: SourceDownloaded, URL: url}, + Format: format, + }, nil +} + +func FetchCRLs(urls []string, timeout time.Duration) ([]*Info, []error) { + var results []*Info + var errs []error + + for _, url := range urls { + result, err := FetchCRL(url, timeout) + if err != nil { + errs = append(errs, err) + continue + } + results = append(results, result) + } + + return results, errs +} diff --git a/internal/crl/crl_test.go b/internal/crl/crl_test.go index 5528aea..9891dbb 100644 --- a/internal/crl/crl_test.go +++ b/internal/crl/crl_test.go @@ -1,9 +1,13 @@ package crl import ( + "encoding/pem" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" + "time" "github.com/cavoq/PCL/internal/loader" ) @@ -82,6 +86,12 @@ func TestGetCRLs_SingleFile(t *testing.T) { if crls[0].FilePath == "" { t.Error("expected non-empty file path") } + if crls[0].Source.Type != SourceLocal { + t.Fatalf("expected local source, got %q", crls[0].Source.Type) + } + if crls[0].Format != FormatPEM { + t.Fatalf("expected PEM format, got %q", crls[0].Format) + } } func TestGetCRLs_Directory(t *testing.T) { @@ -119,3 +129,68 @@ func TestGetCRLs_NoValidCRLs(t *testing.T) { t.Fatal("expected error when no valid CRLs found") } } + +func TestFetchCRL_DER(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "test.crl")) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(data) + if block == nil || block.Type != "X509 CRL" { + t.Fatal("expected X509 CRL PEM fixture") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(block.Bytes) + })) + defer server.Close() + + result, err := FetchCRL(server.URL, time.Second) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.CRL == nil { + t.Fatal("expected CRL, got nil") + } + if result.Format != FormatDER { + t.Fatalf("expected DER format, got %q", result.Format) + } + if result.Source.Type != SourceDownloaded { + t.Fatalf("expected downloaded source, got %q", result.Source.Type) + } + if result.Source.URL != server.URL { + t.Fatalf("expected URL %q, got %q", server.URL, result.Source.URL) + } + if result.FilePath != server.URL { + t.Fatalf("expected file path %q, got %q", server.URL, result.FilePath) + } + if result.Hash == "" { + t.Fatal("expected non-empty hash") + } +} + +func TestFetchCRL_PEM(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "test.pem")) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(data) + })) + defer server.Close() + + result, err := FetchCRL(server.URL, time.Second) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.CRL == nil { + t.Fatal("expected CRL, got nil") + } + if result.Format != FormatPEM { + t.Fatalf("expected PEM format, got %q", result.Format) + } + if result.Source.Type != SourceDownloaded { + t.Fatalf("expected downloaded source, got %q", result.Source.Type) + } +} diff --git a/internal/crl/fetcher.go b/internal/crl/fetcher.go deleted file mode 100644 index 95801d9..0000000 --- a/internal/crl/fetcher.go +++ /dev/null @@ -1,89 +0,0 @@ -package crl - -import ( - "encoding/pem" - "fmt" - "io" - "net/http" - "time" - - "github.com/zmap/zcrypto/x509" -) - -// Format represents the CRL format fetched from CRL Distribution Points URL. -type Format string - -const ( - FormatDER Format = "DER" // RFC required format - FormatPEM Format = "PEM" // Fallback format -) - -// FetchResult contains the fetched CRL and format information. -type FetchResult struct { - CRL *x509.RevocationList - Format Format - URL string -} - -// FetchCRL downloads and parses a CRL from a CRL Distribution Points URL. -// Per RFC 5280, CRL Distribution Points must point to DER format CRLs. -func FetchCRL(url string, timeout time.Duration) (*FetchResult, error) { - if url == "" { - return nil, fmt.Errorf("CRL URL is required") - } - - client := &http.Client{ - Timeout: timeout, - } - resp, err := client.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to fetch CRL from %s: %w", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("CRL server returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read CRL response: %w", err) - } - - // Try DER format first (RFC requirement) - crl, err := x509.ParseRevocationList(body) - if err == nil { - return &FetchResult{CRL: crl, Format: FormatDER, URL: url}, nil - } - - // Try PEM format as fallback - block, _ := pem.Decode(body) - if block != nil && block.Type == "X509 CRL" { - crl, err = x509.ParseRevocationList(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse PEM CRL: %w", err) - } - return &FetchResult{CRL: crl, Format: FormatPEM, URL: url}, nil - } - - return nil, fmt.Errorf("failed to parse CRL as DER/BER format") -} - -// FetchCRLs downloads CRLs from multiple CRL Distribution Points URLs. -// Returns results and any errors encountered. -// Network failures are returned as errors, not as policy failures. -func FetchCRLs(urls []string, timeout time.Duration) ([]*FetchResult, []error) { - var results []*FetchResult - var errs []error - - for _, url := range urls { - result, err := FetchCRL(url, timeout) - if err != nil { - errs = append(errs, err) - continue - } - results = append(results, result) - } - - return results, errs -} \ No newline at end of file diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go index 04d425c..52d7f76 100644 --- a/internal/linter/autofetch.go +++ b/internal/linter/autofetch.go @@ -158,11 +158,7 @@ func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl continue } - results = append(results, &crl.Info{ - CRL: fetchResult.CRL, - FilePath: url, - Source: "downloaded", - }) + results = append(results, fetchResult) } } @@ -223,4 +219,4 @@ func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.No results = append(results, info) return results, nil -} \ No newline at end of file +} diff --git a/internal/linter/evaluator.go b/internal/linter/evaluator.go index 8b82e88..514756c 100644 --- a/internal/linter/evaluator.go +++ b/internal/linter/evaluator.go @@ -156,7 +156,7 @@ func evaluateCRL(ctx EvaluationContext) []policy.Result { crlCertInfo := &cert.Info{ FilePath: crlInfo.FilePath, Type: "crl", - Source: crlInfo.Source, + Source: crlInfo.Source.String(), } tree := crlNode @@ -197,7 +197,7 @@ func evaluateCRLOnly(policies []policy.Policy, registry *operator.Registry, crls crlCertInfo := &cert.Info{ FilePath: crlInfo.FilePath, Type: "crl", - Source: crlInfo.Source, + Source: crlInfo.Source.String(), } tree := crlNode @@ -267,4 +267,4 @@ func extractCertsFromInfo(infos []*cert.Info) []*x509.Certificate { } } return certs -} \ No newline at end of file +} diff --git a/internal/ocsp/ocsp.go b/internal/ocsp/ocsp.go index 3e42398..dd050ad 100644 --- a/internal/ocsp/ocsp.go +++ b/internal/ocsp/ocsp.go @@ -19,10 +19,10 @@ type Info struct { Source string // Source description: "local", "downloaded", etc. // Request debug info (populated when auto-fetching) - RequestNonce []byte // Nonce sent in request - RequestNonceHex string // Hex representation of nonce - RequestNonceLen int // Length of nonce in request - RequestRawLen int // Length of raw OCSP request bytes + RequestNonce []byte // Nonce sent in request + RequestNonceHex string // Hex representation of nonce + RequestNonceLen int // Length of nonce in request + RequestRawLen int // Length of raw OCSP request bytes RequestHashAlgorithm string // Hash algorithm used for CertID (e.g., "SHA256") } From d6b9fe4675356b8f83f763d3aa43f058b7699841 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 19:43:08 +0200 Subject: [PATCH 32/44] refactor: unified source struct --- internal/cert/cert.go | 39 ++++++++++++++++++++++--------- internal/cert/chain.go | 45 +++++++++++++++++++++++------------- internal/crl/crl.go | 23 ++++-------------- internal/crl/crl_test.go | 7 +++--- internal/linter/autofetch.go | 28 +++++++++++----------- internal/linter/evaluator.go | 17 +++++++------- internal/policy/engine.go | 20 ++++++++-------- internal/source/source.go | 23 ++++++++++++++++++ 8 files changed, 122 insertions(+), 80 deletions(-) create mode 100644 internal/source/source.go diff --git a/internal/cert/cert.go b/internal/cert/cert.go index fa1bc1e..628029c 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -4,6 +4,7 @@ import ( "encoding/pem" "fmt" + "github.com/cavoq/PCL/internal/source" "github.com/zmap/zcrypto/x509" "github.com/cavoq/PCL/internal/io" @@ -11,28 +12,44 @@ import ( var extensions = []string{".pem", ".der", ".crt", ".cer"} +type Format string + +const ( + FormatDER Format = "DER" + FormatPEM Format = "PEM" + FormatPKCS7 Format = "PKCS7" +) + type Info struct { - Cert *x509.Certificate - FilePath string - Hash string - Position int - Type string - Source string // Source description: "local", "downloaded", "extracted from PKCS#7", etc. - DownloadURL string // URL if certificate was downloaded via CA Issuers - DownloadFormat string // Format of download: "DER", "PKCS7", "PEM" (PEM is not RFC compliant) + Cert *x509.Certificate + FilePath string + Hash string + Position int + Type string + Source source.Info + Format Format } func ParseCertificate(data []byte) (*x509.Certificate, error) { + cert, _, err := parseCertificate(data) + return cert, err +} + +func parseCertificate(data []byte) (*x509.Certificate, Format, error) { block, _ := pem.Decode(data) if block != nil && block.Type == "CERTIFICATE" { - return x509.ParseCertificate(block.Bytes) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, "", err + } + return cert, FormatPEM, nil } cert, err := x509.ParseCertificate(data) if err != nil { - return nil, fmt.Errorf("failed to parse PEM or DER certificate: %w", err) + return nil, "", fmt.Errorf("failed to parse PEM or DER certificate: %w", err) } - return cert, nil + return cert, FormatDER, nil } func GetCertFiles(path string) ([]string, error) { diff --git a/internal/cert/chain.go b/internal/cert/chain.go index b5b0038..989e11c 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -1,34 +1,47 @@ package cert import ( + "crypto/sha256" + "encoding/hex" "fmt" + "os" "slices" - "github.com/zmap/zcrypto/x509" - - "github.com/cavoq/PCL/internal/loader" + "github.com/cavoq/PCL/internal/source" ) func LoadCertificates(path string) ([]*Info, error) { - results, err := loader.LoadAll( - path, - extensions, - ParseCertificate, - func(cert *x509.Certificate) []byte { return cert.Raw }, - ) + files, err := GetCertFiles(path) if err != nil { return nil, err } - infos := make([]*Info, len(results)) - for i, r := range results { - infos[i] = &Info{ - Cert: r.Data, - FilePath: r.FilePath, - Hash: r.Hash, - Source: "local", + infos := make([]*Info, 0, len(files)) + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + continue + } + + cert, format, err := parseCertificate(data) + if err != nil { + continue } + + hash := sha256.Sum256(cert.Raw) + infos = append(infos, &Info{ + Cert: cert, + FilePath: file, + Hash: hex.EncodeToString(hash[:]), + Source: source.Info{Type: source.Local, Format: string(format)}, + Format: format, + }) } + + if len(infos) == 0 && len(files) > 0 { + return nil, fmt.Errorf("no valid items found in %s", path) + } + return infos, nil } diff --git a/internal/crl/crl.go b/internal/crl/crl.go index 62f5722..0e091e3 100644 --- a/internal/crl/crl.go +++ b/internal/crl/crl.go @@ -10,6 +10,7 @@ import ( "os" "time" + "github.com/cavoq/PCL/internal/source" "github.com/zmap/zcrypto/x509" fileio "github.com/cavoq/PCL/internal/io" @@ -24,27 +25,11 @@ const ( FormatPEM Format = "PEM" // Fallback format ) -type SourceType string - -const ( - SourceLocal SourceType = "local" - SourceDownloaded SourceType = "downloaded" -) - -type SourceInfo struct { - Type SourceType - URL string -} - -func (s SourceInfo) String() string { - return string(s.Type) -} - type Info struct { CRL *x509.RevocationList FilePath string Hash string - Source SourceInfo + Source source.Info Format Format } @@ -99,7 +84,7 @@ func GetCRLs(path string) ([]*Info, error) { CRL: crl, FilePath: file, Hash: hex.EncodeToString(hash[:]), - Source: SourceInfo{Type: SourceLocal}, + Source: source.Info{Type: source.Local, Format: string(format)}, Format: format, }) } @@ -142,7 +127,7 @@ func FetchCRL(url string, timeout time.Duration) (*Info, error) { CRL: crl, FilePath: url, Hash: hex.EncodeToString(hash[:]), - Source: SourceInfo{Type: SourceDownloaded, URL: url}, + Source: source.Info{Type: source.Downloaded, URL: url, Format: string(format)}, Format: format, }, nil } diff --git a/internal/crl/crl_test.go b/internal/crl/crl_test.go index 9891dbb..6bca852 100644 --- a/internal/crl/crl_test.go +++ b/internal/crl/crl_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/cavoq/PCL/internal/loader" + "github.com/cavoq/PCL/internal/source" ) func TestParseCRL_PEM(t *testing.T) { @@ -86,7 +87,7 @@ func TestGetCRLs_SingleFile(t *testing.T) { if crls[0].FilePath == "" { t.Error("expected non-empty file path") } - if crls[0].Source.Type != SourceLocal { + if crls[0].Source.Type != source.Local { t.Fatalf("expected local source, got %q", crls[0].Source.Type) } if crls[0].Format != FormatPEM { @@ -155,7 +156,7 @@ func TestFetchCRL_DER(t *testing.T) { if result.Format != FormatDER { t.Fatalf("expected DER format, got %q", result.Format) } - if result.Source.Type != SourceDownloaded { + if result.Source.Type != source.Downloaded { t.Fatalf("expected downloaded source, got %q", result.Source.Type) } if result.Source.URL != server.URL { @@ -190,7 +191,7 @@ func TestFetchCRL_PEM(t *testing.T) { if result.Format != FormatPEM { t.Fatalf("expected PEM format, got %q", result.Format) } - if result.Source.Type != SourceDownloaded { + if result.Source.Type != source.Downloaded { t.Fatalf("expected downloaded source, got %q", result.Source.Type) } } diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go index 52d7f76..a925983 100644 --- a/internal/linter/autofetch.go +++ b/internal/linter/autofetch.go @@ -10,6 +10,7 @@ import ( "github.com/cavoq/PCL/internal/cert" "github.com/cavoq/PCL/internal/crl" "github.com/cavoq/PCL/internal/ocsp" + "github.com/cavoq/PCL/internal/source" "github.com/cavoq/PCL/internal/zcrypto" "github.com/zmap/zcrypto/x509" ) @@ -107,26 +108,27 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr } // Add issuer to chain - var source string + sourceInfo := source.Info{ + Type: source.Downloaded, + URL: url, + Format: string(pkcs7Result.Format), + } switch pkcs7Result.Format { case aia.FormatPKCS7: - source = "extracted from PKCS#7" + sourceInfo.Type = source.Extracted + sourceInfo.Description = "extracted from PKCS#7" case aia.FormatDER: - source = "downloaded" case aia.FormatPEM: - source = "downloaded PEM" + sourceInfo.Description = "downloaded PEM" _, _ = fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) - default: - source = "downloaded" } issuerInfo := &cert.Info{ - Cert: issuerCert, - FilePath: url, - Type: cert.GetCertType(issuerCert, len(result), len(result)+1), - Position: len(result), - Source: source, - DownloadURL: url, - DownloadFormat: string(pkcs7Result.Format), + Cert: issuerCert, + FilePath: url, + Type: cert.GetCertType(issuerCert, len(result), len(result)+1), + Position: len(result), + Source: sourceInfo, + Format: cert.Format(pkcs7Result.Format), } result = append(result, issuerInfo) diff --git a/internal/linter/evaluator.go b/internal/linter/evaluator.go index 514756c..256393e 100644 --- a/internal/linter/evaluator.go +++ b/internal/linter/evaluator.go @@ -10,6 +10,7 @@ import ( ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/source" "github.com/cavoq/PCL/internal/zcrypto" "github.com/zmap/zcrypto/x509" ) @@ -31,9 +32,9 @@ func evaluateChain(ctx EvaluationContext) []policy.Result { tree := certzcrypto.BuildTree(c.Cert) // Add download format to tree for PEM format detection rule - if c.DownloadFormat != "" { - tree.Children["downloadFormat"] = node.New("downloadFormat", c.DownloadFormat) - tree.Children["downloadURL"] = node.New("downloadURL", c.DownloadURL) + if c.Source.Format != "" && c.Source.Type != source.Local { + tree.Children["downloadFormat"] = node.New("downloadFormat", c.Source.Format) + tree.Children["downloadURL"] = node.New("downloadURL", c.Source.URL) } // Add CRL node to tree if CRLs are present @@ -84,7 +85,7 @@ func evaluateOCSP(ctx EvaluationContext) []policy.Result { ocspCertInfo := &cert.Info{ FilePath: ocspInfo.FilePath, Type: "ocsp", - Source: ocspInfo.Source, + Source: source.Info{Description: ocspInfo.Source}, } tree := ocspNode @@ -119,7 +120,7 @@ func evaluateOCSPSigningCert(policies []policy.Policy, registry *operator.Regist Cert: zcryptoSignerCert, FilePath: ocspInfo.FilePath + " (signing cert)", Type: "ocspSigning", - Source: "extracted from OCSP response", + Source: source.Info{Type: source.Extracted, Description: "extracted from OCSP response"}, } evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} @@ -156,7 +157,7 @@ func evaluateCRL(ctx EvaluationContext) []policy.Result { crlCertInfo := &cert.Info{ FilePath: crlInfo.FilePath, Type: "crl", - Source: crlInfo.Source.String(), + Source: crlInfo.Source, } tree := crlNode @@ -197,7 +198,7 @@ func evaluateCRLOnly(policies []policy.Policy, registry *operator.Registry, crls crlCertInfo := &cert.Info{ FilePath: crlInfo.FilePath, Type: "crl", - Source: crlInfo.Source.String(), + Source: crlInfo.Source, } tree := crlNode @@ -235,7 +236,7 @@ func evaluateOCSPOnly(policies []policy.Policy, registry *operator.Registry, ocs ocspCertInfo := &cert.Info{ FilePath: ocspInfo.FilePath, Type: "ocsp", - Source: ocspInfo.Source, + Source: source.Info{Description: ocspInfo.Source}, } tree := ocspNode diff --git a/internal/policy/engine.go b/internal/policy/engine.go index 6fe6d1f..e935f93 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -9,15 +9,15 @@ import ( ) type Policy struct { - ID string `yaml:"id"` - Version string `yaml:"version"` - Includes []string `yaml:"includes,omitempty"` - AppliesTo []string `yaml:"appliesTo,omitempty"` - CertType []string `yaml:"certType,omitempty"` - CRLType []string `yaml:"crlType,omitempty"` - TSTType []string `yaml:"tstType,omitempty"` - SCTType []string `yaml:"sctType,omitempty"` - Rules []rule.Rule `yaml:"rules"` + ID string `yaml:"id"` + Version string `yaml:"version"` + Includes []string `yaml:"includes,omitempty"` + AppliesTo []string `yaml:"appliesTo,omitempty"` + CertType []string `yaml:"certType,omitempty"` + CRLType []string `yaml:"crlType,omitempty"` + TSTType []string `yaml:"tstType,omitempty"` + SCTType []string `yaml:"sctType,omitempty"` + Rules []rule.Rule `yaml:"rules"` } type Result struct { @@ -62,7 +62,7 @@ func Evaluate( if ctx != nil && ctx.Cert != nil { certType = ctx.Cert.Type certPath = ctx.Cert.FilePath - source = ctx.Cert.Source + source = ctx.Cert.Source.String() } return Result{ diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..2381545 --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,23 @@ +package source + +type Type string + +const ( + Local Type = "local" + Downloaded Type = "downloaded" + Extracted Type = "extracted" +) + +type Info struct { + Type Type + URL string + Format string + Description string +} + +func (i Info) String() string { + if i.Description != "" { + return i.Description + } + return string(i.Type) +} From 468c132e8612f457c56a9226ff92c1710776bef9 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 20:00:56 +0200 Subject: [PATCH 33/44] refactor: unified format definition --- internal/aia/fetcher.go | 52 ++++++++++++++++-------------------- internal/cert/cert.go | 16 +++-------- internal/cert/chain.go | 2 +- internal/crl/crl.go | 19 +++++-------- internal/crl/crl_test.go | 6 ++--- internal/linter/autofetch.go | 10 +++---- internal/source/source.go | 10 ++++++- 7 files changed, 51 insertions(+), 64 deletions(-) diff --git a/internal/aia/fetcher.go b/internal/aia/fetcher.go index 20964c4..5c08d60 100644 --- a/internal/aia/fetcher.go +++ b/internal/aia/fetcher.go @@ -9,34 +9,26 @@ import ( "net/http" "time" + "github.com/cavoq/PCL/internal/source" "github.com/zmap/zcrypto/x509" zcryptoconv "github.com/cavoq/PCL/internal/zcrypto" ) -// Format represents the certificate format fetched from CA Issuers URL. -type Format string - -const ( - FormatDER Format = "DER" // RFC 5280: single DER-encoded certificate - FormatPKCS7 Format = "PKCS7" // RFC 5280: BER/DER-encoded PKCS#7 certs-only - FormatPEM Format = "PEM" // Fallback format (not RFC compliant) -) - // PKCS#7 OIDs var oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} // id-signedData (1.2.840.113549.1.7.2) // Result contains the fetched certificate and format information. type Result struct { Cert *x509.Certificate - Format Format + Format source.Format URL string } // PKCS7Result contains multiple certificates from a PKCS#7 bundle. type PKCS7Result struct { Certs []*x509.Certificate - Format Format + Format source.Format URL string } @@ -44,6 +36,7 @@ type PKCS7Result struct { // Per RFC 5280 Section 4.2.2.1, the CA Issuers URL must point to: // - Single DER-encoded certificate, OR // - BER/DER-encoded PKCS#7 certs-only bundle +// // Returns zcrypto certificate for consistency with the rest of the codebase. func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { if url == "" { @@ -71,7 +64,7 @@ func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { // Try DER format first (single DER-encoded certificate per RFC 5280) cert, err := x509.ParseCertificate(body) if err == nil { - return &Result{Cert: cert, Format: FormatDER, URL: url}, nil + return &Result{Cert: cert, Format: source.FormatDER, URL: url}, nil } // Try PKCS#7 SignedData format (certs-only bundle per RFC 5280) @@ -79,7 +72,7 @@ func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { if err == nil && len(pkcs7Certs) > 0 { // Return the first certificate (typically the issuer certificate) // Caller can use FetchCAIssuerPKCS7 to get all certificates if needed - return &Result{Cert: pkcs7Certs[0], Format: FormatPKCS7, URL: url}, nil + return &Result{Cert: pkcs7Certs[0], Format: source.FormatPKCS7, URL: url}, nil } // Try PEM format as fallback (non-compliant but commonly used) @@ -89,7 +82,7 @@ func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { if err != nil { return nil, fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) } - return &Result{Cert: cert, Format: FormatPEM, URL: url}, nil + return &Result{Cert: cert, Format: source.FormatPEM, URL: url}, nil } return nil, fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") @@ -124,13 +117,13 @@ func FetchCAIssuerPKCS7(url string, timeout time.Duration) (*PKCS7Result, error) // Try DER format first (single certificate) cert, err := x509.ParseCertificate(body) if err == nil { - return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: FormatDER, URL: url}, nil + return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: source.FormatDER, URL: url}, nil } // Try PKCS#7 format (certificate bundle) pkcs7Certs, err := parsePKCS7CertsOnly(body) if err == nil && len(pkcs7Certs) > 0 { - return &PKCS7Result{Certs: pkcs7Certs, Format: FormatPKCS7, URL: url}, nil + return &PKCS7Result{Certs: pkcs7Certs, Format: source.FormatPKCS7, URL: url}, nil } // Try PEM format @@ -140,7 +133,7 @@ func FetchCAIssuerPKCS7(url string, timeout time.Duration) (*PKCS7Result, error) if err != nil { return nil, fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) } - return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: FormatPEM, URL: url}, nil + return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: source.FormatPEM, URL: url}, nil } return nil, fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") @@ -172,17 +165,18 @@ func ToStdCert(cert *x509.Certificate) (*certstd.Certificate, error) { // parsePKCS7CertsOnly parses a PKCS#7 SignedData structure and extracts certificates. // PKCS#7 SignedData (certs-only) structure per RFC 5652: -// ContentInfo ::= SEQUENCE { -// contentType ContentType, -- OID: 1.2.840.113549.1.7.2 for signedData -// content [0] EXPLICIT ANY DEFINED BY contentType -// } -// SignedData ::= SEQUENCE { -// version INTEGER, -// digestAlgorithms SET, -// encapContentInfo SEQUENCE, -// certificates [0] IMPLICIT SET OF Certificate OPTIONAL, -// signerInfos SET -// } +// +// ContentInfo ::= SEQUENCE { +// contentType ContentType, -- OID: 1.2.840.113549.1.7.2 for signedData +// content [0] EXPLICIT ANY DEFINED BY contentType +// } +// SignedData ::= SEQUENCE { +// version INTEGER, +// digestAlgorithms SET, +// encapContentInfo SEQUENCE, +// certificates [0] IMPLICIT SET OF Certificate OPTIONAL, +// signerInfos SET +// } func parsePKCS7CertsOnly(data []byte) ([]*x509.Certificate, error) { // Parse ContentInfo var contentInfo struct { @@ -263,4 +257,4 @@ func parseCertificateSet(data []byte) ([]*x509.Certificate, error) { } return certs, nil -} \ No newline at end of file +} diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 628029c..9bbe8c7 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -12,14 +12,6 @@ import ( var extensions = []string{".pem", ".der", ".crt", ".cer"} -type Format string - -const ( - FormatDER Format = "DER" - FormatPEM Format = "PEM" - FormatPKCS7 Format = "PKCS7" -) - type Info struct { Cert *x509.Certificate FilePath string @@ -27,7 +19,7 @@ type Info struct { Position int Type string Source source.Info - Format Format + Format source.Format } func ParseCertificate(data []byte) (*x509.Certificate, error) { @@ -35,21 +27,21 @@ func ParseCertificate(data []byte) (*x509.Certificate, error) { return cert, err } -func parseCertificate(data []byte) (*x509.Certificate, Format, error) { +func parseCertificate(data []byte) (*x509.Certificate, source.Format, error) { block, _ := pem.Decode(data) if block != nil && block.Type == "CERTIFICATE" { cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, "", err } - return cert, FormatPEM, nil + return cert, source.FormatPEM, nil } cert, err := x509.ParseCertificate(data) if err != nil { return nil, "", fmt.Errorf("failed to parse PEM or DER certificate: %w", err) } - return cert, FormatDER, nil + return cert, source.FormatDER, nil } func GetCertFiles(path string) ([]string, error) { diff --git a/internal/cert/chain.go b/internal/cert/chain.go index 989e11c..547b088 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -33,7 +33,7 @@ func LoadCertificates(path string) ([]*Info, error) { Cert: cert, FilePath: file, Hash: hex.EncodeToString(hash[:]), - Source: source.Info{Type: source.Local, Format: string(format)}, + Source: source.Info{Type: source.Local, Format: format}, Format: format, }) } diff --git a/internal/crl/crl.go b/internal/crl/crl.go index 0e091e3..0833855 100644 --- a/internal/crl/crl.go +++ b/internal/crl/crl.go @@ -18,19 +18,12 @@ import ( var extensions = []string{".crl", ".pem"} -type Format string - -const ( - FormatDER Format = "DER" // RFC 5280 required format - FormatPEM Format = "PEM" // Fallback format -) - type Info struct { CRL *x509.RevocationList FilePath string Hash string Source source.Info - Format Format + Format source.Format } func ParseCRL(data []byte) (*x509.RevocationList, error) { @@ -38,10 +31,10 @@ func ParseCRL(data []byte) (*x509.RevocationList, error) { return crl, err } -func parseCRL(data []byte) (*x509.RevocationList, Format, error) { +func parseCRL(data []byte) (*x509.RevocationList, source.Format, error) { crl, err := x509.ParseRevocationList(data) if err == nil { - return crl, FormatDER, nil + return crl, source.FormatDER, nil } derErr := err @@ -51,7 +44,7 @@ func parseCRL(data []byte) (*x509.RevocationList, Format, error) { if err != nil { return nil, "", fmt.Errorf("failed to parse PEM CRL: %w", err) } - return crl, FormatPEM, nil + return crl, source.FormatPEM, nil } return nil, "", fmt.Errorf("failed to parse PEM or DER CRL: %w", derErr) @@ -84,7 +77,7 @@ func GetCRLs(path string) ([]*Info, error) { CRL: crl, FilePath: file, Hash: hex.EncodeToString(hash[:]), - Source: source.Info{Type: source.Local, Format: string(format)}, + Source: source.Info{Type: source.Local, Format: format}, Format: format, }) } @@ -127,7 +120,7 @@ func FetchCRL(url string, timeout time.Duration) (*Info, error) { CRL: crl, FilePath: url, Hash: hex.EncodeToString(hash[:]), - Source: source.Info{Type: source.Downloaded, URL: url, Format: string(format)}, + Source: source.Info{Type: source.Downloaded, URL: url, Format: format}, Format: format, }, nil } diff --git a/internal/crl/crl_test.go b/internal/crl/crl_test.go index 6bca852..cd356c4 100644 --- a/internal/crl/crl_test.go +++ b/internal/crl/crl_test.go @@ -90,7 +90,7 @@ func TestGetCRLs_SingleFile(t *testing.T) { if crls[0].Source.Type != source.Local { t.Fatalf("expected local source, got %q", crls[0].Source.Type) } - if crls[0].Format != FormatPEM { + if crls[0].Format != source.FormatPEM { t.Fatalf("expected PEM format, got %q", crls[0].Format) } } @@ -153,7 +153,7 @@ func TestFetchCRL_DER(t *testing.T) { if result.CRL == nil { t.Fatal("expected CRL, got nil") } - if result.Format != FormatDER { + if result.Format != source.FormatDER { t.Fatalf("expected DER format, got %q", result.Format) } if result.Source.Type != source.Downloaded { @@ -188,7 +188,7 @@ func TestFetchCRL_PEM(t *testing.T) { if result.CRL == nil { t.Fatal("expected CRL, got nil") } - if result.Format != FormatPEM { + if result.Format != source.FormatPEM { t.Fatalf("expected PEM format, got %q", result.Format) } if result.Source.Type != source.Downloaded { diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go index a925983..20c2c2f 100644 --- a/internal/linter/autofetch.go +++ b/internal/linter/autofetch.go @@ -111,14 +111,14 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr sourceInfo := source.Info{ Type: source.Downloaded, URL: url, - Format: string(pkcs7Result.Format), + Format: pkcs7Result.Format, } switch pkcs7Result.Format { - case aia.FormatPKCS7: + case source.FormatPKCS7: sourceInfo.Type = source.Extracted sourceInfo.Description = "extracted from PKCS#7" - case aia.FormatDER: - case aia.FormatPEM: + case source.FormatDER: + case source.FormatPEM: sourceInfo.Description = "downloaded PEM" _, _ = fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) } @@ -128,7 +128,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr Type: cert.GetCertType(issuerCert, len(result), len(result)+1), Position: len(result), Source: sourceInfo, - Format: cert.Format(pkcs7Result.Format), + Format: pkcs7Result.Format, } result = append(result, issuerInfo) diff --git a/internal/source/source.go b/internal/source/source.go index 2381545..2668990 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -8,10 +8,18 @@ const ( Extracted Type = "extracted" ) +type Format string + +const ( + FormatDER Format = "DER" + FormatPEM Format = "PEM" + FormatPKCS7 Format = "PKCS7" +) + type Info struct { Type Type URL string - Format string + Format Format Description string } From 7c01f6e314c5f4da6418e111f5be3b6bfa22ced0 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 20:09:06 +0200 Subject: [PATCH 34/44] fix: cert type check --- internal/cert/cert.go | 35 ++++++++++++++----------------- internal/cert/chain_test.go | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 9bbe8c7..6b695f5 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -52,26 +52,23 @@ func IsSelfSigned(cert *x509.Certificate) bool { return cert.Subject.String() == cert.Issuer.String() } -func GetCertType(cert *x509.Certificate, position, chainLen int) string { - // At position 0, check BasicConstraints to determine if it's actually a CA - if position == 0 { - if cert.BasicConstraintsValid && cert.IsCA { - if IsSelfSigned(cert) { - return "root" - } - return "intermediate" - } - // Check for ocspSigning EKU before returning "leaf" - for _, eku := range cert.ExtKeyUsage { - if eku == x509.ExtKeyUsageOcspSigning { - return "ocspSigning" - } +func GetCertType(cert *x509.Certificate, _, _ int) string { + if cert == nil { + return "" + } + + if cert.BasicConstraintsValid && cert.IsCA { + if IsSelfSigned(cert) { + return "root" } - return "leaf" + return "intermediate" } - // At other positions, check if it's root or intermediate - if position == chainLen-1 && IsSelfSigned(cert) { - return "root" + + for _, eku := range cert.ExtKeyUsage { + if eku == x509.ExtKeyUsageOcspSigning { + return "ocspSigning" + } } - return "intermediate" + + return "leaf" } diff --git a/internal/cert/chain_test.go b/internal/cert/chain_test.go index 40bfc81..f4f251a 100644 --- a/internal/cert/chain_test.go +++ b/internal/cert/chain_test.go @@ -4,6 +4,9 @@ import ( "path/filepath" "testing" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" + "github.com/cavoq/PCL/internal/loader" ) @@ -149,3 +152,42 @@ func TestBuildChain_FilePaths(t *testing.T) { } } } + +func TestGetCertType_ClassifiesIndependentOfPosition(t *testing.T) { + root := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Root"}, + Issuer: pkix.Name{CommonName: "Root"}, + BasicConstraintsValid: true, + IsCA: true, + } + if got := GetCertType(root, 0, 3); got != "root" { + t.Fatalf("expected root independent of position, got %q", got) + } + + intermediate := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Intermediate"}, + Issuer: pkix.Name{CommonName: "Root"}, + BasicConstraintsValid: true, + IsCA: true, + } + if got := GetCertType(intermediate, 0, 1); got != "intermediate" { + t.Fatalf("expected intermediate independent of position, got %q", got) + } + + ocspSigner := &x509.Certificate{ + Subject: pkix.Name{CommonName: "OCSP Signer"}, + Issuer: pkix.Name{CommonName: "Intermediate"}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOcspSigning}, + } + if got := GetCertType(ocspSigner, 2, 3); got != "ocspSigning" { + t.Fatalf("expected ocspSigning independent of position, got %q", got) + } + + leaf := &x509.Certificate{ + Subject: pkix.Name{CommonName: "Leaf"}, + Issuer: pkix.Name{CommonName: "Intermediate"}, + } + if got := GetCertType(leaf, 2, 3); got != "leaf" { + t.Fatalf("expected leaf independent of position, got %q", got) + } +} From 1f7e0f1a3dc61def1a16ad41a210ea0f7e87d9ab Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 20:26:37 +0200 Subject: [PATCH 35/44] refactor: aia package --- internal/aia/convert.go | 12 ++ internal/aia/fetcher.go | 213 +++-------------------------------- internal/aia/parser.go | 32 ++++++ internal/aia/pkcs7.go | 96 ++++++++++++++++ internal/linter/autofetch.go | 24 ++-- 5 files changed, 167 insertions(+), 210 deletions(-) create mode 100644 internal/aia/convert.go create mode 100644 internal/aia/parser.go create mode 100644 internal/aia/pkcs7.go diff --git a/internal/aia/convert.go b/internal/aia/convert.go new file mode 100644 index 0000000..08d2fae --- /dev/null +++ b/internal/aia/convert.go @@ -0,0 +1,12 @@ +package aia + +import ( + certstd "crypto/x509" + + "github.com/cavoq/PCL/internal/zcrypto" + "github.com/zmap/zcrypto/x509" +) + +func ToStdCert(cert *x509.Certificate) (*certstd.Certificate, error) { + return zcrypto.ToStdCert(cert) +} diff --git a/internal/aia/fetcher.go b/internal/aia/fetcher.go index 5c08d60..1b84dea 100644 --- a/internal/aia/fetcher.go +++ b/internal/aia/fetcher.go @@ -1,9 +1,6 @@ package aia import ( - certstd "crypto/x509" - "encoding/asn1" - "encoding/pem" "fmt" "io" "net/http" @@ -11,25 +8,12 @@ import ( "github.com/cavoq/PCL/internal/source" "github.com/zmap/zcrypto/x509" - - zcryptoconv "github.com/cavoq/PCL/internal/zcrypto" ) -// PKCS#7 OIDs -var oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} // id-signedData (1.2.840.113549.1.7.2) - -// Result contains the fetched certificate and format information. -type Result struct { - Cert *x509.Certificate - Format source.Format - URL string -} - -// PKCS7Result contains multiple certificates from a PKCS#7 bundle. -type PKCS7Result struct { +// IssuerResult contains certificates fetched from a CA Issuers URL. +type IssuerResult struct { Certs []*x509.Certificate - Format source.Format - URL string + Source source.Info } // FetchCAIssuer downloads and parses a certificate from a CA Issuers URL. @@ -38,7 +22,7 @@ type PKCS7Result struct { // - BER/DER-encoded PKCS#7 certs-only bundle // // Returns zcrypto certificate for consistency with the rest of the codebase. -func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { +func FetchCAIssuer(url string, timeout time.Duration) (*IssuerResult, error) { if url == "" { return nil, fmt.Errorf("CA Issuers URL is required") } @@ -61,89 +45,16 @@ func FetchCAIssuer(url string, timeout time.Duration) (*Result, error) { return nil, fmt.Errorf("failed to read CA Issuers response: %w", err) } - // Try DER format first (single DER-encoded certificate per RFC 5280) - cert, err := x509.ParseCertificate(body) - if err == nil { - return &Result{Cert: cert, Format: source.FormatDER, URL: url}, nil - } - - // Try PKCS#7 SignedData format (certs-only bundle per RFC 5280) - pkcs7Certs, err := parsePKCS7CertsOnly(body) - if err == nil && len(pkcs7Certs) > 0 { - // Return the first certificate (typically the issuer certificate) - // Caller can use FetchCAIssuerPKCS7 to get all certificates if needed - return &Result{Cert: pkcs7Certs[0], Format: source.FormatPKCS7, URL: url}, nil - } - - // Try PEM format as fallback (non-compliant but commonly used) - block, _ := pem.Decode(body) - if block != nil && block.Type == "CERTIFICATE" { - cert, err = x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) - } - return &Result{Cert: cert, Format: source.FormatPEM, URL: url}, nil - } - - return nil, fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") -} - -// FetchCAIssuerPKCS7 downloads and parses certificates from a CA Issuers URL, -// returning all certificates if the response is a PKCS#7 bundle. -// Useful when the PKCS#7 bundle contains multiple certificates (e.g., chain). -func FetchCAIssuerPKCS7(url string, timeout time.Duration) (*PKCS7Result, error) { - if url == "" { - return nil, fmt.Errorf("CA Issuers URL is required") - } - - client := &http.Client{ - Timeout: timeout, - } - resp, err := client.Get(url) + certs, format, err := ParseIssuerResponse(body) if err != nil { - return nil, fmt.Errorf("failed to fetch CA Issuers from %s: %w", url, err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("CA Issuers server returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read CA Issuers response: %w", err) - } - - // Try DER format first (single certificate) - cert, err := x509.ParseCertificate(body) - if err == nil { - return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: source.FormatDER, URL: url}, nil - } - - // Try PKCS#7 format (certificate bundle) - pkcs7Certs, err := parsePKCS7CertsOnly(body) - if err == nil && len(pkcs7Certs) > 0 { - return &PKCS7Result{Certs: pkcs7Certs, Format: source.FormatPKCS7, URL: url}, nil + return nil, err } - // Try PEM format - block, _ := pem.Decode(body) - if block != nil && block.Type == "CERTIFICATE" { - cert, err = x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) - } - return &PKCS7Result{Certs: []*x509.Certificate{cert}, Format: source.FormatPEM, URL: url}, nil - } - - return nil, fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") + return issuerResult(certs, url, format), nil } -// FetchCAIssuers downloads certificates from multiple CA Issuer URLs. -// Returns results and any errors encountered. -// Network failures are returned as errors, not as policy failures. -func FetchCAIssuers(urls []string, timeout time.Duration) ([]*Result, []error) { - var results []*Result +func FetchCAIssuers(urls []string, timeout time.Duration) ([]*IssuerResult, []error) { + var results []*IssuerResult var errs []error for _, url := range urls { @@ -158,103 +69,13 @@ func FetchCAIssuers(urls []string, timeout time.Duration) ([]*Result, []error) { return results, errs } -// ToStdCert converts zcrypto certificate to standard Go certificate. -func ToStdCert(cert *x509.Certificate) (*certstd.Certificate, error) { - return zcryptoconv.ToStdCert(cert) -} - -// parsePKCS7CertsOnly parses a PKCS#7 SignedData structure and extracts certificates. -// PKCS#7 SignedData (certs-only) structure per RFC 5652: -// -// ContentInfo ::= SEQUENCE { -// contentType ContentType, -- OID: 1.2.840.113549.1.7.2 for signedData -// content [0] EXPLICIT ANY DEFINED BY contentType -// } -// SignedData ::= SEQUENCE { -// version INTEGER, -// digestAlgorithms SET, -// encapContentInfo SEQUENCE, -// certificates [0] IMPLICIT SET OF Certificate OPTIONAL, -// signerInfos SET -// } -func parsePKCS7CertsOnly(data []byte) ([]*x509.Certificate, error) { - // Parse ContentInfo - var contentInfo struct { - ContentType asn1.ObjectIdentifier - Content asn1.RawValue `asn1:"explicit,tag:0"` - } - if _, err := asn1.Unmarshal(data, &contentInfo); err != nil { - return nil, fmt.Errorf("failed to parse PKCS#7 ContentInfo: %w", err) - } - - // Verify it's signedData (1.2.840.113549.1.7.2) - if !contentInfo.ContentType.Equal(oidSignedData) { - return nil, fmt.Errorf("PKCS#7 contentType is not signedData: %v", contentInfo.ContentType) - } - - // Parse SignedData from Content.Bytes (strips explicit tag wrapper) - var signedData struct { - Version int - DigestAlgorithms asn1.RawValue - EncapContentInfo struct { - ContentType asn1.ObjectIdentifier - Content asn1.RawValue `asn1:"optional,explicit,tag:0"` - } - Certificates asn1.RawValue `asn1:"optional,implicit,tag:0"` - CRLs asn1.RawValue `asn1:"optional,implicit,tag:1"` - SignerInfos asn1.RawValue - } - if _, err := asn1.Unmarshal(contentInfo.Content.Bytes, &signedData); err != nil { - return nil, fmt.Errorf("failed to parse PKCS#7 SignedData: %w", err) - } - - // Extract certificates from the [0] IMPLICIT SET OF Certificate - // Due to implicit tagging, the tag is replaced from SET (17) to context-specific [0] - // FullBytes contains the complete tagged content, Bytes contains inner SET data - if len(signedData.Certificates.Bytes) == 0 { - return nil, fmt.Errorf("PKCS#7 SignedData contains no certificates") - } - - certs, err := parseCertificateSet(signedData.Certificates.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse PKCS#7 certificates: %w", err) - } - - return certs, nil -} - -// parseCertificateSet parses a SET OF Certificate bytes and returns zcrypto certificates. -func parseCertificateSet(data []byte) ([]*x509.Certificate, error) { - var certs []*x509.Certificate - - // Parse the SET content directly - remaining := data - for len(remaining) > 0 { - // Each element in the SET is a SEQUENCE (Certificate) - var certRaw asn1.RawValue - n, err := asn1.Unmarshal(remaining, &certRaw) - if err != nil { - return nil, fmt.Errorf("failed to parse certificate element: %w", err) - } - - // Parse the certificate DER bytes - cert, err := x509.ParseCertificate(certRaw.FullBytes) - if err != nil { - // Try standard library parser as fallback - stdCert, stdErr := certstd.ParseCertificate(certRaw.FullBytes) - if stdErr != nil { - return nil, fmt.Errorf("failed to parse certificate: %w", err) - } - // Convert std cert to zcrypto cert - cert, err = zcryptoconv.FromStdCert(stdCert) - if err != nil { - return nil, fmt.Errorf("failed to convert certificate: %w", err) - } - } - - certs = append(certs, cert) - remaining = n +func issuerResult(certs []*x509.Certificate, url string, format source.Format) *IssuerResult { + return &IssuerResult{ + Certs: certs, + Source: source.Info{ + Type: source.Downloaded, + URL: url, + Format: format, + }, } - - return certs, nil } diff --git a/internal/aia/parser.go b/internal/aia/parser.go new file mode 100644 index 0000000..d9bfcf9 --- /dev/null +++ b/internal/aia/parser.go @@ -0,0 +1,32 @@ +package aia + +import ( + "encoding/pem" + "fmt" + + "github.com/cavoq/PCL/internal/source" + "github.com/zmap/zcrypto/x509" +) + +func ParseIssuerResponse(data []byte) ([]*x509.Certificate, source.Format, error) { + cert, err := x509.ParseCertificate(data) + if err == nil { + return []*x509.Certificate{cert}, source.FormatDER, nil + } + + pkcs7Certs, err := parsePKCS7CertsOnly(data) + if err == nil && len(pkcs7Certs) > 0 { + return pkcs7Certs, source.FormatPKCS7, nil + } + + block, _ := pem.Decode(data) + if block != nil && block.Type == "CERTIFICATE" { + cert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, "", fmt.Errorf("failed to parse PEM certificate from CA Issuers: %w", err) + } + return []*x509.Certificate{cert}, source.FormatPEM, nil + } + + return nil, "", fmt.Errorf("failed to parse CA Issuers: expected DER certificate, PKCS#7 bundle, or PEM format") +} diff --git a/internal/aia/pkcs7.go b/internal/aia/pkcs7.go new file mode 100644 index 0000000..e2a1ed3 --- /dev/null +++ b/internal/aia/pkcs7.go @@ -0,0 +1,96 @@ +package aia + +import ( + certstd "crypto/x509" + "encoding/asn1" + "fmt" + + "github.com/cavoq/PCL/internal/zcrypto" + "github.com/zmap/zcrypto/x509" +) + +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. +// PKCS#7 SignedData (certs-only) structure per RFC 5652: +// +// ContentInfo ::= SEQUENCE { +// contentType ContentType, -- OID: 1.2.840.113549.1.7.2 for signedData +// content [0] EXPLICIT ANY DEFINED BY contentType +// } +// SignedData ::= SEQUENCE { +// version INTEGER, +// digestAlgorithms SET, +// encapContentInfo SEQUENCE, +// certificates [0] IMPLICIT SET OF Certificate OPTIONAL, +// signerInfos SET +// } +func parsePKCS7CertsOnly(data []byte) ([]*x509.Certificate, error) { + var contentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue `asn1:"explicit,tag:0"` + } + if _, err := asn1.Unmarshal(data, &contentInfo); err != nil { + return nil, fmt.Errorf("failed to parse PKCS#7 ContentInfo: %w", err) + } + + if !contentInfo.ContentType.Equal(oidSignedData) { + return nil, fmt.Errorf("PKCS#7 contentType is not signedData: %v", contentInfo.ContentType) + } + + var signedData struct { + Version int + DigestAlgorithms asn1.RawValue + EncapContentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue `asn1:"optional,explicit,tag:0"` + } + Certificates asn1.RawValue `asn1:"optional,implicit,tag:0"` + CRLs asn1.RawValue `asn1:"optional,implicit,tag:1"` + SignerInfos asn1.RawValue + } + if _, err := asn1.Unmarshal(contentInfo.Content.Bytes, &signedData); err != nil { + return nil, fmt.Errorf("failed to parse PKCS#7 SignedData: %w", err) + } + + if len(signedData.Certificates.Bytes) == 0 { + return nil, fmt.Errorf("PKCS#7 SignedData contains no certificates") + } + + certs, err := parseCertificateSet(signedData.Certificates.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS#7 certificates: %w", err) + } + + return certs, nil +} + +func parseCertificateSet(data []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + + remaining := data + for len(remaining) > 0 { + var certRaw asn1.RawValue + n, err := asn1.Unmarshal(remaining, &certRaw) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate element: %w", err) + } + + cert, err := x509.ParseCertificate(certRaw.FullBytes) + if err != nil { + stdCert, stdErr := certstd.ParseCertificate(certRaw.FullBytes) + if stdErr != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + cert, err = zcrypto.FromStdCert(stdCert) + if err != nil { + return nil, fmt.Errorf("failed to convert certificate: %w", err) + } + } + + certs = append(certs, cert) + remaining = n + } + + return certs, nil +} diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go index 20c2c2f..785bc51 100644 --- a/internal/linter/autofetch.go +++ b/internal/linter/autofetch.go @@ -59,7 +59,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr // Fetch issuer(s) from first CA Issuers URL (may be PKCS#7 bundle) url := top.Cert.IssuingCertificateURL[0] - pkcs7Result, err := aia.FetchCAIssuerPKCS7(url, timeout) + issuerResult, err := aia.FetchCAIssuer(url, timeout) if err != nil { _, _ = fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) break @@ -68,7 +68,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr // Find the correct issuer certificate from the bundle // Match by Issuer DN (subject of issuer should match issuer of cert) var issuerCert *x509.Certificate - for _, cert := range pkcs7Result.Certs { + for _, cert := range issuerResult.Certs { // Check if this cert's subject matches the current cert's issuer if cert.Subject.String() == top.Cert.Issuer.String() { issuerCert = cert @@ -78,7 +78,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr // If no exact DN match, try AKI-SKI matching if issuerCert == nil && len(top.Cert.AuthorityKeyId) > 0 { - for _, cert := range pkcs7Result.Certs { + for _, cert := range issuerResult.Certs { if len(cert.SubjectKeyId) > 0 && string(cert.SubjectKeyId) == string(top.Cert.AuthorityKeyId) { issuerCert = cert break @@ -88,12 +88,12 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr // Fallback: use first certificate if only one, or continue with best guess if issuerCert == nil { - if len(pkcs7Result.Certs) == 1 { - issuerCert = pkcs7Result.Certs[0] + if len(issuerResult.Certs) == 1 { + issuerCert = issuerResult.Certs[0] } else { // Multiple certs with no match - use first as best guess - issuerCert = pkcs7Result.Certs[0] - _, _ = fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(pkcs7Result.Certs)) + issuerCert = issuerResult.Certs[0] + _, _ = fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(issuerResult.Certs)) } } @@ -108,12 +108,8 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr } // Add issuer to chain - sourceInfo := source.Info{ - Type: source.Downloaded, - URL: url, - Format: pkcs7Result.Format, - } - switch pkcs7Result.Format { + sourceInfo := issuerResult.Source + switch issuerResult.Source.Format { case source.FormatPKCS7: sourceInfo.Type = source.Extracted sourceInfo.Description = "extracted from PKCS#7" @@ -128,7 +124,7 @@ func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Wr Type: cert.GetCertType(issuerCert, len(result), len(result)+1), Position: len(result), Source: sourceInfo, - Format: pkcs7Result.Format, + Format: issuerResult.Source.Format, } result = append(result, issuerInfo) From efe86884e6b2ed229ab926007eb1067bbc808b83 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 20:35:13 +0200 Subject: [PATCH 36/44] refactor: asn1 package --- internal/asn1/encoding.go | 259 ---------------------------------- internal/asn1/oid.go | 51 +++++++ internal/asn1/oid_test.go | 51 +++++++ internal/asn1/params.go | 44 ++++++ internal/asn1/params_test.go | 26 ++++ internal/asn1/parser.go | 262 ++++++++++------------------------- internal/asn1/parser_test.go | 37 +---- internal/asn1/string.go | 106 ++++++++++++++ internal/asn1/string_test.go | 65 +++++++++ internal/asn1/time.go | 99 +++++++++++++ internal/asn1/time_test.go | 59 ++++++++ 11 files changed, 578 insertions(+), 481 deletions(-) delete mode 100644 internal/asn1/encoding.go create mode 100644 internal/asn1/oid.go create mode 100644 internal/asn1/oid_test.go create mode 100644 internal/asn1/params.go create mode 100644 internal/asn1/params_test.go create mode 100644 internal/asn1/string.go create mode 100644 internal/asn1/string_test.go create mode 100644 internal/asn1/time.go create mode 100644 internal/asn1/time_test.go diff --git a/internal/asn1/encoding.go b/internal/asn1/encoding.go deleted file mode 100644 index 2d54403..0000000 --- a/internal/asn1/encoding.go +++ /dev/null @@ -1,259 +0,0 @@ -package asn1 - -import ( - stdasn1 "encoding/asn1" - "fmt" - "strings" - "time" -) - -// TimeFormatInfo contains information about ASN.1 time encoding. -type TimeFormatInfo struct { - Tag int // ASN.1 tag: 23 for UTCTime, 24 for GeneralizedTime - Format string // Time format string - RawBytes []byte // Raw DER bytes of the time value - RawString string // Raw string representation from DER - IsUTC bool // true for UTCTime, false for GeneralizedTime - HasSeconds bool // whether seconds are present - HasFraction bool // whether fractional seconds are present - HasZulu bool // whether 'Z' suffix is present (required by RFC 5280) -} - -// ParseUTCTime parses UTCTime DER bytes and returns format info. -// UTCTime format: YYMMDDHHMMSSZ (RFC 5280 requires Z suffix) -// Tag: 23 (0x17) -func ParseUTCTime(derBytes []byte) (*TimeFormatInfo, error) { - info := &TimeFormatInfo{ - Tag: 23, - IsUTC: true, - RawBytes: derBytes, - } - - // Decode the raw string from DER - // DER encoding: tag (1 byte) + length + value - if len(derBytes) < 2 { - return nil, fmt.Errorf("invalid UTCTime: too short") - } - - tag := int(derBytes[0]) - if tag != 23 { - return nil, fmt.Errorf("invalid UTCTime tag: expected 23, got %d", tag) - } - - length := int(derBytes[1]) - if len(derBytes) < 2+length { - return nil, fmt.Errorf("invalid UTCTime: length mismatch") - } - - valueBytes := derBytes[2:2+length] - info.RawString = string(valueBytes) - - // Parse format characteristics - // Valid formats per RFC 5280: - // - YYMMDDHHMMSSZ (13 chars, must have Z) - // Seconds are required per RFC 5280 Section 4.1.2.5.1 - info.HasZulu = strings.HasSuffix(info.RawString, "Z") - info.HasSeconds = len(info.RawString) >= 12 // YYMMDDHHMMSS has 12 chars before Z - - // Parse the actual time to validate - var t time.Time - // Go's asn1 package can parse UTCTime - rest, err := stdasn1.UnmarshalWithParams(derBytes, &t, "utctime") - if err != nil { - return nil, fmt.Errorf("failed to parse UTCTime: %w", err) - } - if len(rest) > 0 { - return nil, fmt.Errorf("trailing data in UTCTime") - } - - return info, nil -} - -// ParseGeneralizedTime parses GeneralizedTime DER bytes and returns format info. -// GeneralizedTime format: YYYYMMDDHHMMSSZ or YYYYMMDDHHMMSS.fffZ -// Tag: 24 (0x18) -func ParseGeneralizedTime(derBytes []byte) (*TimeFormatInfo, error) { - info := &TimeFormatInfo{ - Tag: 24, - IsUTC: false, - RawBytes: derBytes, - HasSeconds: true, // GeneralizedTime always has seconds - } - - // Decode the raw string from DER - if len(derBytes) < 2 { - return nil, fmt.Errorf("invalid GeneralizedTime: too short") - } - - tag := int(derBytes[0]) - if tag != 24 { - return nil, fmt.Errorf("invalid GeneralizedTime tag: expected 24, got %d", tag) - } - - length := int(derBytes[1]) - if len(derBytes) < 2+length { - return nil, fmt.Errorf("invalid GeneralizedTime: length mismatch") - } - - valueBytes := derBytes[2:2+length] - info.RawString = string(valueBytes) - - // Parse format characteristics - info.HasZulu = strings.HasSuffix(info.RawString, "Z") - info.HasFraction = strings.Contains(info.RawString, ".") - - // Parse the actual time to validate - var t time.Time - // Go's asn1 package can parse GeneralizedTime - rest, err := stdasn1.UnmarshalWithParams(derBytes, &t, "generalized") - if err != nil { - return nil, fmt.Errorf("failed to parse GeneralizedTime: %w", err) - } - if len(rest) > 0 { - return nil, fmt.Errorf("trailing data in GeneralizedTime") - } - - return info, nil -} - -// EncodingType represents ASN.1 string encoding types. -type EncodingType int - -const ( - EncodingUnknown EncodingType = iota - EncodingIA5String - EncodingPrintableString - EncodingUTF8String - EncodingBMPString - EncodingUniversalString -) - -// EncodingInfo contains information about ASN.1 string encoding. -type EncodingInfo struct { - Type EncodingType - TagName string - RawBytes []byte - StringValue string - ValidChars bool // whether all characters are valid for the encoding type - InvalidChars []byte // characters that violate encoding rules -} - -// ValidateIA5String validates that a byte sequence conforms to IA5String encoding. -// IA5String is equivalent to ASCII (0x00-0x7F). -func ValidateIA5String(derBytes []byte) (*EncodingInfo, error) { - info := &EncodingInfo{ - Type: EncodingIA5String, - TagName: "IA5String", - RawBytes: derBytes, - ValidChars: true, - } - - // DER encoding: tag (1 byte) + length + value - if len(derBytes) < 2 { - return nil, fmt.Errorf("invalid IA5String: too short") - } - - tag := int(derBytes[0]) - if tag != 22 { // IA5String tag - return nil, fmt.Errorf("invalid IA5String tag: expected 22, got %d", tag) - } - - length := int(derBytes[1]) - if len(derBytes) < 2+length { - return nil, fmt.Errorf("invalid IA5String: length mismatch") - } - - valueBytes := derBytes[2:2+length] - info.StringValue = string(valueBytes) - - // Check each character is in ASCII range (0x00-0x7F) - for _, b := range valueBytes { - if b > 0x7F { - info.ValidChars = false - info.InvalidChars = append(info.InvalidChars, b) - } - } - - return info, nil -} - -// ValidatePrintableString validates that a byte sequence conforms to PrintableString encoding. -// PrintableString allows: A-Z, a-z, 0-9, space, '(),./:=?- and special chars -// Per RFC 5280 Appendix A.1: PrintableString character set -func ValidatePrintableString(derBytes []byte) (*EncodingInfo, error) { - info := &EncodingInfo{ - Type: EncodingPrintableString, - TagName: "PrintableString", - RawBytes: derBytes, - ValidChars: true, - } - - // DER encoding: tag (1 byte) + length + value - if len(derBytes) < 2 { - return nil, fmt.Errorf("invalid PrintableString: too short") - } - - tag := int(derBytes[0]) - if tag != 19 { // PrintableString tag - return nil, fmt.Errorf("invalid PrintableString tag: expected 19, got %d", tag) - } - - length := int(derBytes[1]) - if len(derBytes) < 2+length { - return nil, fmt.Errorf("invalid PrintableString: length mismatch") - } - - valueBytes := derBytes[2:2+length] - info.StringValue = string(valueBytes) - - // PrintableString valid characters per ASN.1: - // A-Z, a-z, 0-9, space, apostrophe, (, ), +, comma, -, ., /, :, =, ? - for _, b := range valueBytes { - if !isPrintableStringChar(b) { - info.ValidChars = false - info.InvalidChars = append(info.InvalidChars, b) - } - } - - return info, nil -} - -// isPrintableStringChar checks if a byte is valid in PrintableString. -func isPrintableStringChar(b byte) bool { - // Upper case letters - if b >= 'A' && b <= 'Z' { - return true - } - // Lower case letters - if b >= 'a' && b <= 'z' { - return true - } - // Digits - if b >= '0' && b <= '9' { - return true - } - // Special characters allowed in PrintableString - switch b { - case ' ', '\'', '(', ')', '+', ',', '-', '.', '/', ':', '=', '?', '&', '[', ']', '#', '@', '!', '"', '%', '*', ';', '<', '>', '_', '\\', '{', '}', '|', '~', '^': - return true - } - return false -} - -// GetEncodingType returns the encoding type from ASN.1 tag. -func GetEncodingType(tag int) EncodingType { - switch tag { - case 22: - return EncodingIA5String - case 19: - return EncodingPrintableString - case 12: - return EncodingUTF8String - case 30: - return EncodingBMPString - case 28: - return EncodingUniversalString - default: - return EncodingUnknown - } -} \ No newline at end of file diff --git a/internal/asn1/oid.go b/internal/asn1/oid.go new file mode 100644 index 0000000..b1ed9c2 --- /dev/null +++ b/internal/asn1/oid.go @@ -0,0 +1,51 @@ +package asn1 + +import ( + "strconv" + + "golang.org/x/crypto/cryptobyte" +) + +// oidString converts a cryptobyte OID to standard string format (e.g., "1.2.840.113549.1.1.11"). +func oidString(oid cryptobyte.String) string { + var components []int + + var first byte + if !oid.ReadUint8(&first) { + return "" + } + components = append(components, int(first/40), int(first%40)) + + for !oid.Empty() { + var val int + if !readOIDComponent(&oid, &val) { + break + } + components = append(components, val) + } + + result := "" + for i, c := range components { + if i > 0 { + result += "." + } + result += strconv.Itoa(c) + } + return result +} + +func readOIDComponent(oid *cryptobyte.String, val *int) bool { + var v int + for { + var b byte + if !oid.ReadUint8(&b) { + return false + } + v = (v << 7) | int(b&0x7f) + if b&0x80 == 0 { + break + } + } + *val = v + return true +} diff --git a/internal/asn1/oid_test.go b/internal/asn1/oid_test.go new file mode 100644 index 0000000..887f887 --- /dev/null +++ b/internal/asn1/oid_test.go @@ -0,0 +1,51 @@ +package asn1 + +import ( + "testing" + + "golang.org/x/crypto/cryptobyte" +) + +func TestOIDString(t *testing.T) { + tests := []struct { + name string + oidBytes []byte + expected string + }{ + { + name: "sha256WithRSAEncryption", + oidBytes: []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b}, + expected: "1.2.840.113549.1.1.11", + }, + { + name: "rsaEncryption", + oidBytes: []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01}, + expected: "1.2.840.113549.1.1.1", + }, + { + name: "commonName", + oidBytes: []byte{0x55, 0x04, 0x03}, + expected: "2.5.4.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := oidString(tt.oidBytes) + if result != tt.expected { + t.Errorf("got %s, want %s", result, tt.expected) + } + }) + } +} + +func TestReadOIDComponent(t *testing.T) { + oid := cryptobyte.String([]byte{0x86, 0x48}) + var got int + if !readOIDComponent(&oid, &got) { + t.Fatal("expected OID component to parse") + } + if got != 840 { + t.Fatalf("expected 840, got %d", got) + } +} diff --git a/internal/asn1/params.go b/internal/asn1/params.go new file mode 100644 index 0000000..b2fc8c0 --- /dev/null +++ b/internal/asn1/params.go @@ -0,0 +1,44 @@ +package asn1 + +// ParamsState represents the state of an AlgorithmIdentifier parameters field. +type ParamsState struct { + IsNull bool // parameters is ASN.1 NULL + IsAbsent bool // parameters field is absent + OID string // algorithm OID + NamedCurve string // namedCurve OID for ECDSA (from parameters field) + RawDER []byte // raw DER bytes of the entire AlgorithmIdentifier SEQUENCE + + // RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) + PSS *PSSParams + + // RSAES-OAEP parameters (OID 1.2.840.113549.1.1.7) + OAEP *OAEPParams +} + +// PSSParams represents RSASSA-PSS-params structure. +type PSSParams struct { + HashAlgorithm AlgorithmIdentifier // [0] DEFAULT sha1 + MaskGenAlgorithm AlgorithmIdentifier // [1] DEFAULT mgf1SHA1 + SaltLength int // [2] DEFAULT 20 + TrailerField int // [3] DEFAULT 1 + HashAlgorithmSet bool // whether hashAlgorithm was explicitly set + MaskGenAlgorithmSet bool // whether maskGenAlgorithm was explicitly set + SaltLengthSet bool // whether saltLength was explicitly set + TrailerFieldSet bool // whether trailerField was explicitly set +} + +// OAEPParams represents RSAES-OAEP-params structure. +type OAEPParams struct { + HashAlgorithm AlgorithmIdentifier // [0] DEFAULT sha1 + MaskGenAlgorithm AlgorithmIdentifier // [1] DEFAULT mgf1SHA1 + PSourceAlgorithm AlgorithmIdentifier // [2] DEFAULT pSpecifiedEmpty + HashAlgorithmSet bool // whether hashAlgorithm was explicitly set + MaskGenAlgorithmSet bool // whether maskGenAlgorithm was explicitly set + PSourceAlgorithmSet bool // whether pSourceAlgorithm was explicitly set +} + +// AlgorithmIdentifier represents an AlgorithmIdentifier structure. +type AlgorithmIdentifier struct { + OID string + Params ParamsState // nested params for MGF1, etc. +} diff --git a/internal/asn1/params_test.go b/internal/asn1/params_test.go new file mode 100644 index 0000000..e283faf --- /dev/null +++ b/internal/asn1/params_test.go @@ -0,0 +1,26 @@ +package asn1 + +import "testing" + +func TestParamsStateZeroValue(t *testing.T) { + var state ParamsState + if state.IsNull || state.IsAbsent || state.OID != "" || state.PSS != nil || state.OAEP != nil { + t.Fatalf("unexpected zero value: %+v", state) + } +} + +func TestAlgorithmIdentifierCarriesNestedParams(t *testing.T) { + algo := AlgorithmIdentifier{ + OID: oidMGF1, + Params: ParamsState{ + OID: oidSHA1, + }, + } + + if algo.OID != oidMGF1 { + t.Fatalf("expected MGF1 OID, got %q", algo.OID) + } + if algo.Params.OID != oidSHA1 { + t.Fatalf("expected SHA1 nested OID, got %q", algo.Params.OID) + } +} diff --git a/internal/asn1/parser.go b/internal/asn1/parser.go index ccd15a7..ef4a878 100644 --- a/internal/asn1/parser.go +++ b/internal/asn1/parser.go @@ -5,48 +5,13 @@ import ( cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" ) -// ParamsState represents the state of an AlgorithmIdentifier parameters field. -type ParamsState struct { - IsNull bool // parameters is ASN.1 NULL - IsAbsent bool // parameters field is absent - OID string // algorithm OID - NamedCurve string // namedCurve OID for ECDSA (from parameters field) - RawDER []byte // raw DER bytes of the entire AlgorithmIdentifier SEQUENCE - - // RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) - PSS *PSSParams - - // RSAES-OAEP parameters (OID 1.2.840.113549.1.1.7) - OAEP *OAEPParams -} - -// PSSParams represents RSASSA-PSS-params structure. -type PSSParams struct { - HashAlgorithm AlgorithmIdentifier // [0] DEFAULT sha1 - MaskGenAlgorithm AlgorithmIdentifier // [1] DEFAULT mgf1SHA1 - SaltLength int // [2] DEFAULT 20 - TrailerField int // [3] DEFAULT 1 - HashAlgorithmSet bool // whether hashAlgorithm was explicitly set - MaskGenAlgorithmSet bool // whether maskGenAlgorithm was explicitly set - SaltLengthSet bool // whether saltLength was explicitly set - TrailerFieldSet bool // whether trailerField was explicitly set -} - -// OAEPParams represents RSAES-OAEP-params structure. -type OAEPParams struct { - HashAlgorithm AlgorithmIdentifier // [0] DEFAULT sha1 - MaskGenAlgorithm AlgorithmIdentifier // [1] DEFAULT mgf1SHA1 - PSourceAlgorithm AlgorithmIdentifier // [2] DEFAULT pSpecifiedEmpty - HashAlgorithmSet bool // whether hashAlgorithm was explicitly set - MaskGenAlgorithmSet bool // whether maskGenAlgorithm was explicitly set - PSourceAlgorithmSet bool // whether pSourceAlgorithm was explicitly set -} - -// AlgorithmIdentifier represents an AlgorithmIdentifier structure. -type AlgorithmIdentifier struct { - OID string - Params ParamsState // nested params for MGF1, etc. -} +const ( + oidSHA1 = "1.3.14.3.2.26" + oidMGF1 = "1.2.840.113549.1.1.8" + oidRSAPSS = "1.2.840.113549.1.1.10" + oidRSAOAEP = "1.2.840.113549.1.1.7" + oidPSpecified = "1.2.840.113549.1.1.9" +) // ParseAlgorithmIDParams parses an AlgorithmIdentifier from DER bytes // and returns the parameters state and OID. @@ -97,13 +62,13 @@ func ParseAlgorithmIDParams(derBytes []byte) ParamsState { } // Parse RSASSA-PSS parameters (OID 1.2.840.113549.1.1.10) - if result.OID == "1.2.840.113549.1.1.10" { + if result.OID == oidRSAPSS { result.PSS = parsePSSParams(params) return result } // Parse RSAES-OAEP parameters (OID 1.2.840.113549.1.1.7) - if result.OID == "1.2.840.113549.1.1.7" { + if result.OID == oidRSAOAEP { result.OAEP = parseOAEPParams(params) return result } @@ -114,8 +79,8 @@ func ParseAlgorithmIDParams(derBytes []byte) ParamsState { // parsePSSParams parses RSASSA-PSS-params from a SEQUENCE. func parsePSSParams(params cryptobyte.String) *PSSParams { result := &PSSParams{ - HashAlgorithm: AlgorithmIdentifier{OID: "1.3.14.3.2.26"}, // sha1 default - MaskGenAlgorithm: AlgorithmIdentifier{OID: "1.2.840.113549.1.1.8", Params: ParamsState{OID: "1.3.14.3.2.26"}}, + HashAlgorithm: AlgorithmIdentifier{OID: oidSHA1}, + MaskGenAlgorithm: AlgorithmIdentifier{OID: oidMGF1, Params: ParamsState{OID: oidSHA1}}, SaltLength: 20, TrailerField: 1, } @@ -125,56 +90,24 @@ func parsePSSParams(params cryptobyte.String) *PSSParams { return result } - // hashAlgorithm [0] EXPLICIT HashAlgorithm OPTIONAL - if !seq.Empty() { - var hashAlgo cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { - result.HashAlgorithmSet = true - if !seq.ReadASN1(&hashAlgo, cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { - return result - } - result.HashAlgorithm = parseNestedAlgorithmIdentifier(hashAlgo) - } + if algo, ok := readExplicitAlgorithmIdentifier(&seq, 0); ok { + result.HashAlgorithmSet = true + result.HashAlgorithm = algo } - // maskGenAlgorithm [1] EXPLICIT MaskGenAlgorithm OPTIONAL - if !seq.Empty() { - var mgfAlgo cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { - result.MaskGenAlgorithmSet = true - if !seq.ReadASN1(&mgfAlgo, cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { - return result - } - result.MaskGenAlgorithm = parseNestedAlgorithmIdentifier(mgfAlgo) - } + if algo, ok := readExplicitAlgorithmIdentifier(&seq, 1); ok { + result.MaskGenAlgorithmSet = true + result.MaskGenAlgorithm = algo } - // saltLength [2] EXPLICIT INTEGER OPTIONAL - if !seq.Empty() { - var saltLen cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { - result.SaltLengthSet = true - if !seq.ReadASN1(&saltLen, cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { - return result - } - if !saltLen.ReadASN1Integer(&result.SaltLength) { - return result - } - } + if value, ok := readExplicitInteger(&seq, 2); ok { + result.SaltLengthSet = true + result.SaltLength = value } - // trailerField [3] EXPLICIT TrailerField OPTIONAL - if !seq.Empty() { - var trailer cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(3).Constructed().ContextSpecific()) { - result.TrailerFieldSet = true - if !seq.ReadASN1(&trailer, cryptobyte_asn1.Tag(3).Constructed().ContextSpecific()) { - return result - } - if !trailer.ReadASN1Integer(&result.TrailerField) { - return result - } - } + if value, ok := readExplicitInteger(&seq, 3); ok { + result.TrailerFieldSet = true + result.TrailerField = value } return result @@ -183,9 +116,9 @@ func parsePSSParams(params cryptobyte.String) *PSSParams { // parseOAEPParams parses RSAES-OAEP-params from a SEQUENCE. func parseOAEPParams(params cryptobyte.String) *OAEPParams { result := &OAEPParams{ - HashAlgorithm: AlgorithmIdentifier{OID: "1.3.14.3.2.26"}, // sha1 default - MaskGenAlgorithm: AlgorithmIdentifier{OID: "1.2.840.113549.1.1.8", Params: ParamsState{OID: "1.3.14.3.2.26"}}, - PSourceAlgorithm: AlgorithmIdentifier{OID: "1.2.840.113549.1.1.9"}, // pSpecified with empty P + HashAlgorithm: AlgorithmIdentifier{OID: oidSHA1}, + MaskGenAlgorithm: AlgorithmIdentifier{OID: oidMGF1, Params: ParamsState{OID: oidSHA1}}, + PSourceAlgorithm: AlgorithmIdentifier{OID: oidPSpecified}, } var seq cryptobyte.String @@ -193,45 +126,63 @@ func parseOAEPParams(params cryptobyte.String) *OAEPParams { return result } - // hashAlgorithm [0] EXPLICIT HashAlgorithm OPTIONAL - if !seq.Empty() { - var hashAlgo cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { - result.HashAlgorithmSet = true - if !seq.ReadASN1(&hashAlgo, cryptobyte_asn1.Tag(0).Constructed().ContextSpecific()) { - return result - } - result.HashAlgorithm = parseNestedAlgorithmIdentifier(hashAlgo) - } + if algo, ok := readExplicitAlgorithmIdentifier(&seq, 0); ok { + result.HashAlgorithmSet = true + result.HashAlgorithm = algo } - // maskGenAlgorithm [1] EXPLICIT MaskGenAlgorithm OPTIONAL - if !seq.Empty() { - var mgfAlgo cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { - result.MaskGenAlgorithmSet = true - if !seq.ReadASN1(&mgfAlgo, cryptobyte_asn1.Tag(1).Constructed().ContextSpecific()) { - return result - } - result.MaskGenAlgorithm = parseNestedAlgorithmIdentifier(mgfAlgo) - } + if algo, ok := readExplicitAlgorithmIdentifier(&seq, 1); ok { + result.MaskGenAlgorithmSet = true + result.MaskGenAlgorithm = algo } - // pSourceAlgorithm [2] EXPLICIT PSourceAlgorithm OPTIONAL - if !seq.Empty() { - var pSource cryptobyte.String - if seq.PeekASN1Tag(cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { - result.PSourceAlgorithmSet = true - if !seq.ReadASN1(&pSource, cryptobyte_asn1.Tag(2).Constructed().ContextSpecific()) { - return result - } - result.PSourceAlgorithm = parseNestedAlgorithmIdentifier(pSource) - } + if algo, ok := readExplicitAlgorithmIdentifier(&seq, 2); ok { + result.PSourceAlgorithmSet = true + result.PSourceAlgorithm = algo } return result } +func readExplicitAlgorithmIdentifier(seq *cryptobyte.String, tag uint) (AlgorithmIdentifier, bool) { + var value cryptobyte.String + present, ok := readExplicit(seq, tag, &value) + if !present { + return AlgorithmIdentifier{}, false + } + if !ok { + return AlgorithmIdentifier{}, true + } + return parseNestedAlgorithmIdentifier(value), true +} + +func readExplicitInteger(seq *cryptobyte.String, tag uint) (int, bool) { + var value cryptobyte.String + present, ok := readExplicit(seq, tag, &value) + if !present { + return 0, false + } + if !ok { + return 0, true + } + var result int + if !value.ReadASN1Integer(&result) { + return 0, true + } + return result, true +} + +func readExplicit(seq *cryptobyte.String, tag uint, out *cryptobyte.String) (present bool, ok bool) { + if seq.Empty() { + return false, false + } + asn1Tag := cryptobyte_asn1.Tag(tag).Constructed().ContextSpecific() + if !seq.PeekASN1Tag(asn1Tag) { + return false, false + } + return true, seq.ReadASN1(out, asn1Tag) +} + // parseNestedAlgorithmIdentifier parses an AlgorithmIdentifier structure. func parseNestedAlgorithmIdentifier(input cryptobyte.String) AlgorithmIdentifier { result := AlgorithmIdentifier{} @@ -266,72 +217,9 @@ func parseNestedAlgorithmIdentifier(input cryptobyte.String) AlgorithmIdentifier } // For MGF1, the parameter is another AlgorithmIdentifier (hash algorithm) - if result.OID == "1.2.840.113549.1.1.8" { // id-mgf1 + if result.OID == oidMGF1 { result.Params = ParseAlgorithmIDParams(params) } return result } - -// oidString converts a cryptobyte OID to standard string format (e.g., "1.2.840.113549.1.1.11") -func oidString(oid cryptobyte.String) string { - var components []int - - // First two components are encoded in first byte - var first byte - if !oid.ReadUint8(&first) { - return "" - } - components = append(components, int(first/40), int(first%40)) - - // Read remaining components (variable length encoding) - for !oid.Empty() { - var val int - if !readOIDComponent(&oid, &val) { - break - } - components = append(components, val) - } - - // Build string representation - result := "" - for i, c := range components { - if i > 0 { - result += "." - } - result += intToStr(c) - } - return result -} - -func readOIDComponent(oid *cryptobyte.String, val *int) bool { - var v int - for { - var b byte - if !oid.ReadUint8(&b) { - return false - } - v = (v << 7) | int(b&0x7f) - if b&0x80 == 0 { - break - } - } - *val = v - return true -} - -func intToStr(n int) string { - if n == 0 { - return "0" - } - var digits []byte - for n > 0 { - digits = append(digits, byte('0'+n%10)) - n /= 10 - } - // Reverse digits - for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { - digits[i], digits[j] = digits[j], digits[i] - } - return string(digits) -} \ No newline at end of file diff --git a/internal/asn1/parser_test.go b/internal/asn1/parser_test.go index 0e4fc76..aaca756 100644 --- a/internal/asn1/parser_test.go +++ b/internal/asn1/parser_test.go @@ -49,8 +49,8 @@ func TestParseAlgorithmIDParams(t *testing.T) { }, }, { - name: "Invalid DER", - der: []byte{0x00, 0x00}, + name: "Invalid DER", + der: []byte{0x00, 0x00}, expected: ParamsState{}, }, } @@ -70,36 +70,3 @@ func TestParseAlgorithmIDParams(t *testing.T) { }) } } - -func TestOIDString(t *testing.T) { - tests := []struct { - name string - oidBytes []byte - expected string - }{ - { - name: "sha256WithRSAEncryption", - oidBytes: []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b}, - expected: "1.2.840.113549.1.1.11", - }, - { - name: "rsaEncryption", - oidBytes: []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01}, - expected: "1.2.840.113549.1.1.1", - }, - { - name: "commonName OID", - oidBytes: []byte{0x55, 0x04, 0x03}, - expected: "2.5.4.3", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := oidString(tt.oidBytes) - if result != tt.expected { - t.Errorf("got %s, want %s", result, tt.expected) - } - }) - } -} \ No newline at end of file diff --git a/internal/asn1/string.go b/internal/asn1/string.go new file mode 100644 index 0000000..85d49cb --- /dev/null +++ b/internal/asn1/string.go @@ -0,0 +1,106 @@ +package asn1 + +type EncodingType int + +const ( + EncodingUnknown EncodingType = iota + EncodingIA5String + EncodingPrintableString + EncodingUTF8String + EncodingBMPString + EncodingUniversalString +) + +type EncodingInfo struct { + Type EncodingType + TagName string + RawBytes []byte + StringValue string + ValidChars bool // whether all characters are valid for the encoding type + InvalidChars []byte // characters that violate encoding rules +} + +// ValidateIA5String validates that a byte sequence conforms to IA5String encoding. +// IA5String is equivalent to ASCII (0x00-0x7F). +func ValidateIA5String(derBytes []byte) (*EncodingInfo, error) { + info := newEncodingInfo(EncodingIA5String, "IA5String", derBytes) + valueBytes, err := readDERValue(derBytes, 22, "IA5String") + if err != nil { + return nil, err + } + info.StringValue = string(valueBytes) + + for _, b := range valueBytes { + if b > 0x7F { + info.ValidChars = false + info.InvalidChars = append(info.InvalidChars, b) + } + } + + return info, nil +} + +// ValidatePrintableString validates that a byte sequence conforms to PrintableString encoding. +// PrintableString allows: A-Z, a-z, 0-9, space, '(),./:=?- and special chars +// Per RFC 5280 Appendix A.1: PrintableString character set +func ValidatePrintableString(derBytes []byte) (*EncodingInfo, error) { + info := newEncodingInfo(EncodingPrintableString, "PrintableString", derBytes) + valueBytes, err := readDERValue(derBytes, 19, "PrintableString") + if err != nil { + return nil, err + } + info.StringValue = string(valueBytes) + + for _, b := range valueBytes { + if !isPrintableStringChar(b) { + info.ValidChars = false + info.InvalidChars = append(info.InvalidChars, b) + } + } + + return info, nil +} + +func newEncodingInfo(encodingType EncodingType, tagName string, derBytes []byte) *EncodingInfo { + return &EncodingInfo{ + Type: encodingType, + TagName: tagName, + RawBytes: derBytes, + ValidChars: true, + } +} + +func isPrintableStringChar(b byte) bool { + if b >= 'A' && b <= 'Z' { + return true + } + if b >= 'a' && b <= 'z' { + return true + } + if b >= '0' && b <= '9' { + return true + } + switch b { + case ' ', '\'', '(', ')', '+', ',', '-', '.', '/', ':', '=', '?', '&', '[', ']', '#', '@', '!', '"', '%', '*', ';', '<', '>', '_', '\\', '{', '}', '|', '~', '^': + return true + } + return false +} + +// GetEncodingType returns the encoding type from ASN.1 tag. +func GetEncodingType(tag int) EncodingType { + switch tag { + case 22: + return EncodingIA5String + case 19: + return EncodingPrintableString + case 12: + return EncodingUTF8String + case 30: + return EncodingBMPString + case 28: + return EncodingUniversalString + default: + return EncodingUnknown + } +} diff --git a/internal/asn1/string_test.go b/internal/asn1/string_test.go new file mode 100644 index 0000000..1708165 --- /dev/null +++ b/internal/asn1/string_test.go @@ -0,0 +1,65 @@ +package asn1 + +import "testing" + +func TestValidateIA5String(t *testing.T) { + info, err := ValidateIA5String([]byte{0x16, 0x03, 'a', 'b', 'c'}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Type != EncodingIA5String || info.TagName != "IA5String" { + t.Fatalf("unexpected encoding info: %+v", info) + } + if info.StringValue != "abc" || !info.ValidChars { + t.Fatalf("unexpected string validation: %+v", info) + } +} + +func TestValidateIA5String_InvalidCharacter(t *testing.T) { + info, err := ValidateIA5String([]byte{0x16, 0x01, 0x80}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.ValidChars { + t.Fatal("expected invalid IA5 character") + } + if len(info.InvalidChars) != 1 || info.InvalidChars[0] != 0x80 { + t.Fatalf("unexpected invalid chars: %v", info.InvalidChars) + } +} + +func TestValidatePrintableString(t *testing.T) { + info, err := ValidatePrintableString([]byte{0x13, 0x05, 'A', 'b', '1', ' ', '?'}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Type != EncodingPrintableString || info.StringValue != "Ab1 ?" || !info.ValidChars { + t.Fatalf("unexpected printable string info: %+v", info) + } +} + +func TestValidatePrintableString_InvalidCharacter(t *testing.T) { + info, err := ValidatePrintableString([]byte{0x13, 0x01, 0x00}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.ValidChars { + t.Fatal("expected invalid PrintableString character") + } +} + +func TestGetEncodingType(t *testing.T) { + tests := map[int]EncodingType{ + 22: EncodingIA5String, + 19: EncodingPrintableString, + 12: EncodingUTF8String, + 30: EncodingBMPString, + 28: EncodingUniversalString, + 99: EncodingUnknown, + } + for tag, want := range tests { + if got := GetEncodingType(tag); got != want { + t.Fatalf("tag %d: got %v, want %v", tag, got, want) + } + } +} diff --git a/internal/asn1/time.go b/internal/asn1/time.go new file mode 100644 index 0000000..3a3ce80 --- /dev/null +++ b/internal/asn1/time.go @@ -0,0 +1,99 @@ +package asn1 + +import ( + stdasn1 "encoding/asn1" + "fmt" + "strings" + "time" +) + +// TimeFormatInfo contains information about ASN.1 time encoding. +type TimeFormatInfo struct { + Tag int // ASN.1 tag: 23 for UTCTime, 24 for GeneralizedTime + Format string // Time format string + RawBytes []byte // Raw DER bytes of the time value + RawString string // Raw string representation from DER + IsUTC bool // true for UTCTime, false for GeneralizedTime + HasSeconds bool // whether seconds are present + HasFraction bool // whether fractional seconds are present + HasZulu bool // whether 'Z' suffix is present (required by RFC 5280) +} + +// ParseUTCTime parses UTCTime DER bytes and returns format info. +// UTCTime format: YYMMDDHHMMSSZ (RFC 5280 requires Z suffix) +// Tag: 23 (0x17) +func ParseUTCTime(derBytes []byte) (*TimeFormatInfo, error) { + valueBytes, err := readDERValue(derBytes, 23, "UTCTime") + if err != nil { + return nil, err + } + + info := &TimeFormatInfo{ + Tag: 23, + IsUTC: true, + RawBytes: derBytes, + RawString: string(valueBytes), + HasZulu: strings.HasSuffix(string(valueBytes), "Z"), + HasSeconds: len(valueBytes) >= 12, + } + + var t time.Time + rest, err := stdasn1.UnmarshalWithParams(derBytes, &t, "utctime") + if err != nil { + return nil, fmt.Errorf("failed to parse UTCTime: %w", err) + } + if len(rest) > 0 { + return nil, fmt.Errorf("trailing data in UTCTime") + } + + return info, nil +} + +// ParseGeneralizedTime parses GeneralizedTime DER bytes and returns format info. +// GeneralizedTime format: YYYYMMDDHHMMSSZ or YYYYMMDDHHMMSS.fffZ +// Tag: 24 (0x18) +func ParseGeneralizedTime(derBytes []byte) (*TimeFormatInfo, error) { + valueBytes, err := readDERValue(derBytes, 24, "GeneralizedTime") + if err != nil { + return nil, err + } + + info := &TimeFormatInfo{ + Tag: 24, + IsUTC: false, + RawBytes: derBytes, + RawString: string(valueBytes), + HasSeconds: true, + HasZulu: strings.HasSuffix(string(valueBytes), "Z"), + HasFraction: strings.Contains(string(valueBytes), "."), + } + + var t time.Time + rest, err := stdasn1.UnmarshalWithParams(derBytes, &t, "generalized") + if err != nil { + return nil, fmt.Errorf("failed to parse GeneralizedTime: %w", err) + } + if len(rest) > 0 { + return nil, fmt.Errorf("trailing data in GeneralizedTime") + } + + return info, nil +} + +func readDERValue(derBytes []byte, expectedTag int, name string) ([]byte, error) { + if len(derBytes) < 2 { + return nil, fmt.Errorf("invalid %s: too short", name) + } + + tag := int(derBytes[0]) + if tag != expectedTag { + return nil, fmt.Errorf("invalid %s tag: expected %d, got %d", name, expectedTag, tag) + } + + length := int(derBytes[1]) + if len(derBytes) < 2+length { + return nil, fmt.Errorf("invalid %s: length mismatch", name) + } + + return derBytes[2 : 2+length], nil +} diff --git a/internal/asn1/time_test.go b/internal/asn1/time_test.go new file mode 100644 index 0000000..fdc5c7f --- /dev/null +++ b/internal/asn1/time_test.go @@ -0,0 +1,59 @@ +package asn1 + +import "testing" + +func TestParseUTCTime(t *testing.T) { + der := []byte{0x17, 0x0d} + der = append(der, []byte("250101000000Z")...) + + info, err := ParseUTCTime(der) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.IsUTC { + t.Fatal("expected UTC time") + } + if info.Tag != 23 { + t.Fatalf("expected tag 23, got %d", info.Tag) + } + if info.RawString != "250101000000Z" { + t.Fatalf("expected raw string, got %q", info.RawString) + } + if !info.HasZulu || !info.HasSeconds { + t.Fatalf("expected Z suffix and seconds: %+v", info) + } +} + +func TestParseGeneralizedTime(t *testing.T) { + der := []byte{0x18, 0x11} + der = append(der, []byte("20250101000000.5Z")...) + + info, err := ParseGeneralizedTime(der) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.IsUTC { + t.Fatal("expected generalized time") + } + if info.Tag != 24 { + t.Fatalf("expected tag 24, got %d", info.Tag) + } + if info.RawString != "20250101000000.5Z" { + t.Fatalf("expected raw string, got %q", info.RawString) + } + if !info.HasZulu || !info.HasSeconds || !info.HasFraction { + t.Fatalf("expected Z suffix, seconds, and fraction: %+v", info) + } +} + +func TestReadDERValueErrors(t *testing.T) { + if _, err := readDERValue([]byte{0x17}, 23, "UTCTime"); err == nil { + t.Fatal("expected too short error") + } + if _, err := readDERValue([]byte{0x18, 0x00}, 23, "UTCTime"); err == nil { + t.Fatal("expected wrong tag error") + } + if _, err := readDERValue([]byte{0x17, 0x02, '1'}, 23, "UTCTime"); err == nil { + t.Fatal("expected length mismatch error") + } +} From 3bc1ef9dc14477d91c36c7cb0afd2c7b8d652a6a Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 21:12:26 +0200 Subject: [PATCH 37/44] refactor: ocsp --- internal/asn1/der.go | 74 ++++++ internal/asn1/der_test.go | 51 ++++ internal/cert/chain.go | 11 +- internal/cert/download.go | 20 ++ internal/cert/download_test.go | 39 +++ internal/linter/autofetch.go | 27 +- internal/linter/config.go | 38 +-- internal/linter/debug.go | 25 +- internal/linter/evaluator.go | 4 +- internal/linter/evaluator_test.go | 2 +- internal/linter/filter.go | 8 +- internal/linter/loader.go | 14 +- internal/linter/runner.go | 2 +- internal/linter/runner_test.go | 4 +- internal/ocsp/fetch.go | 75 ++++++ internal/ocsp/fetch_test.go | 27 ++ internal/ocsp/fetcher.go | 342 -------------------------- internal/ocsp/fetcher_test.go | 166 ------------- internal/ocsp/ocsp.go | 87 +++++-- internal/ocsp/ocsp_test.go | 41 +++ internal/ocsp/request.go | 146 +++++++++++ internal/ocsp/request_test.go | 91 +++++++ internal/ocsp/zcrypto/builder.go | 2 +- internal/ocsp/zcrypto/builder_test.go | 28 +-- internal/ocsp/zcrypto/parser.go | 2 +- internal/ocsp/zcrypto/parser_test.go | 2 +- 26 files changed, 708 insertions(+), 620 deletions(-) create mode 100644 internal/asn1/der.go create mode 100644 internal/asn1/der_test.go create mode 100644 internal/ocsp/fetch.go create mode 100644 internal/ocsp/fetch_test.go delete mode 100644 internal/ocsp/fetcher.go delete mode 100644 internal/ocsp/fetcher_test.go create mode 100644 internal/ocsp/request.go create mode 100644 internal/ocsp/request_test.go diff --git a/internal/asn1/der.go b/internal/asn1/der.go new file mode 100644 index 0000000..32f40a2 --- /dev/null +++ b/internal/asn1/der.go @@ -0,0 +1,74 @@ +package asn1 + +import ( + stdasn1 "encoding/asn1" + "fmt" +) + +// EncodeOctetString encodes content as a DER OCTET STRING. +func EncodeOctetString(content []byte) []byte { + return encodeTagged(0x04, content) +} + +// EncodeSequence encodes content as a DER SEQUENCE. +func EncodeSequence(content []byte) []byte { + return encodeTagged(0x30, content) +} + +// EncodeContextSpecificConstructed encodes content as a DER context-specific constructed tag. +func EncodeContextSpecificConstructed(tag int, content []byte) []byte { + return encodeTagged(byte(0xA0|tag), content) +} + +// EncodeObjectIdentifier encodes oid as a DER OBJECT IDENTIFIER. +func EncodeObjectIdentifier(oid stdasn1.ObjectIdentifier) ([]byte, error) { + encoded, err := stdasn1.Marshal(oid) + if err != nil { + return nil, fmt.Errorf("failed to encode object identifier: %w", err) + } + return encoded, nil +} + +// ReadDERLength reads a DER length at pos and returns the length and content start offset. +func ReadDERLength(data []byte, pos int) (int, int, error) { + if pos < 0 || pos >= len(data) { + return 0, 0, fmt.Errorf("invalid DER length offset") + } + if data[pos] < 128 { + return int(data[pos]), pos + 1, nil + } + + lenBytes := int(data[pos] & 0x7F) + if lenBytes == 0 { + return 0, 0, fmt.Errorf("indefinite DER length is not allowed") + } + if pos+lenBytes >= len(data) { + return 0, 0, fmt.Errorf("invalid DER length") + } + + length := 0 + for i := 0; i < lenBytes; i++ { + length = (length << 8) | int(data[pos+1+i]) + } + return length, pos + 1 + lenBytes, nil +} + +func encodeTagged(tag byte, content []byte) []byte { + result := []byte{tag} + result = append(result, encodeDERLength(len(content))...) + result = append(result, content...) + return result +} + +func encodeDERLength(length int) []byte { + if length < 128 { + return []byte{byte(length)} + } + + var result []byte + for length > 0 { + result = append([]byte{byte(length & 0xFF)}, result...) + length >>= 8 + } + return append([]byte{byte(0x80 | len(result))}, result...) +} diff --git a/internal/asn1/der_test.go b/internal/asn1/der_test.go new file mode 100644 index 0000000..6560a73 --- /dev/null +++ b/internal/asn1/der_test.go @@ -0,0 +1,51 @@ +package asn1 + +import ( + stdasn1 "encoding/asn1" + "testing" +) + +func TestEncodeOctetString(t *testing.T) { + got := EncodeOctetString([]byte{0x01, 0x02}) + want := []byte{0x04, 0x02, 0x01, 0x02} + if string(got) != string(want) { + t.Fatalf("got %x, want %x", got, want) + } +} + +func TestEncodeSequence(t *testing.T) { + got := EncodeSequence([]byte{0x05, 0x00}) + want := []byte{0x30, 0x02, 0x05, 0x00} + if string(got) != string(want) { + t.Fatalf("got %x, want %x", got, want) + } +} + +func TestEncodeContextSpecificConstructed(t *testing.T) { + got := EncodeContextSpecificConstructed(2, []byte{0x30, 0x00}) + want := []byte{0xA2, 0x02, 0x30, 0x00} + if string(got) != string(want) { + t.Fatalf("got %x, want %x", got, want) + } +} + +func TestEncodeObjectIdentifier(t *testing.T) { + got, err := EncodeObjectIdentifier(stdasn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []byte{0x06, 0x09, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01, 0x02} + if string(got) != string(want) { + t.Fatalf("got %x, want %x", got, want) + } +} + +func TestReadDERLength(t *testing.T) { + length, contentStart, err := ReadDERLength([]byte{0x30, 0x82, 0x01, 0x00}, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if length != 256 || contentStart != 4 { + t.Fatalf("got length=%d contentStart=%d", length, contentStart) + } +} diff --git a/internal/cert/chain.go b/internal/cert/chain.go index 547b088..f29fb5a 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -11,6 +11,10 @@ import ( ) func LoadCertificates(path string) ([]*Info, error) { + return LoadCertificatesWithSource(path, source.Info{Type: source.Local}) +} + +func LoadCertificatesWithSource(path string, sourceInfo source.Info) ([]*Info, error) { files, err := GetCertFiles(path) if err != nil { return nil, err @@ -29,11 +33,16 @@ func LoadCertificates(path string) ([]*Info, error) { } hash := sha256.Sum256(cert.Raw) + infoSource := sourceInfo + if infoSource.Type == "" { + infoSource.Type = source.Local + } + infoSource.Format = format infos = append(infos, &Info{ Cert: cert, FilePath: file, Hash: hex.EncodeToString(hash[:]), - Source: source.Info{Type: source.Local, Format: format}, + Source: infoSource, Format: format, }) } diff --git a/internal/cert/download.go b/internal/cert/download.go index 59a5079..a1d01ff 100644 --- a/internal/cert/download.go +++ b/internal/cert/download.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/cavoq/PCL/internal/source" ) func DownloadCertificates(urls []string, timeout time.Duration, saveDir string) (string, func(), error) { @@ -76,6 +78,24 @@ func DownloadCertificates(urls []string, timeout time.Duration, saveDir string) return dir, cleanup, nil } +func DownloadAndLoadCertificates(urls []string, timeout time.Duration, saveDir string) ([]*Info, func(), error) { + dir, cleanup, err := DownloadCertificates(urls, timeout, saveDir) + if err != nil { + return nil, cleanup, err + } + + sourceInfo := source.Info{Type: source.Downloaded} + if len(urls) == 1 { + sourceInfo.URL = urls[0] + } + + certs, err := LoadCertificatesWithSource(dir, sourceInfo) + if err != nil { + return nil, cleanup, err + } + return certs, cleanup, nil +} + var tlsChainFetcher = fetchTLSChain func uniqueFilename(name string, used map[string]bool) string { diff --git a/internal/cert/download_test.go b/internal/cert/download_test.go index 6a05c7f..1968152 100644 --- a/internal/cert/download_test.go +++ b/internal/cert/download_test.go @@ -2,11 +2,14 @@ package cert import ( "crypto/tls" + "encoding/pem" "os" "path/filepath" "strings" "testing" "time" + + "github.com/cavoq/PCL/internal/source" ) func TestDownloadCertificatesWritesFiles(t *testing.T) { @@ -76,3 +79,39 @@ func TestDownloadCertificatesRejectsHTTP(t *testing.T) { t.Fatalf("expected error for http scheme") } } + +func TestDownloadAndLoadCertificatesPreservesSource(t *testing.T) { + pemData, err := os.ReadFile("../../tests/certs/leaf.pem") + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(pemData) + if block == nil { + t.Fatal("expected certificate PEM fixture") + } + + origFetcher := tlsChainFetcher + tlsChainFetcher = func(_ string, _ string, _ time.Duration) ([]*tls.Certificate, error) { + return []*tls.Certificate{{Certificate: [][]byte{block.Bytes}}}, nil + } + defer func() { tlsChainFetcher = origFetcher }() + + certs, cleanup, err := DownloadAndLoadCertificates([]string{"https://example.test"}, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cleanup == nil { + t.Fatal("expected cleanup") + } + defer cleanup() + + if len(certs) != 1 { + t.Fatalf("expected one certificate, got %d", len(certs)) + } + if certs[0].Source.Type != source.Downloaded { + t.Fatalf("expected downloaded source, got %+v", certs[0].Source) + } + if certs[0].Source.URL != "https://example.test" { + t.Fatalf("expected source URL, got %q", certs[0].Source.URL) + } +} diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go index 785bc51..a3b515b 100644 --- a/internal/linter/autofetch.go +++ b/internal/linter/autofetch.go @@ -189,31 +189,18 @@ func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.No return nil, fmt.Errorf("failed to convert certificates to standard format") } - // Fetch OCSP for leaf certificate - fetchResult, url, err := ocsp.FetchOCSPFromChainWithInfo(stdChain, timeout, nonceOpts) - if err != nil { - return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) + url := "" + if len(stdChain[0].OCSPServer) > 0 { + url = stdChain[0].OCSPServer[0] } - if fetchResult == nil { - // No OCSP URL in certificate, not an error + if url == "" { return nil, nil } - info := &ocsp.Info{ - Response: fetchResult.Response, - FilePath: url, // Use URL as "file path" for auto-fetched responses - Source: "downloaded", - } - - // Populate request debug info - if fetchResult.RequestInfo != nil { - info.RequestNonce = fetchResult.RequestInfo.Nonce - info.RequestNonceHex = fetchResult.RequestInfo.NonceHex - info.RequestNonceLen = fetchResult.RequestInfo.NonceLen - info.RequestRawLen = fetchResult.RequestInfo.RequestLen - info.RequestHashAlgorithm = fetchResult.RequestInfo.HashAlgorithm + info, err := ocsp.FetchOCSP(stdChain[0], stdChain[1], url, timeout, nonceOpts) + if err != nil { + return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) } - results = append(results, info) return results, nil diff --git a/internal/linter/config.go b/internal/linter/config.go index e1c093d..c769d23 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -3,27 +3,27 @@ package linter import "time" type Config struct { - PolicyPaths []string // Multiple policy paths - CertPath string - CertURLs []string - CertTimeout time.Duration - CertSaveDir string - IssuerPath string // Single issuer path (backward compatible) - IssuerPaths []string // Multiple issuer paths - IssuerURLs []string - CRLPath string - OCSPPath string - OCSPTimeout time.Duration - OutputFmt string - Verbosity int - ShowMeta bool + PolicyPaths []string // Multiple policy paths + CertPath string + CertURLs []string + CertTimeout time.Duration + CertSaveDir string + IssuerPath string // Single issuer path (backward compatible) + IssuerPaths []string // Multiple issuer paths + IssuerURLs []string + CRLPath string + OCSPPath string + OCSPTimeout time.Duration + OutputFmt string + Verbosity int + ShowMeta bool // Auto-validate mode options - AutoValidate bool // Enable automatic PKI resource fetching (OCSP, CRL, chain climbing) - NoAutoChain bool // Disable chain climbing via CA Issuers URLs - NoAutoCRL bool // Disable CRL fetching from CRL Distribution Points - NoAutoOCSP bool // Disable OCSP fetching for all certificates in chain - MaxChainDepth int // Maximum chain depth for climbing (default 10) + AutoValidate bool // Enable automatic PKI resource fetching (OCSP, CRL, chain climbing) + NoAutoChain bool // Disable chain climbing via CA Issuers URLs + NoAutoCRL bool // Disable CRL fetching from CRL Distribution Points + NoAutoOCSP bool // Disable OCSP fetching for all certificates in chain + MaxChainDepth int // Maximum chain depth for climbing (default 10) // OCSP nonce options (RFC 9654) OCSPNonceLength int // Length of nonce to generate (default 32, per RFC 9654) diff --git a/internal/linter/debug.go b/internal/linter/debug.go index 39ee5f0..1e592b4 100644 --- a/internal/linter/debug.go +++ b/internal/linter/debug.go @@ -19,24 +19,25 @@ func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.No _, _ = fmt.Fprintf(w, " URL: %s\n", ocspInfo.FilePath) // Print request info + requestInfo := ocspInfo.RequestInfo _, _ = fmt.Fprintf(w, " Request:\n") - if ocspInfo.RequestRawLen > 0 { - _, _ = fmt.Fprintf(w, " Length: %d bytes\n", ocspInfo.RequestRawLen) + if requestInfo != nil && requestInfo.RequestLen > 0 { + _, _ = fmt.Fprintf(w, " Length: %d bytes\n", requestInfo.RequestLen) } else { _, _ = fmt.Fprintf(w, " Length: (unknown)\n") } // Print hash algorithm used for CertID - if ocspInfo.RequestHashAlgorithm != "" { - _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", ocspInfo.RequestHashAlgorithm) + if requestInfo != nil && requestInfo.HashAlgorithm != "" { + _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: %s\n", requestInfo.HashAlgorithm) } else { _, _ = fmt.Fprintf(w, " CertID Hash Algorithm: SHA256 (default)\n") } // Print nonce request info - if ocspInfo.RequestNonceLen > 0 { - _, _ = fmt.Fprintf(w, " Nonce Length: %d bytes\n", ocspInfo.RequestNonceLen) - _, _ = fmt.Fprintf(w, " Nonce (hex): %s\n", ocspInfo.RequestNonceHex) + if requestInfo != nil && requestInfo.NonceLen > 0 { + _, _ = fmt.Fprintf(w, " Nonce Length: %d bytes\n", requestInfo.NonceLen) + _, _ = fmt.Fprintf(w, " Nonce (hex): %s\n", requestInfo.NonceHex) } else if nonceOpts != nil && nonceOpts.Disabled { _, _ = fmt.Fprintf(w, " Nonce: disabled\n") } else { @@ -79,17 +80,17 @@ func printOCSPResponseDebug(w io.Writer, ocspInfo *ocsp.Info, nonceOpts *ocsp.No _, _ = fmt.Fprintf(w, " Length: %d bytes\n", nonceState.Length) _, _ = fmt.Fprintf(w, " Value (hex): %s\n", nonceState.HexValue) // Check if nonce matches request - if ocspInfo.RequestNonceLen > 0 && nonceState.Length == ocspInfo.RequestNonceLen { - if nonceState.HexValue == ocspInfo.RequestNonceHex { + if requestInfo != nil && requestInfo.NonceLen > 0 && nonceState.Length == requestInfo.NonceLen { + if nonceState.HexValue == requestInfo.NonceHex { _, _ = fmt.Fprintf(w, " Match: YES (echoed correctly)\n") } else { _, _ = fmt.Fprintf(w, " Match: NO (different value)\n") } - } else if ocspInfo.RequestNonceLen > 0 && nonceState.Length != ocspInfo.RequestNonceLen { - _, _ = fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", ocspInfo.RequestNonceLen, nonceState.Length) + } else if requestInfo != nil && requestInfo.NonceLen > 0 && nonceState.Length != requestInfo.NonceLen { + _, _ = fmt.Fprintf(w, " Match: NO (different length: requested %d, got %d)\n", requestInfo.NonceLen, nonceState.Length) } } else { _, _ = fmt.Fprintf(w, " Present: false\n") } _, _ = fmt.Fprintf(w, "\n") -} \ No newline at end of file +} diff --git a/internal/linter/evaluator.go b/internal/linter/evaluator.go index 256393e..64a5a3f 100644 --- a/internal/linter/evaluator.go +++ b/internal/linter/evaluator.go @@ -85,7 +85,7 @@ func evaluateOCSP(ctx EvaluationContext) []policy.Result { ocspCertInfo := &cert.Info{ FilePath: ocspInfo.FilePath, Type: "ocsp", - Source: source.Info{Description: ocspInfo.Source}, + Source: ocspInfo.Source, } tree := ocspNode @@ -236,7 +236,7 @@ func evaluateOCSPOnly(policies []policy.Policy, registry *operator.Registry, ocs ocspCertInfo := &cert.Info{ FilePath: ocspInfo.FilePath, Type: "ocsp", - Source: source.Info{Description: ocspInfo.Source}, + Source: ocspInfo.Source, } tree := ocspNode diff --git a/internal/linter/evaluator_test.go b/internal/linter/evaluator_test.go index 245876d..fe18bb6 100644 --- a/internal/linter/evaluator_test.go +++ b/internal/linter/evaluator_test.go @@ -76,4 +76,4 @@ func TestEvaluationContextDefaults(t *testing.T) { if evalCtx.OCSPs != nil { t.Errorf("expected nil OCSPs in default context") } -} \ No newline at end of file +} diff --git a/internal/linter/filter.go b/internal/linter/filter.go index 04b2ada..fb2beed 100644 --- a/internal/linter/filter.go +++ b/internal/linter/filter.go @@ -49,10 +49,10 @@ var oidNameMap = map[string]string{ "2.5.29.29": "issuingDistributionPoint", // Built-in cert types - "ca": "ca", - "root": "root", + "ca": "ca", + "root": "root", "intermediate": "intermediate", - "leaf": "leaf", + "leaf": "leaf", } // normalizeOID converts human-readable name to OID or returns the OID if already an OID @@ -352,4 +352,4 @@ func filterPoliciesByCRL(policies []policy.Policy, hasDeltaIndicator bool, isInd } } return filtered -} \ No newline at end of file +} diff --git a/internal/linter/loader.go b/internal/linter/loader.go index 001824f..2c8c0b0 100644 --- a/internal/linter/loader.go +++ b/internal/linter/loader.go @@ -20,17 +20,13 @@ func loadCertificates(cfg Config) ([]*cert.Info, func(), error) { } if len(cfg.CertURLs) > 0 { - dir, tempCleanup, err := cert.DownloadCertificates(cfg.CertURLs, cfg.CertTimeout, cfg.CertSaveDir) + loaded, tempCleanup, err := cert.DownloadAndLoadCertificates(cfg.CertURLs, cfg.CertTimeout, cfg.CertSaveDir) if err != nil { return nil, cleanup, fmt.Errorf("failed to download certificates: %w", err) } if tempCleanup != nil { cleanup = tempCleanup } - loaded, err := cert.LoadCertificates(dir) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to load downloaded certificates: %w", err) - } certs = append(certs, loaded...) } @@ -55,17 +51,13 @@ func loadIssuers(cfg Config, existingCleanup func()) ([]*cert.Info, func(), erro } if len(cfg.IssuerURLs) > 0 { - dir, tempCleanup, err := cert.DownloadCertificates(cfg.IssuerURLs, cfg.CertTimeout, cfg.CertSaveDir) + loaded, tempCleanup, err := cert.DownloadAndLoadCertificates(cfg.IssuerURLs, cfg.CertTimeout, cfg.CertSaveDir) if err != nil { return nil, cleanup, fmt.Errorf("failed to download issuer certificates: %w", err) } if tempCleanup != nil { cleanup = tempCleanup } - loaded, err := cert.LoadCertificates(dir) - if err != nil { - return nil, cleanup, fmt.Errorf("failed to load downloaded issuer certificates: %w", err) - } issuers = append(issuers, loaded...) } @@ -74,4 +66,4 @@ func loadIssuers(cfg Config, existingCleanup func()) ([]*cert.Info, func(), erro } return issuers, cleanup, nil -} \ No newline at end of file +} diff --git a/internal/linter/runner.go b/internal/linter/runner.go index fcffa2f..07c7344 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -286,4 +286,4 @@ func buildNonceOptions(cfg Config) *ocsp.NonceOptions { Disabled: cfg.NoOCSPNonce, Hash: cfg.OCSPHashAlgorithm, } -} \ No newline at end of file +} diff --git a/internal/linter/runner_test.go b/internal/linter/runner_test.go index b5624ad..7a39ecf 100644 --- a/internal/linter/runner_test.go +++ b/internal/linter/runner_test.go @@ -16,7 +16,7 @@ func TestApplyDefaults(t *testing.T) { expected Config }{ { - name: "empty config gets defaults", + name: "empty config gets defaults", input: Config{}, expected: Config{ CertTimeout: 10 * time.Second, @@ -317,4 +317,4 @@ func TestLoadIssuersIfProvided(t *testing.T) { if cleanup != nil { t.Errorf("expected nil cleanup") } -} \ No newline at end of file +} diff --git a/internal/ocsp/fetch.go b/internal/ocsp/fetch.go new file mode 100644 index 0000000..a1a1590 --- /dev/null +++ b/internal/ocsp/fetch.go @@ -0,0 +1,75 @@ +package ocsp + +import ( + "bytes" + "crypto/x509" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/crypto/ocsp" +) + +// FetchOCSP sends an OCSP request and returns the response with source/request metadata. +func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration, nonceOpts *NonceOptions) (*Info, error) { + if err := validateOCSPFetchInput(cert, issuer, url); err != nil { + return nil, err + } + + req, reqInfo, err := buildOCSPRequest(cert, issuer, nonceOpts) + if err != nil { + return nil, err + } + + resp, err := postOCSPRequest(url, req, timeout) + if err != nil { + return nil, err + } + + return infoFromDownloadedResponse(resp, reqInfo, url), nil +} + +func validateOCSPFetchInput(cert, issuer *x509.Certificate, url string) error { + if cert == nil { + return fmt.Errorf("certificate is required") + } + if issuer == nil { + return fmt.Errorf("issuer certificate is required for OCSP request") + } + if url == "" { + return fmt.Errorf("OCSP URL is required") + } + return nil +} + +func postOCSPRequest(url string, req []byte, timeout time.Duration) (*ocsp.Response, error) { + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(req)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/ocsp-request") + httpReq.Header.Set("Accept", "application/ocsp-response") + + client := &http.Client{Timeout: timeout} + httpResp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send OCSP request: %w", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OCSP server returned status %d", httpResp.StatusCode) + } + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OCSP response: %w", err) + } + + resp, err := ocsp.ParseResponse(body, nil) + if err != nil { + return nil, fmt.Errorf("failed to parse OCSP response: %w", err) + } + return resp, nil +} diff --git a/internal/ocsp/fetch_test.go b/internal/ocsp/fetch_test.go new file mode 100644 index 0000000..e44d780 --- /dev/null +++ b/internal/ocsp/fetch_test.go @@ -0,0 +1,27 @@ +package ocsp + +import ( + "crypto/x509" + "testing" +) + +func TestFetchOCSP_NilCert(t *testing.T) { + _, err := FetchOCSP(nil, &x509.Certificate{}, "http://example.com", 5, nil) + if err == nil { + t.Error("Expected error for nil cert") + } +} + +func TestFetchOCSP_NilIssuer(t *testing.T) { + _, err := FetchOCSP(&x509.Certificate{}, nil, "http://example.com", 5, nil) + if err == nil { + t.Error("Expected error for nil issuer") + } +} + +func TestFetchOCSP_EmptyURL(t *testing.T) { + _, err := FetchOCSP(&x509.Certificate{}, &x509.Certificate{}, "", 5, nil) + if err == nil { + t.Error("Expected error for empty URL") + } +} diff --git a/internal/ocsp/fetcher.go b/internal/ocsp/fetcher.go deleted file mode 100644 index bcfa10b..0000000 --- a/internal/ocsp/fetcher.go +++ /dev/null @@ -1,342 +0,0 @@ -package ocsp - -import ( - "bytes" - "crypto" - "crypto/rand" - "crypto/x509" - "encoding/hex" - "fmt" - "io" - "net/http" - "time" - - "golang.org/x/crypto/ocsp" -) - -// Nonce OID: id-pkix-ocsp-nonce (1.3.6.1.5.5.7.48.1.2) -const nonceOID = "1.3.6.1.5.5.7.48.1.2" - -// NonceOptions configures nonce in OCSP requests (RFC 9654). -type NonceOptions struct { - Length int // Length of nonce to generate (default 32, per RFC 9654) - Value string // Custom nonce value in hex format (optional) - Disabled bool // Disable nonce in requests - Hash string // Hash algorithm for CertID: "sha1" or "sha256" (default) -} - -// encodeOctetString wraps content in OCTET STRING tag (04). -func encodeOctetString(content []byte) []byte { - return encodeTagged(0x04, content) -} - -// encodeContextTagged wraps content in context-specific tag. -func encodeContextTagged(tag int, content []byte) []byte { - return encodeTagged(byte(0xA0|tag), content) // context-specific, constructed -} - -// encodeTagged wraps content with tag and length. -func encodeTagged(tag byte, content []byte) []byte { - length := len(content) - var result []byte - result = append(result, tag) - if length < 128 { - result = append(result, byte(length)) - } else { - // Long form length encoding - lenBytes := encodeLengthBytes(length) - result = append(result, byte(0x80|len(lenBytes))) - result = append(result, lenBytes...) - } - result = append(result, content...) - return result -} - -// encodeLengthBytes encodes length as bytes for long form. -func encodeLengthBytes(length int) []byte { - var result []byte - for length > 0 { - result = append([]byte{byte(length & 0xFF)}, result...) - length >>= 8 - } - return result -} - -// encodeOID encodes an OID string like "1.3.6.1.5.5.7.48.1.2" to DER bytes. -func encodeOID(oid string) []byte { - // Pre-encoded nonce OID: 06 09 2B 06 01 05 05 07 30 01 02 - // OID 1.3.6.1.5.5.7.48.1.2 - return []byte{0x06, 0x09, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01, 0x02} -} - -// encodeSequence wraps content in SEQUENCE tag (30). -func encodeSequence(content []byte) []byte { - return encodeTagged(0x30, content) -} - -// generateNonce generates a random nonce of specified length. -func generateNonce(length int) ([]byte, error) { - nonce := make([]byte, length) - if _, err := rand.Read(nonce); err != nil { - return nil, fmt.Errorf("failed to generate nonce: %w", err) - } - return nonce, nil -} - -// parseNonceHex parses a hex string to bytes. -func parseNonceHex(hexValue string) ([]byte, error) { - return hex.DecodeString(hexValue) -} - -// FetchResult contains OCSP response and request debug info. -type FetchResult struct { - Response *ocsp.Response - RequestInfo *RequestInfo -} - -// RequestInfo contains OCSP request debug information. -type RequestInfo struct { - Nonce []byte // Nonce sent in request (nil if no nonce) - NonceHex string // Hex representation of nonce - NonceLen int // Length of nonce in request (0 if no nonce) - RequestLen int // Length of raw OCSP request bytes - HashAlgorithm string // Hash algorithm used for CertID (e.g., "SHA256") -} - -// GetOCSPURLFromCert extracts OCSP URL from certificate's AIA extension. -// Returns empty string if no OCSP URL is present. -func GetOCSPURLFromCert(cert *x509.Certificate) string { - if cert == nil || len(cert.OCSPServer) == 0 { - return "" - } - return cert.OCSPServer[0] -} - -// FetchOCSP sends an OCSP request to the specified URL and returns the response. -// Requires issuer certificate to compute IssuerNameHash and IssuerKeyHash per RFC 6960. -// The response is parsed but signature is not verified (nil issuer passed to ParseResponse). -// Signature verification should be done by operators (ocspValid operator). -// nonceOpts configures nonce extension in request (RFC 9654) and hash algorithm for CertID. -func FetchOCSPWithInfo(cert, issuer *x509.Certificate, url string, timeout time.Duration, nonceOpts *NonceOptions) (*FetchResult, error) { - if cert == nil { - return nil, fmt.Errorf("certificate is required") - } - if issuer == nil { - return nil, fmt.Errorf("issuer certificate is required for OCSP request") - } - if url == "" { - return nil, fmt.Errorf("OCSP URL is required") - } - - // Determine hash algorithm for CertID - var hashAlgorithm crypto.Hash - var hashName string - if nonceOpts != nil && nonceOpts.Hash == "sha1" { - hashAlgorithm = crypto.SHA1 - hashName = "SHA1" - } else { - hashAlgorithm = crypto.SHA256 // Default, modern and more secure - hashName = "SHA256" - } - - // Create OCSP request - req, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{ - Hash: hashAlgorithm, - }) - if err != nil { - return nil, fmt.Errorf("failed to create OCSP request: %w", err) - } - - // Track request info - reqInfo := &RequestInfo{ - RequestLen: len(req), - HashAlgorithm: hashName, - } - - // Add nonce extension if configured - if nonceOpts != nil && !nonceOpts.Disabled { - var nonce []byte - if nonceOpts.Value != "" { - // Use custom nonce value - nonce, err = parseNonceHex(nonceOpts.Value) - if err != nil { - return nil, fmt.Errorf("invalid nonce hex value: %w", err) - } - } else { - // Generate random nonce - length := nonceOpts.Length - if length <= 0 { - length = 32 // Default per RFC 9654 - } - if length < 1 || length > 128 { - return nil, fmt.Errorf("nonce length must be 1-128 bytes (RFC 9654)") - } - nonce, err = generateNonce(length) - if err != nil { - return nil, err - } - } - - // The request from ocsp.CreateRequest is the full OCSPRequest bytes. - // We need to extract TBSRequest and add nonce extension. - // OCSPRequest ::= SEQUENCE { tbsRequest TBSRequest, optionalSignature [0] Signature OPTIONAL } - // The request bytes are the full OCSPRequest SEQUENCE. - // We decode the SEQUENCE to get TBSRequest content. - req, err = addNonceToOCSPRequest(req, nonce) - if err != nil { - return nil, fmt.Errorf("failed to add nonce extension: %w", err) - } - - // Update request info with nonce details - reqInfo.Nonce = nonce - reqInfo.NonceHex = hex.EncodeToString(nonce) - reqInfo.NonceLen = len(nonce) - reqInfo.RequestLen = len(req) - } - - // Send HTTP POST request - client := &http.Client{ - Timeout: timeout, - } - httpReq, err := http.NewRequest("POST", url, bytes.NewReader(req)) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/ocsp-request") - httpReq.Header.Set("Accept", "application/ocsp-response") - - httpResp, err := client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("failed to send OCSP request: %w", err) - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("OCSP server returned status %d", httpResp.StatusCode) - } - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read OCSP response: %w", err) - } - - // Parse response without signature verification - // Signature verification is done by ocspValid operator with chain context - resp, err := ocsp.ParseResponse(body, nil) - if err != nil { - return nil, fmt.Errorf("failed to parse OCSP response: %w", err) - } - - return &FetchResult{ - Response: resp, - RequestInfo: reqInfo, - }, nil -} - -// FetchOCSP is the legacy function that returns just the response. -// Deprecated: Use FetchOCSPWithInfo for debug info support. -func FetchOCSP(cert, issuer *x509.Certificate, url string, timeout time.Duration, nonceOpts *NonceOptions) (*ocsp.Response, error) { - result, err := FetchOCSPWithInfo(cert, issuer, url, timeout, nonceOpts) - if err != nil { - return nil, err - } - return result.Response, nil -} - -// addNonceToOCSPRequest adds nonce extension to an OCSP request. -// The OCSPRequest is SEQUENCE { TBSRequest, [0] optionalSignature } -// We extract TBSRequest content, add nonce extension, and rebuild with correct length. -func addNonceToOCSPRequest(ocspRequest []byte, nonce []byte) ([]byte, error) { - // Decode OCSPRequest SEQUENCE - if len(ocspRequest) < 2 || ocspRequest[0] != 0x30 { - return nil, fmt.Errorf("invalid OCSP request: expected SEQUENCE tag") - } - - // Parse OCSPRequest SEQUENCE length - ocspLen, contentStart := parseDERLength(ocspRequest, 1) - if contentStart+ocspLen > len(ocspRequest) { - return nil, fmt.Errorf("invalid OCSP request: length mismatch") - } - - // TBSRequest is the first element in OCSPRequest - tbsBytes := ocspRequest[contentStart:contentStart+ocspLen] - - // Verify TBSRequest is SEQUENCE - if len(tbsBytes) < 2 || tbsBytes[0] != 0x30 { - return nil, fmt.Errorf("invalid TBSRequest: expected SEQUENCE tag") - } - - // Extract TBSRequest content (after tag and length) - tbsLen, tbsContentStart := parseDERLength(tbsBytes, 1) - tbsContent := tbsBytes[tbsContentStart:tbsContentStart+tbsLen] - - // Build nonce extension - nonceOctetString := encodeOctetString(nonce) - extensionContent := append(encodeOID(nonceOID), nonceOctetString...) - extension := encodeSequence(extensionContent) - extensions := encodeSequence(extension) - requestExtensions := encodeContextTagged(2, extensions) - - // Append nonce extension to TBSRequest content - newTbsContent := append(tbsContent, requestExtensions...) - - // Rebuild TBSRequest with correct length - newTbsRequest := encodeSequence(newTbsContent) - - // Rebuild OCSPRequest as SEQUENCE { TBSRequest } - return encodeSequence(newTbsRequest), nil -} - -// parseDERLength parses DER length encoding starting at pos. -// Returns the length value and the start position of content. -func parseDERLength(data []byte, pos int) (int, int) { - if data[pos] < 128 { - // Short form: length in single byte - return int(data[pos]), pos + 1 - } - // Long form: length in following bytes - lenBytes := int(data[pos] & 0x7F) - length := 0 - for i := 0; i < lenBytes; i++ { - length = (length << 8) | int(data[pos+1+i]) - } - return length, pos + 1 + lenBytes -} - -// FetchOCSPFromChain automatically fetches OCSP response for the leaf certificate. -// Uses the first OCSP URL from leaf cert's AIA extension and the issuer from chain. -// Returns nil if no OCSP URL is present or chain is insufficient. -// nonceOpts configures nonce extension in request (RFC 9654). -func FetchOCSPFromChainWithInfo(chain []*x509.Certificate, timeout time.Duration, nonceOpts *NonceOptions) (*FetchResult, string, error) { - if len(chain) < 2 { - return nil, "", fmt.Errorf("chain must have at least 2 certificates (leaf + issuer)") - } - - leaf := chain[0] - issuer := chain[1] - - url := GetOCSPURLFromCert(leaf) - if url == "" { - return nil, "", nil // No OCSP URL, not an error - } - - result, err := FetchOCSPWithInfo(leaf, issuer, url, timeout, nonceOpts) - if err != nil { - return nil, url, err - } - - return result, url, nil -} - -// FetchOCSPFromChain is the legacy function. -// Deprecated: Use FetchOCSPFromChainWithInfo for debug info support. -func FetchOCSPFromChain(chain []*x509.Certificate, timeout time.Duration, nonceOpts *NonceOptions) (*ocsp.Response, string, error) { - result, url, err := FetchOCSPFromChainWithInfo(chain, timeout, nonceOpts) - if err != nil { - return nil, url, err - } - if result == nil { - return nil, url, nil - } - return result.Response, url, nil -} \ No newline at end of file diff --git a/internal/ocsp/fetcher_test.go b/internal/ocsp/fetcher_test.go deleted file mode 100644 index b1c1a6b..0000000 --- a/internal/ocsp/fetcher_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package ocsp - -import ( - "bytes" - "crypto/x509" - "testing" -) - -func TestGetOCSPURLFromCert_NilCert(t *testing.T) { - url := GetOCSPURLFromCert(nil) - if url != "" { - t.Errorf("Expected empty URL for nil cert, got %s", url) - } -} - -func TestGetOCSPURLFromCert_NoOCSPServer(t *testing.T) { - cert := &x509.Certificate{ - OCSPServer: nil, - } - url := GetOCSPURLFromCert(cert) - if url != "" { - t.Errorf("Expected empty URL for cert with no OCSP server, got %s", url) - } -} - -func TestGetOCSPURLFromCert_EmptyOCSPServer(t *testing.T) { - cert := &x509.Certificate{ - OCSPServer: []string{}, - } - url := GetOCSPURLFromCert(cert) - if url != "" { - t.Errorf("Expected empty URL for cert with empty OCSP server list, got %s", url) - } -} - -func TestGetOCSPURLFromCert_SingleOCSPServer(t *testing.T) { - cert := &x509.Certificate{ - OCSPServer: []string{"http://ocsp.example.com"}, - } - url := GetOCSPURLFromCert(cert) - if url != "http://ocsp.example.com" { - t.Errorf("Expected http://ocsp.example.com, got %s", url) - } -} - -func TestGetOCSPURLFromCert_MultipleOCSPServers(t *testing.T) { - cert := &x509.Certificate{ - OCSPServer: []string{"http://ocsp1.example.com", "http://ocsp2.example.com"}, - } - url := GetOCSPURLFromCert(cert) - // Should return the first URL - if url != "http://ocsp1.example.com" { - t.Errorf("Expected http://ocsp1.example.com, got %s", url) - } -} - -func TestFetchOCSP_NilCert(t *testing.T) { - _, err := FetchOCSP(nil, &x509.Certificate{}, "http://example.com", 5, nil) - if err == nil { - t.Error("Expected error for nil cert") - } -} - -func TestFetchOCSP_NilIssuer(t *testing.T) { - _, err := FetchOCSP(&x509.Certificate{}, nil, "http://example.com", 5, nil) - if err == nil { - t.Error("Expected error for nil issuer") - } -} - -func TestFetchOCSP_EmptyURL(t *testing.T) { - _, err := FetchOCSP(&x509.Certificate{}, &x509.Certificate{}, "", 5, nil) - if err == nil { - t.Error("Expected error for empty URL") - } -} - -func TestFetchOCSPFromChain_TooShort(t *testing.T) { - chain := []*x509.Certificate{&x509.Certificate{}} - _, _, err := FetchOCSPFromChain(chain, 5, nil) - if err == nil { - t.Error("Expected error for chain with less than 2 certificates") - } -} - -func TestFetchOCSPFromChain_EmptyChain(t *testing.T) { - chain := []*x509.Certificate{} - _, _, err := FetchOCSPFromChain(chain, 5, nil) - if err == nil { - t.Error("Expected error for empty chain") - } -} - -func TestFetchOCSPFromChain_NoOCSPURL(t *testing.T) { - chain := []*x509.Certificate{ - &x509.Certificate{OCSPServer: nil}, - &x509.Certificate{}, - } - resp, url, err := FetchOCSPFromChain(chain, 5, nil) - if err != nil { - t.Errorf("Expected no error for cert without OCSP URL, got %v", err) - } - if resp != nil { - t.Error("Expected nil response for cert without OCSP URL") - } - if url != "" { - t.Errorf("Expected empty URL for cert without OCSP URL, got %s", url) - } -} - -func TestNonceOptions_GenerateNonce(t *testing.T) { - nonce, err := generateNonce(32) - if err != nil { - t.Errorf("Failed to generate nonce: %v", err) - } - if len(nonce) != 32 { - t.Errorf("Expected nonce length 32, got %d", len(nonce)) - } -} - -func TestNonceOptions_ParseNonceHex(t *testing.T) { - hexValue := "aabbccdd12345678" - nonce, err := parseNonceHex(hexValue) - if err != nil { - t.Errorf("Failed to parse nonce hex: %v", err) - } - expected := []byte{0xaa, 0xbb, 0xcc, 0xdd, 0x12, 0x34, 0x56, 0x78} - if !bytes.Equal(nonce, expected) { - t.Errorf("Expected %v, got %v", expected, nonce) - } -} - -func TestAddNonceExtension(t *testing.T) { - // Create a minimal OCSPRequest for testing - // OCSPRequest ::= SEQUENCE { TBSRequest } - // TBSRequest ::= SEQUENCE { requestList } - // requestList ::= SEQUENCE OF Request - - // Minimal TBSRequest content (just requestList) - requestList := encodeSequence(encodeSequence([]byte{})) - tbsRequest := encodeSequence(requestList) - ocspRequest := encodeSequence(tbsRequest) - - nonce := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} - - result, err := addNonceToOCSPRequest(ocspRequest, nonce) - if err != nil { - t.Errorf("Failed to add nonce extension: %v", err) - } - - // Verify the result is valid DER - if len(result) < len(ocspRequest)+10 { - t.Errorf("Result too short, nonce extension may not be added properly") - } - - // Check that result starts with SEQUENCE tag (0x30) - if result[0] != 0x30 { - t.Errorf("Expected SEQUENCE tag (0x30), got 0x%02x", result[0]) - } - - // Verify it can be parsed back - _, contentStart := parseDERLength(result, 1) - if contentStart >= len(result) { - t.Errorf("Invalid result structure") - } -} \ No newline at end of file diff --git a/internal/ocsp/ocsp.go b/internal/ocsp/ocsp.go index dd050ad..6241b48 100644 --- a/internal/ocsp/ocsp.go +++ b/internal/ocsp/ocsp.go @@ -1,13 +1,16 @@ package ocsp import ( + "crypto/sha256" + "encoding/hex" "encoding/pem" "fmt" + "os" "golang.org/x/crypto/ocsp" "github.com/cavoq/PCL/internal/io" - "github.com/cavoq/PCL/internal/loader" + "github.com/cavoq/PCL/internal/source" ) var extensions = []string{".ocsp", ".der", ".pem"} @@ -16,27 +19,33 @@ type Info struct { Response *ocsp.Response FilePath string Hash string - Source string // Source description: "local", "downloaded", etc. + Source source.Info + Format source.Format // Request debug info (populated when auto-fetching) - RequestNonce []byte // Nonce sent in request - RequestNonceHex string // Hex representation of nonce - RequestNonceLen int // Length of nonce in request - RequestRawLen int // Length of raw OCSP request bytes - RequestHashAlgorithm string // Hash algorithm used for CertID (e.g., "SHA256") + RequestInfo *RequestInfo } func ParseOCSP(data []byte) (*ocsp.Response, error) { + resp, _, err := parseOCSP(data) + return resp, err +} + +func parseOCSP(data []byte) (*ocsp.Response, source.Format, error) { block, _ := pem.Decode(data) if block != nil && block.Type == "OCSP RESPONSE" { - return ocsp.ParseResponse(block.Bytes, nil) + resp, err := ocsp.ParseResponse(block.Bytes, nil) + if err != nil { + return nil, "", err + } + return resp, source.FormatPEM, nil } resp, err := ocsp.ParseResponse(data, nil) if err != nil { - return nil, fmt.Errorf("failed to parse PEM or DER OCSP response: %w", err) + return nil, "", fmt.Errorf("failed to parse PEM or DER OCSP response: %w", err) } - return resp, nil + return resp, source.FormatDER, nil } func GetOCSPFiles(path string) ([]string, error) { @@ -44,23 +53,57 @@ func GetOCSPFiles(path string) ([]string, error) { } func GetOCSPs(path string) ([]*Info, error) { - results, err := loader.LoadAll( - path, - extensions, - ParseOCSP, - func(resp *ocsp.Response) []byte { return resp.Raw }, - ) + files, err := GetOCSPFiles(path) if err != nil { return nil, err } - infos := make([]*Info, len(results)) - for i, r := range results { - infos[i] = &Info{ - Response: r.Data, - FilePath: r.FilePath, - Hash: r.Hash, + infos := make([]*Info, 0, len(files)) + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + continue + } + + resp, format, err := parseOCSP(data) + if err != nil { + continue } + + hash := sha256.Sum256(resp.Raw) + infos = append(infos, &Info{ + Response: resp, + FilePath: file, + Hash: hex.EncodeToString(hash[:]), + Source: source.Info{Type: source.Local, Format: format}, + Format: format, + }) + } + + if len(infos) == 0 && len(files) > 0 { + return nil, fmt.Errorf("no valid items found in %s", path) } return infos, nil } + +func infoFromDownloadedResponse(resp *ocsp.Response, requestInfo *RequestInfo, url string) *Info { + if resp == nil { + return nil + } + + info := &Info{ + Response: resp, + FilePath: url, + Source: source.Info{Type: source.Downloaded, URL: url, Format: source.FormatDER}, + Format: source.FormatDER, + } + + if len(resp.Raw) > 0 { + hash := sha256.Sum256(resp.Raw) + info.Hash = hex.EncodeToString(hash[:]) + } + + info.RequestInfo = requestInfo + + return info +} diff --git a/internal/ocsp/ocsp_test.go b/internal/ocsp/ocsp_test.go index 4e44b07..1626e61 100644 --- a/internal/ocsp/ocsp_test.go +++ b/internal/ocsp/ocsp_test.go @@ -4,6 +4,10 @@ import ( "os" "path/filepath" "testing" + + xocsp "golang.org/x/crypto/ocsp" + + "github.com/cavoq/PCL/internal/source" ) func TestParseOCSP_Invalid(t *testing.T) { @@ -139,3 +143,40 @@ func TestGetOCSPs_SkipsInvalidFiles(t *testing.T) { t.Fatal("expected error when all OCSP files are invalid") } } + +func TestInfoFromDownloadedResponse(t *testing.T) { + response := &xocsp.Response{Raw: []byte{1, 2, 3}} + requestInfo := &RequestInfo{ + Nonce: []byte{0xaa}, + NonceHex: "aa", + NonceLen: 1, + RequestLen: 42, + HashAlgorithm: "SHA256", + } + + info := infoFromDownloadedResponse(response, requestInfo, "http://ocsp.example.test") + if info == nil { + t.Fatal("expected info") + } + if info.Response != response { + t.Fatal("expected response to be copied") + } + if info.FilePath != "http://ocsp.example.test" { + t.Fatalf("expected URL as file path, got %q", info.FilePath) + } + if info.Source.Type != source.Downloaded || info.Source.URL != "http://ocsp.example.test" { + t.Fatalf("unexpected source: %+v", info.Source) + } + if info.Format != source.FormatDER || info.Source.Format != source.FormatDER { + t.Fatalf("unexpected format: info=%q source=%q", info.Format, info.Source.Format) + } + if info.Hash == "" { + t.Fatal("expected hash") + } + if info.RequestInfo == nil { + t.Fatal("expected request info") + } + if info.RequestInfo.NonceHex != "aa" || info.RequestInfo.NonceLen != 1 || info.RequestInfo.RequestLen != 42 || info.RequestInfo.HashAlgorithm != "SHA256" { + t.Fatalf("request metadata not copied: %+v", info) + } +} diff --git a/internal/ocsp/request.go b/internal/ocsp/request.go new file mode 100644 index 0000000..0ce2654 --- /dev/null +++ b/internal/ocsp/request.go @@ -0,0 +1,146 @@ +package ocsp + +import ( + "crypto" + "crypto/rand" + "crypto/x509" + stdasn1 "encoding/asn1" + "encoding/hex" + "fmt" + + der "github.com/cavoq/PCL/internal/asn1" + "golang.org/x/crypto/ocsp" +) + +var nonceOID = stdasn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2} + +// NonceOptions configures nonce in OCSP requests (RFC 9654). +type NonceOptions struct { + Length int // Length of nonce to generate (default 32, per RFC 9654) + Value string // Custom nonce value in hex format (optional) + Disabled bool // Disable nonce in requests + Hash string // Hash algorithm for CertID: "sha1" or "sha256" (default) +} + +// RequestInfo contains OCSP request debug information. +type RequestInfo struct { + Nonce []byte // Nonce sent in request (nil if no nonce) + NonceHex string // Hex representation of nonce + NonceLen int // Length of nonce in request (0 if no nonce) + RequestLen int // Length of raw OCSP request bytes + HashAlgorithm string // Hash algorithm used for CertID (e.g., "SHA256") +} + +func buildOCSPRequest(cert, issuer *x509.Certificate, nonceOpts *NonceOptions) ([]byte, *RequestInfo, error) { + hashAlgorithm, hashName := certIDHash(nonceOpts) + req, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: hashAlgorithm}) + if err != nil { + return nil, nil, fmt.Errorf("failed to create OCSP request: %w", err) + } + + reqInfo := &RequestInfo{ + RequestLen: len(req), + HashAlgorithm: hashName, + } + + if nonceOpts == nil || nonceOpts.Disabled { + return req, reqInfo, nil + } + + nonce, err := nonceBytes(nonceOpts) + if err != nil { + return nil, nil, err + } + + req, err = addNonceToOCSPRequest(req, nonce) + if err != nil { + return nil, nil, fmt.Errorf("failed to add nonce extension: %w", err) + } + + reqInfo.Nonce = nonce + reqInfo.NonceHex = hex.EncodeToString(nonce) + reqInfo.NonceLen = len(nonce) + reqInfo.RequestLen = len(req) + + return req, reqInfo, nil +} + +func certIDHash(nonceOpts *NonceOptions) (crypto.Hash, string) { + if nonceOpts != nil && nonceOpts.Hash == "sha1" { + return crypto.SHA1, "SHA1" + } + return crypto.SHA256, "SHA256" +} + +func nonceBytes(opts *NonceOptions) ([]byte, error) { + if opts.Value != "" { + nonce, err := parseNonceHex(opts.Value) + if err != nil { + return nil, fmt.Errorf("invalid nonce hex value: %w", err) + } + return nonce, nil + } + + length := opts.Length + if length <= 0 { + length = 32 + } + if length < 1 || length > 128 { + return nil, fmt.Errorf("nonce length must be 1-128 bytes (RFC 9654)") + } + return generateNonce(length) +} + +func generateNonce(length int) ([]byte, error) { + nonce := make([]byte, length) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + return nonce, nil +} + +func parseNonceHex(hexValue string) ([]byte, error) { + return hex.DecodeString(hexValue) +} + +func addNonceToOCSPRequest(ocspRequest []byte, nonce []byte) ([]byte, error) { + if len(ocspRequest) < 2 || ocspRequest[0] != 0x30 { + return nil, fmt.Errorf("invalid OCSP request: expected SEQUENCE tag") + } + + ocspLen, contentStart, err := der.ReadDERLength(ocspRequest, 1) + if err != nil { + return nil, fmt.Errorf("invalid OCSP request: %w", err) + } + if contentStart+ocspLen > len(ocspRequest) { + return nil, fmt.Errorf("invalid OCSP request: length mismatch") + } + + tbsBytes := ocspRequest[contentStart : contentStart+ocspLen] + if len(tbsBytes) < 2 || tbsBytes[0] != 0x30 { + return nil, fmt.Errorf("invalid TBSRequest: expected SEQUENCE tag") + } + + tbsLen, tbsContentStart, err := der.ReadDERLength(tbsBytes, 1) + if err != nil { + return nil, fmt.Errorf("invalid TBSRequest: %w", err) + } + if tbsContentStart+tbsLen > len(tbsBytes) { + return nil, fmt.Errorf("invalid TBSRequest: length mismatch") + } + + nonceOIDDER, err := der.EncodeObjectIdentifier(nonceOID) + if err != nil { + return nil, err + } + + tbsContent := tbsBytes[tbsContentStart : tbsContentStart+tbsLen] + nonceOctetString := der.EncodeOctetString(nonce) + extensionContent := append(nonceOIDDER, nonceOctetString...) + extension := der.EncodeSequence(extensionContent) + extensions := der.EncodeSequence(extension) + requestExtensions := der.EncodeContextSpecificConstructed(2, extensions) + + newTbsContent := append(tbsContent, requestExtensions...) + return der.EncodeSequence(der.EncodeSequence(newTbsContent)), nil +} diff --git a/internal/ocsp/request_test.go b/internal/ocsp/request_test.go new file mode 100644 index 0000000..b4a5d9e --- /dev/null +++ b/internal/ocsp/request_test.go @@ -0,0 +1,91 @@ +package ocsp + +import ( + "bytes" + "crypto" + "testing" + + der "github.com/cavoq/PCL/internal/asn1" +) + +func TestGenerateNonce(t *testing.T) { + nonce, err := generateNonce(32) + if err != nil { + t.Errorf("Failed to generate nonce: %v", err) + } + if len(nonce) != 32 { + t.Errorf("Expected nonce length 32, got %d", len(nonce)) + } +} + +func TestParseNonceHex(t *testing.T) { + hexValue := "aabbccdd12345678" + nonce, err := parseNonceHex(hexValue) + if err != nil { + t.Errorf("Failed to parse nonce hex: %v", err) + } + expected := []byte{0xaa, 0xbb, 0xcc, 0xdd, 0x12, 0x34, 0x56, 0x78} + if !bytes.Equal(nonce, expected) { + t.Errorf("Expected %v, got %v", expected, nonce) + } +} + +func TestCertIDHash(t *testing.T) { + hash, name := certIDHash(nil) + if hash != crypto.SHA256 || name != "SHA256" { + t.Fatalf("expected SHA256 default, got %v %s", hash, name) + } + + hash, name = certIDHash(&NonceOptions{Hash: "sha1"}) + if hash != crypto.SHA1 || name != "SHA1" { + t.Fatalf("expected SHA1, got %v %s", hash, name) + } +} + +func TestNonceBytes(t *testing.T) { + nonce, err := nonceBytes(&NonceOptions{Value: "aabb"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(nonce, []byte{0xaa, 0xbb}) { + t.Fatalf("unexpected nonce: %x", nonce) + } + + nonce, err = nonceBytes(&NonceOptions{Length: 4}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nonce) != 4 { + t.Fatalf("expected 4 byte nonce, got %d", len(nonce)) + } + + if _, err := nonceBytes(&NonceOptions{Length: 129}); err == nil { + t.Fatal("expected error for oversized nonce") + } +} + +func TestAddNonceExtension(t *testing.T) { + requestList := der.EncodeSequence(der.EncodeSequence([]byte{})) + tbsRequest := der.EncodeSequence(requestList) + ocspRequest := der.EncodeSequence(tbsRequest) + nonce := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + + result, err := addNonceToOCSPRequest(ocspRequest, nonce) + if err != nil { + t.Errorf("Failed to add nonce extension: %v", err) + } + if len(result) < len(ocspRequest)+10 { + t.Errorf("Result too short, nonce extension may not be added properly") + } + if result[0] != 0x30 { + t.Errorf("Expected SEQUENCE tag (0x30), got 0x%02x", result[0]) + } + + _, contentStart, err := der.ReadDERLength(result, 1) + if err != nil { + t.Fatalf("Failed to parse result length: %v", err) + } + if contentStart >= len(result) { + t.Errorf("Invalid result structure") + } +} diff --git a/internal/ocsp/zcrypto/builder.go b/internal/ocsp/zcrypto/builder.go index 655c404..f8820e5 100644 --- a/internal/ocsp/zcrypto/builder.go +++ b/internal/ocsp/zcrypto/builder.go @@ -197,4 +197,4 @@ func hashString(h crypto.Hash) string { default: return fmt.Sprintf("Unknown(%d)", h) } -} \ No newline at end of file +} diff --git a/internal/ocsp/zcrypto/builder_test.go b/internal/ocsp/zcrypto/builder_test.go index 6e46c33..18b3f6a 100644 --- a/internal/ocsp/zcrypto/builder_test.go +++ b/internal/ocsp/zcrypto/builder_test.go @@ -42,12 +42,12 @@ func TestBuildTree_OCSP_RSA_SHA256(t *testing.T) { // Create responder certificate responderTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(2), - Subject: pkix.Name{CommonName: "Test OCSP Responder"}, - NotBefore: time.Now().Add(-1 * time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test OCSP Responder"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, } issuerCertParsed, err := x509.ParseCertificate(issuerCert) if err != nil { @@ -147,7 +147,7 @@ func TestBuildTree_OCSP_RSA_SHA256(t *testing.T) { if !ok { t.Error("ocsp.signatureAlgorithm.parameters.null not found") } else { - isNull, ok := nullNode.Value.(bool) + isNull, ok := nullNode.Value.(bool) if !ok { t.Error("Null parameter value is not a bool") } else if !isNull { @@ -202,12 +202,12 @@ func TestBuildTree_OCSP_Revoked(t *testing.T) { // Create responder certificate responderTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(2), - Subject: pkix.Name{CommonName: "Test OCSP Responder"}, - NotBefore: time.Now().Add(-1 * time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test OCSP Responder"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, } responderCert, err := x509.CreateCertificate(rand.Reader, responderTemplate, issuerCertParsed, &responderKey.PublicKey, issuerKey) if err != nil { @@ -268,4 +268,4 @@ func TestBuildTree_OCSP_Revoked(t *testing.T) { if !ok { t.Error("ocsp.revocationReason not found for revoked certificate") } -} \ No newline at end of file +} diff --git a/internal/ocsp/zcrypto/parser.go b/internal/ocsp/zcrypto/parser.go index 8b86e67..cca04cf 100644 --- a/internal/ocsp/zcrypto/parser.go +++ b/internal/ocsp/zcrypto/parser.go @@ -294,4 +294,4 @@ func ParseOCSPSignatureAlgorithmParams(rawOCSP []byte) asn1.ParamsState { } return asn1.ParseAlgorithmIDParams(sigAlgo) -} \ No newline at end of file +} diff --git a/internal/ocsp/zcrypto/parser_test.go b/internal/ocsp/zcrypto/parser_test.go index 87a3d6b..83cc822 100644 --- a/internal/ocsp/zcrypto/parser_test.go +++ b/internal/ocsp/zcrypto/parser_test.go @@ -24,4 +24,4 @@ func TestNonceOIDString(t *testing.T) { if nonceOID != expected { t.Errorf("Expected nonceOID=%s, got %s", expected, nonceOID) } -} \ No newline at end of file +} From e2f7fa490389f0c5a16b4d8f024fdce15d961151 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 21:30:48 +0200 Subject: [PATCH 38/44] refactor: autofetching --- internal/aia/issuer.go | 27 +++++ internal/cert/chain.go | 93 ++++++++++++++++ internal/crl/crl.go | 32 +++++- internal/linter/autofetch.go | 207 ----------------------------------- internal/linter/runner.go | 44 ++------ internal/ocsp/fetch.go | 54 +++++++++ 6 files changed, 214 insertions(+), 243 deletions(-) create mode 100644 internal/aia/issuer.go delete mode 100644 internal/linter/autofetch.go diff --git a/internal/aia/issuer.go b/internal/aia/issuer.go new file mode 100644 index 0000000..3e0724a --- /dev/null +++ b/internal/aia/issuer.go @@ -0,0 +1,27 @@ +package aia + +import "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. +func SelectIssuer(child *x509.Certificate, candidates []*x509.Certificate) (*x509.Certificate, bool) { + if child == nil || len(candidates) == 0 { + return nil, false + } + + for _, candidate := range candidates { + if candidate.Subject.String() == child.Issuer.String() { + return candidate, true + } + } + + if len(child.AuthorityKeyId) > 0 { + for _, candidate := range candidates { + if len(candidate.SubjectKeyId) > 0 && string(candidate.SubjectKeyId) == string(child.AuthorityKeyId) { + return candidate, true + } + } + } + + return candidates[0], false +} diff --git a/internal/cert/chain.go b/internal/cert/chain.go index f29fb5a..c4fcab7 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -4,9 +4,12 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "io" "os" "slices" + "time" + "github.com/cavoq/PCL/internal/aia" "github.com/cavoq/PCL/internal/source" ) @@ -110,3 +113,93 @@ func BuildChain(certs []*Info) ([]*Info, error) { return longestChain, nil } + +// ClimbChain recursively fetches issuer certificates via CA Issuers URLs. +func ClimbChain(chain []*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 + + for depth < maxDepth { + top := result[len(result)-1] + if top.Cert == nil || IsSelfSigned(top.Cert) { + break + } + + if len(top.Cert.IssuingCertificateURL) == 0 { + break + } + + url := top.Cert.IssuingCertificateURL[0] + issuerResult, err := aia.FetchCAIssuer(url, timeout) + 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) + } + + result = append(result, &Info{ + Cert: issuerCert, + FilePath: url, + Type: GetCertType(issuerCert, len(result), len(result)+1), + Position: len(result), + Source: sourceInfo, + Format: issuerResult.Source.Format, + }) + + depth++ + } + + RebuildChainMetadata(result) + return result +} + +func RebuildChainMetadata(chain []*Info) { + for i, c := range chain { + c.Position = i + c.Type = GetCertType(c.Cert, i, len(chain)) + } +} + +func warnf(w io.Writer, format string, args ...any) { + if w == nil { + return + } + _, _ = fmt.Fprintf(w, format, args...) +} diff --git a/internal/crl/crl.go b/internal/crl/crl.go index 0833855..d9d5ecd 100644 --- a/internal/crl/crl.go +++ b/internal/crl/crl.go @@ -5,15 +5,15 @@ import ( "encoding/hex" "encoding/pem" "fmt" - stdio "io" + "io" "net/http" "os" "time" + "github.com/cavoq/PCL/internal/cert" + fileio "github.com/cavoq/PCL/internal/io" "github.com/cavoq/PCL/internal/source" "github.com/zmap/zcrypto/x509" - - fileio "github.com/cavoq/PCL/internal/io" ) var extensions = []string{".crl", ".pem"} @@ -105,7 +105,7 @@ func FetchCRL(url string, timeout time.Duration) (*Info, error) { return nil, fmt.Errorf("CRL server returned status %d", resp.StatusCode) } - body, err := stdio.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read CRL response: %w", err) } @@ -140,3 +140,27 @@ func FetchCRLs(urls []string, timeout time.Duration) ([]*Info, []error) { return results, errs } + +func FetchForChain(chain []*cert.Info, timeout time.Duration, w io.Writer) []*Info { + var results []*Info + + for _, c := range chain { + if c.Cert == nil || len(c.Cert.CRLDistributionPoints) == 0 { + continue + } + + for _, url := range c.Cert.CRLDistributionPoints { + fetchResult, err := FetchCRL(url, timeout) + if err != nil { + if w != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) + } + continue + } + + results = append(results, fetchResult) + } + } + + return results +} diff --git a/internal/linter/autofetch.go b/internal/linter/autofetch.go deleted file mode 100644 index a3b515b..0000000 --- a/internal/linter/autofetch.go +++ /dev/null @@ -1,207 +0,0 @@ -package linter - -import ( - certstd "crypto/x509" - "fmt" - "io" - "time" - - "github.com/cavoq/PCL/internal/aia" - "github.com/cavoq/PCL/internal/cert" - "github.com/cavoq/PCL/internal/crl" - "github.com/cavoq/PCL/internal/ocsp" - "github.com/cavoq/PCL/internal/source" - "github.com/cavoq/PCL/internal/zcrypto" - "github.com/zmap/zcrypto/x509" -) - -// climbChain recursively fetches issuer certificates via CA Issuers URLs. -// Starts from the top of the chain and climbs toward root until: -// - Self-signed certificate found (root) -// - Max depth reached -// - No CA Issuers URL found -// - Circular certificate detected -// -// Handles PKCS#7 bundles: extracts all certificates and selects the correct issuer -// by matching Issuer DN and/or AKI extension. -func climbChain(chain []*cert.Info, timeout time.Duration, maxDepth int, w io.Writer) []*cert.Info { - if len(chain) == 0 || maxDepth <= 0 { - return chain - } - - // Track seen certificates to detect circular chains - 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 - - for depth < maxDepth { - // Get the highest certificate in the chain (potential issuer to climb) - top := result[len(result)-1] - if top.Cert == nil { - break - } - - // Check if it's self-signed (root) - if top.Cert.Issuer.String() == top.Cert.Subject.String() { - break - } - - // Check for CA Issuers URL - if len(top.Cert.IssuingCertificateURL) == 0 { - break - } - - // Fetch issuer(s) from first CA Issuers URL (may be PKCS#7 bundle) - url := top.Cert.IssuingCertificateURL[0] - issuerResult, err := aia.FetchCAIssuer(url, timeout) - if err != nil { - _, _ = fmt.Fprintf(w, "Warning: failed to climb chain from %s: %v\n", url, err) - break - } - - // Find the correct issuer certificate from the bundle - // Match by Issuer DN (subject of issuer should match issuer of cert) - var issuerCert *x509.Certificate - for _, cert := range issuerResult.Certs { - // Check if this cert's subject matches the current cert's issuer - if cert.Subject.String() == top.Cert.Issuer.String() { - issuerCert = cert - break - } - } - - // If no exact DN match, try AKI-SKI matching - if issuerCert == nil && len(top.Cert.AuthorityKeyId) > 0 { - for _, cert := range issuerResult.Certs { - if len(cert.SubjectKeyId) > 0 && string(cert.SubjectKeyId) == string(top.Cert.AuthorityKeyId) { - issuerCert = cert - break - } - } - } - - // Fallback: use first certificate if only one, or continue with best guess - if issuerCert == nil { - if len(issuerResult.Certs) == 1 { - issuerCert = issuerResult.Certs[0] - } else { - // Multiple certs with no match - use first as best guess - issuerCert = issuerResult.Certs[0] - _, _ = fmt.Fprintf(w, "Warning: PKCS#7 bundle contains %d certs, no exact issuer match found, using first cert\n", len(issuerResult.Certs)) - } - } - - // Check for circular certificate - if issuerCert.SerialNumber != nil { - serial := issuerCert.SerialNumber.String() - if seen[serial] { - _, _ = fmt.Fprintf(w, "Warning: circular certificate detected at %s\n", url) - break - } - seen[serial] = true - } - - // Add issuer to chain - sourceInfo := issuerResult.Source - switch issuerResult.Source.Format { - case source.FormatPKCS7: - sourceInfo.Type = source.Extracted - sourceInfo.Description = "extracted from PKCS#7" - case source.FormatDER: - case source.FormatPEM: - sourceInfo.Description = "downloaded PEM" - _, _ = fmt.Fprintf(w, "Warning: CA Issuers URL %s returned PEM format (RFC 5280 requires DER/BER)\n", url) - } - issuerInfo := &cert.Info{ - Cert: issuerCert, - FilePath: url, - Type: cert.GetCertType(issuerCert, len(result), len(result)+1), - Position: len(result), - Source: sourceInfo, - Format: issuerResult.Source.Format, - } - result = append(result, issuerInfo) - - depth++ - } - - // Rebuild chain types after climbing is complete - for i, c := range result { - c.Position = i - c.Type = cert.GetCertType(c.Cert, i, len(result)) - } - - return result -} - -// fetchAutoCRL fetches CRLs from CRL Distribution Points for certificates in chain. -func fetchAutoCRL(chain []*cert.Info, timeout time.Duration, w io.Writer) []*crl.Info { - var results []*crl.Info - - for _, c := range chain { - if c.Cert == nil || len(c.Cert.CRLDistributionPoints) == 0 { - continue - } - - for _, url := range c.Cert.CRLDistributionPoints { - fetchResult, err := crl.FetchCRL(url, timeout) - if err != nil { - _, _ = fmt.Fprintf(w, "Warning: failed to fetch CRL from %s: %v\n", url, err) - continue - } - - results = append(results, fetchResult) - } - } - - return results -} - -// fetchAutoOCSP automatically fetches OCSP responses for certificates in the chain. -// For leaf certificates, uses the OCSP URL from AIA extension and issuer from chain. -func fetchAutoOCSP(chain []*cert.Info, timeout time.Duration, nonceOpts *ocsp.NonceOptions) ([]*ocsp.Info, error) { - if len(chain) < 2 { - return nil, fmt.Errorf("chain must have at least 2 certificates for OCSP request") - } - - results := make([]*ocsp.Info, 0, 1) - - // Convert zcrypto certs to standard certs for OCSP request - stdChain := make([]*certstd.Certificate, 0, len(chain)) - for _, c := range chain { - if c.Cert == nil { - continue - } - stdCert, err := zcrypto.ToStdCert(c.Cert) - if err != nil { - continue - } - stdChain = append(stdChain, stdCert) - } - - if len(stdChain) < 2 { - return nil, fmt.Errorf("failed to convert certificates to standard format") - } - - url := "" - if len(stdChain[0].OCSPServer) > 0 { - url = stdChain[0].OCSPServer[0] - } - if url == "" { - return nil, nil - } - - info, err := ocsp.FetchOCSP(stdChain[0], stdChain[1], url, timeout, nonceOpts) - if err != nil { - return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) - } - results = append(results, info) - - return results, nil -} diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 07c7344..483413c 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -160,7 +160,7 @@ func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Reg continue } miniChain := []*cert.Info{c} - miniChain = climbChain(miniChain, cfg.CertTimeout, cfg.MaxChainDepth, w) + miniChain = cert.ClimbChain(miniChain, cfg.CertTimeout, cfg.MaxChainDepth, w) climbedCerts = append(climbedCerts, miniChain...) } allCerts = append(climbedCerts, issuers...) @@ -176,13 +176,22 @@ func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Reg // Auto-validate: fetch CRLs if cfg.AutoValidate && !cfg.NoAutoCRL { - autoCRLs := fetchAutoCRL(chain, cfg.OCSPTimeout, w) + autoCRLs := crl.FetchForChain(chain, cfg.OCSPTimeout, w) crls = append(crls, autoCRLs...) } // Auto-validate: fetch OCSP if cfg.AutoValidate && !cfg.NoAutoOCSP { - ocsps = append(ocsps, fetchAutoOCSPForChain(chain, cfg, nonceOpts, w)...) + autoOCSPs, errs := ocsp.FetchForChain(chain, cfg.OCSPTimeout, nonceOpts) + for _, err := range errs { + _, _ = fmt.Fprintf(w, "Warning: auto OCSP fetch failed for %v\n", err) + } + if cfg.Verbosity >= 2 { + for _, ocspInfo := range autoOCSPs { + printOCSPResponseDebug(w, ocspInfo, nonceOpts) + } + } + ocsps = append(ocsps, autoOCSPs...) } // Evaluate certificates @@ -208,35 +217,6 @@ func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Reg return results, cleanup } -func fetchAutoOCSPForChain(chain []*cert.Info, cfg Config, nonceOpts *ocsp.NonceOptions, w io.Writer) []*ocsp.Info { - var ocsps []*ocsp.Info - - for i := 0; i < len(chain)-1; i++ { - c := chain[i] - if c.Cert == nil || len(c.Cert.OCSPServer) == 0 { - continue - } - - miniChain := []*cert.Info{c, chain[i+1]} - autoOCSPs, err := fetchAutoOCSP(miniChain, cfg.OCSPTimeout, nonceOpts) - if err != nil { - _, _ = fmt.Fprintf(w, "Warning: auto OCSP fetch failed for cert %d: %v\n", i, err) - continue - } - - // Debug output - if cfg.Verbosity >= 2 && len(autoOCSPs) > 0 { - for _, ocspInfo := range autoOCSPs { - printOCSPResponseDebug(w, ocspInfo, nonceOpts) - } - } - - ocsps = append(ocsps, autoOCSPs...) - } - - return ocsps -} - func outputResults(cfg Config, results []policy.Result, w io.Writer) error { outputOpts := output.Options{ ShowPassed: cfg.Verbosity >= 1, diff --git a/internal/ocsp/fetch.go b/internal/ocsp/fetch.go index a1a1590..3ef50c4 100644 --- a/internal/ocsp/fetch.go +++ b/internal/ocsp/fetch.go @@ -8,6 +8,8 @@ import ( "net/http" "time" + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/zcrypto" "golang.org/x/crypto/ocsp" ) @@ -73,3 +75,55 @@ func postOCSPRequest(url string, req []byte, timeout time.Duration) (*ocsp.Respo } return resp, nil } + +func FetchForChain(chain []*cert.Info, timeout time.Duration, nonceOpts *NonceOptions) ([]*Info, []error) { + var results []*Info + var errs []error + + for i := 0; i < len(chain)-1; i++ { + c := chain[i] + if c.Cert == nil || len(c.Cert.OCSPServer) == 0 { + continue + } + + info, err := fetchForPair(c, chain[i+1], timeout, nonceOpts) + if err != nil { + errs = append(errs, fmt.Errorf("cert %d: %w", i, err)) + continue + } + if info != nil { + results = append(results, info) + } + } + + return results, errs +} + +func fetchForPair(c, issuer *cert.Info, timeout time.Duration, nonceOpts *NonceOptions) (*Info, error) { + if issuer == nil || issuer.Cert == nil { + return nil, fmt.Errorf("issuer certificate is required for OCSP request") + } + + stdCert, err := zcrypto.ToStdCert(c.Cert) + if err != nil { + return nil, fmt.Errorf("failed to convert certificate to standard format: %w", err) + } + stdIssuer, err := zcrypto.ToStdCert(issuer.Cert) + if err != nil { + return nil, fmt.Errorf("failed to convert issuer certificate to standard format: %w", err) + } + + url := "" + if len(stdCert.OCSPServer) > 0 { + url = stdCert.OCSPServer[0] + } + if url == "" { + return nil, nil + } + + info, err := FetchOCSP(stdCert, stdIssuer, url, timeout, nonceOpts) + if err != nil { + return nil, fmt.Errorf("failed to fetch OCSP from %s: %w", url, err) + } + return info, nil +} From 44bf6fd05da83b57b06aad62228c1b853f4564c7 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 21:34:18 +0200 Subject: [PATCH 39/44] fix: proper name for debug.go --- internal/linter/{debug.go => ocsp_debug.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/linter/{debug.go => ocsp_debug.go} (100%) diff --git a/internal/linter/debug.go b/internal/linter/ocsp_debug.go similarity index 100% rename from internal/linter/debug.go rename to internal/linter/ocsp_debug.go From 25c0bd4e0f254a5793694ba2272bd1e299958a04 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 22:13:04 +0200 Subject: [PATCH 40/44] refactor: linter --- internal/crl/properties.go | 40 ++++++ internal/evaluator/evaluator.go | 192 +++++++++++++++++++++++++++ internal/evaluator/evaluator_test.go | 77 +++++++++++ internal/linter/filter.go | 85 +----------- internal/oid/oid.go | 62 +++++++++ internal/operator/subject.go | 111 +++++++--------- internal/policy/match/match.go | 189 ++++++++++++++++++++++++++ 7 files changed, 617 insertions(+), 139 deletions(-) create mode 100644 internal/crl/properties.go create mode 100644 internal/evaluator/evaluator.go create mode 100644 internal/evaluator/evaluator_test.go create mode 100644 internal/oid/oid.go create mode 100644 internal/policy/match/match.go diff --git a/internal/crl/properties.go b/internal/crl/properties.go new file mode 100644 index 0000000..69377db --- /dev/null +++ b/internal/crl/properties.go @@ -0,0 +1,40 @@ +package crl + +import ( + "github.com/zmap/zcrypto/x509" + + "github.com/cavoq/PCL/internal/oid" +) + +func HasDeltaIndicator(crl *x509.RevocationList) bool { + if crl == nil { + return false + } + for _, ext := range crl.Extensions { + if ext.Id.String() == oid.DeltaCRLIndicator { + return true + } + } + return false +} + +func IsIndirect(crl *x509.RevocationList) bool { + if crl == nil { + return false + } + for _, ext := range crl.Extensions { + if ext.Id.String() == oid.IssuingDistributionPoint { + return hasIndirectCRLInExtension(ext.Value) + } + } + return false +} + +func hasIndirectCRLInExtension(extValue []byte) bool { + for i := 0; i < len(extValue)-1; i++ { + if extValue[i] == 0x84 && extValue[i+1] == 0x01 && i+2 < len(extValue) { + return extValue[i+2] == 0xff + } + } + return false +} diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go new file mode 100644 index 0000000..b4c39fe --- /dev/null +++ b/internal/evaluator/evaluator.go @@ -0,0 +1,192 @@ +package evaluator + +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/node" + "github.com/cavoq/PCL/internal/ocsp" + ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" + "github.com/cavoq/PCL/internal/operator" + "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/policy/match" + "github.com/cavoq/PCL/internal/source" + "github.com/cavoq/PCL/internal/zcrypto" + "github.com/zmap/zcrypto/x509" +) + +// Context contains all data needed for policy evaluation. +type Context struct { + Policies []policy.Policy + Registry *operator.Registry + CRLs []*crl.Info + OCSPs []*ocsp.Info + Chain []*cert.Info +} + +func Chain(ctx Context) []policy.Result { + var results []policy.Result + + for _, c := range ctx.Chain { + tree := certzcrypto.BuildTree(c.Cert) + + if c.Source.Format != "" && c.Source.Type != source.Local { + tree.Children["downloadFormat"] = node.New("downloadFormat", c.Source.Format) + tree.Children["downloadURL"] = node.New("downloadURL", c.Source.URL) + } + + if len(ctx.CRLs) > 0 { + for _, crlInfo := range ctx.CRLs { + if crlInfo.CRL != nil { + crlNode := crlzcrypto.BuildTree(crlInfo.CRL) + if crlNode != nil { + tree.Children["crl"] = crlNode + } + break + } + } + } + + evalOpts := []operator.ContextOption{ + operator.WithCRLs(ctx.CRLs), + operator.WithOCSPs(ctx.OCSPs), + } + evalCtx := operator.NewEvaluationContext(tree, c, ctx.Chain, evalOpts...) + + filteredPolicies := match.ByCertificate(ctx.Policies, c.Cert) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) + results = append(results, res) + } + } + + return results +} + +func OCSP(ctx Context) []policy.Result { + var results []policy.Result + + for _, ocspInfo := range ctx.OCSPs { + if ocspInfo.Response == nil { + continue + } + + ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) + if ocspNode == nil { + continue + } + + ocspCertInfo := &cert.Info{ + FilePath: ocspInfo.FilePath, + Type: "ocsp", + Source: ocspInfo.Source, + } + + tree := ocspNode + evalOpts := []operator.ContextOption{operator.WithOCSPs(ctx.OCSPs)} + evalCtx := operator.NewEvaluationContext(tree, ocspCertInfo, ctx.Chain, evalOpts...) + + filteredPolicies := match.ByInput(ctx.Policies, match.InputOCSP) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) + results = append(results, res) + } + + if ocspInfo.Response.Certificate != nil { + results = append(results, ocspSigningCert(ctx.Policies, ctx.Registry, ctx.OCSPs, ocspInfo, ctx.Chain)...) + } + } + + return results +} + +func CRL(ctx Context) []policy.Result { + var results []policy.Result + + for _, crlInfo := range ctx.CRLs { + if crlInfo.CRL == nil { + continue + } + + issuerCerts := ExtractCertsFromInfo(ctx.Chain) + + crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) + if crlNode == nil { + continue + } + + crlCertInfo := &cert.Info{ + FilePath: crlInfo.FilePath, + Type: "crl", + Source: crlInfo.Source, + } + + tree := crlNode + evalOpts := []operator.ContextOption{operator.WithCRLs(ctx.CRLs)} + evalCtx := operator.NewEvaluationContext(tree, crlCertInfo, ctx.Chain, evalOpts...) + + filteredPolicies := match.ByCRL(ctx.Policies, crlInfo.CRL) + for _, p := range filteredPolicies { + res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) + results = append(results, res) + } + } + + return results +} + +func CRLOnly(policies []policy.Policy, registry *operator.Registry, crls []*crl.Info, issuers []*cert.Info) []policy.Result { + return CRL(Context{ + Policies: policies, + Registry: registry, + CRLs: crls, + Chain: issuers, + }) +} + +func OCSPOnly(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info) []policy.Result { + return OCSP(Context{ + Policies: policies, + Registry: registry, + OCSPs: ocsps, + }) +} + +func ocspSigningCert(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info, ocspInfo *ocsp.Info, chain []*cert.Info) []policy.Result { + zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) + if err != nil || zcryptoSignerCert == nil { + return nil + } + + ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) + ocspSignerInfo := &cert.Info{ + Cert: zcryptoSignerCert, + FilePath: ocspInfo.FilePath + " (signing cert)", + Type: "ocspSigning", + Source: source.Info{Type: source.Extracted, Description: "extracted from OCSP response"}, + } + + evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} + evalCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, evalOpts...) + + var results []policy.Result + signerPolicies := match.ByCertificate(policies, zcryptoSignerCert) + for _, p := range signerPolicies { + res := policy.Evaluate(p, ocspSignerTree, registry, evalCtx) + results = append(results, res) + } + + return results +} + +// 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 +} diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go new file mode 100644 index 0000000..79570f2 --- /dev/null +++ b/internal/evaluator/evaluator_test.go @@ -0,0 +1,77 @@ +package evaluator + +import ( + "testing" + + "github.com/cavoq/PCL/internal/cert" + "github.com/cavoq/PCL/internal/operator" +) + +func TestChainWithEmptyChain(t *testing.T) { + evalCtx := Context{ + Policies: nil, + Registry: operator.DefaultRegistry(), + CRLs: nil, + OCSPs: nil, + Chain: []*cert.Info{}, + } + + results := Chain(evalCtx) + if len(results) != 0 { + t.Errorf("expected 0 results for empty chain, got %d", len(results)) + } +} + +func TestCRLOnlyWithEmptyCRLs(t *testing.T) { + results := CRLOnly(nil, operator.DefaultRegistry(), nil, nil) + if len(results) != 0 { + t.Errorf("expected 0 results for empty CRLs, got %d", len(results)) + } +} + +func TestOCSPOnlyWithEmptyOCSPs(t *testing.T) { + results := OCSPOnly(nil, operator.DefaultRegistry(), nil) + if len(results) != 0 { + t.Errorf("expected 0 results for empty OCSPs, got %d", len(results)) + } +} + +func TestExtractCertsFromInfoEmpty(t *testing.T) { + certs := ExtractCertsFromInfo(nil) + if len(certs) != 0 { + t.Errorf("expected 0 certs for nil input, got %d", len(certs)) + } + + certs = ExtractCertsFromInfo([]*cert.Info{}) + if len(certs) != 0 { + t.Errorf("expected 0 certs for empty slice, got %d", len(certs)) + } +} + +func TestExtractCertsFromInfoWithNilCert(t *testing.T) { + infos := []*cert.Info{ + {Cert: nil}, + {Cert: nil, FilePath: "test.pem"}, + } + certs := ExtractCertsFromInfo(infos) + if len(certs) != 0 { + t.Errorf("expected 0 certs for nil certs in info, got %d", len(certs)) + } +} + +func TestContextDefaults(t *testing.T) { + evalCtx := Context{} + + if evalCtx.Registry == nil { + t.Log("Registry is nil in default context (expected)") + } + if evalCtx.Chain != nil { + t.Errorf("expected nil Chain in default context") + } + if evalCtx.CRLs != nil { + t.Errorf("expected nil CRLs in default context") + } + if evalCtx.OCSPs != nil { + t.Errorf("expected nil OCSPs in default context") + } +} diff --git a/internal/linter/filter.go b/internal/linter/filter.go index fb2beed..c7bccc9 100644 --- a/internal/linter/filter.go +++ b/internal/linter/filter.go @@ -6,16 +6,11 @@ import ( "github.com/zmap/zcrypto/x509" + "github.com/cavoq/PCL/internal/oid" "github.com/cavoq/PCL/internal/policy" "github.com/cavoq/PCL/internal/rule" ) -// OID constants for CRL extensions -const ( - oidDeltaCRLIndicator = "2.5.29.27" - oidIssuingDistributionPoint = "2.5.29.29" -) - // AppliesTo types - fixed enumeration for PKI input types const ( AppliesToCert = "cert" @@ -26,52 +21,6 @@ const ( AppliesToAttrCert = "attrCert" ) -// OID name mappings for human-readable certType/crlType values -var oidNameMap = map[string]string{ - // Extended Key Usage OIDs - "serverAuth": "1.3.6.1.5.5.7.3.1", - "clientAuth": "1.3.6.1.5.5.7.3.2", - "codeSigning": "1.3.6.1.5.5.7.3.3", - "emailProtection": "1.3.6.1.5.5.7.3.4", - "timeStamping": "1.3.6.1.5.5.7.3.8", - "ocspSigning": "1.3.6.1.5.5.7.3.9", - "1.3.6.1.5.5.7.3.1": "serverAuth", - "1.3.6.1.5.5.7.3.2": "clientAuth", - "1.3.6.1.5.5.7.3.3": "codeSigning", - "1.3.6.1.5.5.7.3.4": "emailProtection", - "1.3.6.1.5.5.7.3.8": "timeStamping", - "1.3.6.1.5.5.7.3.9": "ocspSigning", - - // CRL extension OIDs - "deltaCRLIndicator": "2.5.29.27", - "issuingDistributionPoint": "2.5.29.29", - "2.5.29.27": "deltaCRLIndicator", - "2.5.29.29": "issuingDistributionPoint", - - // Built-in cert types - "ca": "ca", - "root": "root", - "intermediate": "intermediate", - "leaf": "leaf", -} - -// normalizeOID converts human-readable name to OID or returns the OID if already an OID -func normalizeOID(nameOrOID string) string { - if oid, ok := oidNameMap[nameOrOID]; ok { - // If input is a name, return the OID - // Built-in types (ca, root, intermediate, leaf) are not OIDs - if oid == "ca" || oid == "root" || oid == "intermediate" || oid == "leaf" { - return oid - } - // Other names are OID mappings - if len(oid) > 10 { - return oid - } - } - // Input might already be an OID or a built-in type - return nameOrOID -} - // policyAppliesToInput checks if a policy applies to the given input type func policyAppliesToInput(p policy.Policy, inputType string) bool { // If AppliesTo is explicitly set, use it @@ -142,7 +91,7 @@ func policyAppliesToCert(p policy.Policy, cert *x509.Certificate) bool { // Check each certType constraint for _, ct := range p.CertType { - ct = normalizeOID(ct) + ct = oid.NormalizeOID(ct) // Built-in types if ct == "ca" { @@ -174,7 +123,7 @@ func policyAppliesToCert(p policy.Policy, cert *x509.Certificate) bool { // EKU OID check for _, eku := range cert.ExtKeyUsage { - ekuOID := extKeyUsageToOID(eku) + ekuOID := oid.ExtKeyUsageToOID(eku) if ekuOID == ct { return true } @@ -198,9 +147,9 @@ func policyAppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL b // Check each crlType constraint for _, ct := range p.CRLType { - ct = normalizeOID(ct) + ct = oid.NormalizeOID(ct) - if ct == "deltaCRLIndicator" || ct == "2.5.29.27" { + if ct == oid.DeltaCRLIndicator { if hasDeltaIndicator { return true } @@ -245,33 +194,13 @@ func policyAppliesToCRLInput(p policy.Policy) bool { return false } -// extKeyUsageToOID converts x509.ExtKeyUsage to OID string -func extKeyUsageToOID(eku x509.ExtKeyUsage) string { - switch eku { - case x509.ExtKeyUsageServerAuth: - return "1.3.6.1.5.5.7.3.1" - case x509.ExtKeyUsageClientAuth: - return "1.3.6.1.5.5.7.3.2" - case x509.ExtKeyUsageCodeSigning: - return "1.3.6.1.5.5.7.3.3" - case x509.ExtKeyUsageEmailProtection: - return "1.3.6.1.5.5.7.3.4" - case x509.ExtKeyUsageTimeStamping: - return "1.3.6.1.5.5.7.3.8" - case x509.ExtKeyUsageOcspSigning: - return "1.3.6.1.5.5.7.3.9" - default: - return "" - } -} - // hasDeltaCRLIndicator checks if CRL has the delta CRL indicator extension func hasDeltaCRLIndicator(crl *x509.RevocationList) bool { if crl == nil { return false } for _, ext := range crl.Extensions { - if ext.Id.String() == oidDeltaCRLIndicator { + if ext.Id.String() == oid.DeltaCRLIndicator { return true } } @@ -286,7 +215,7 @@ func isIndirectCRL(crl *x509.RevocationList) bool { return false } for _, ext := range crl.Extensions { - if ext.Id.String() == oidIssuingDistributionPoint { + if ext.Id.String() == oid.IssuingDistributionPoint { // The indirectCRL field is a boolean in the IssuingDistributionPoint extension // If the extension is present, we check the raw value for indirectCRL indicator // The ASN.1 structure includes an optional indirectCRL BOOLEAN DEFAULT FALSE diff --git a/internal/oid/oid.go b/internal/oid/oid.go new file mode 100644 index 0000000..fe65b34 --- /dev/null +++ b/internal/oid/oid.go @@ -0,0 +1,62 @@ +package oid + +import "github.com/zmap/zcrypto/x509" + +const ( + // Extended Key Usage OIDs (RFC 5280) + ServerAuth = "1.3.6.1.5.5.7.3.1" + ClientAuth = "1.3.6.1.5.5.7.3.2" + CodeSigning = "1.3.6.1.5.5.7.3.3" + EmailProtection = "1.3.6.1.5.5.7.3.4" + TimeStamping = "1.3.6.1.5.5.7.3.8" + OCSPSigning = "1.3.6.1.5.5.7.3.9" + + // CRL Extension OIDs (RFC 5280) + DeltaCRLIndicator = "2.5.29.27" + IssuingDistributionPoint = "2.5.29.29" +) + +// NormalizeOID converts a friendly name to its OID string. +// If the input is already an OID or a built-in cert type, it is returned unchanged. +func NormalizeOID(nameOrOID string) string { + switch nameOrOID { + case "serverAuth": + return ServerAuth + case "clientAuth": + return ClientAuth + case "codeSigning": + return CodeSigning + case "emailProtection": + return EmailProtection + case "timeStamping": + return TimeStamping + case "ocspSigning": + return OCSPSigning + case "deltaCRLIndicator": + return DeltaCRLIndicator + case "issuingDistributionPoint": + return IssuingDistributionPoint + default: + return nameOrOID + } +} + +// ExtKeyUsageToOID returns the OID string for an x509.ExtKeyUsage value. +func ExtKeyUsageToOID(eku x509.ExtKeyUsage) string { + switch eku { + case x509.ExtKeyUsageServerAuth: + return ServerAuth + case x509.ExtKeyUsageClientAuth: + return ClientAuth + case x509.ExtKeyUsageCodeSigning: + return CodeSigning + case x509.ExtKeyUsageEmailProtection: + return EmailProtection + case x509.ExtKeyUsageTimeStamping: + return TimeStamping + case x509.ExtKeyUsageOcspSigning: + return OCSPSigning + default: + return "" + } +} diff --git a/internal/operator/subject.go b/internal/operator/subject.go index 1696c45..7dfaf45 100644 --- a/internal/operator/subject.go +++ b/internal/operator/subject.go @@ -10,6 +10,47 @@ import ( // duplicate AttributeTypeAndValue instances per CABF BR 7.1.4.1 type NoDuplicateAttributes struct{} +// singleInstanceOIDs maps OID string → attribute name for attributes that must +// appear at most once per CABF BR 7.1.4.1. +var singleInstanceOIDs = map[string]string{ + "2.5.4.3": "commonName", + "2.5.4.4": "surname", + "2.5.4.5": "serialNumber", + "2.5.4.6": "countryName", + "2.5.4.7": "localityName", + "2.5.4.8": "stateOrProvinceName", + "2.5.4.10": "organizationName", + "2.5.4.15": "businessCategory", + "2.5.4.42": "givenName", + "2.5.4.97": "organizationIdentifier", + "1.3.6.1.4.1.311.60.2.1.1": "jurisdictionLocality", + "1.3.6.1.4.1.311.60.2.1.2": "jurisdictionStateOrProvince", + "1.3.6.1.4.1.311.60.2.1.3": "jurisdictionCountry", +} + +// exemptOIDs lists attributes that may appear multiple times (domainComponent, +// streetAddress, and the deprecated organizationalUnitName). +var exemptOIDs = map[string]bool{ + "0.9.2342.19200300.100.1.25": true, + "2.5.4.9": true, + "2.5.4.11": true, +} + +// nameToOID maps friendly attribute names to their OID strings for cases where +// the node carries no explicit OID child. +var nameToOID = map[string]string{ + "commonName": "2.5.4.3", + "surname": "2.5.4.4", + "serialNumber": "2.5.4.5", + "countryName": "2.5.4.6", + "localityName": "2.5.4.7", + "stateOrProvinceName": "2.5.4.8", + "organizationName": "2.5.4.10", + "businessCategory": "2.5.4.15", + "givenName": "2.5.4.42", + "organizationIdentifier": "2.5.4.97", +} + func (NoDuplicateAttributes) Name() string { return "noDuplicateAttributes" } func (NoDuplicateAttributes) Evaluate(n *node.Node, _ *EvaluationContext, _ []any) (bool, error) { @@ -17,82 +58,30 @@ func (NoDuplicateAttributes) Evaluate(n *node.Node, _ *EvaluationContext, _ []an return false, nil } - // Subject DN attributes that must be unique (single instance) - // Per CABF BR 7.1.4.1 and zlint implementation - singleInstanceOIDs := map[string]string{ - "2.5.4.3": "commonName", - "2.5.4.4": "surname", - "2.5.4.5": "serialNumber", - "2.5.4.6": "countryName", - "2.5.4.7": "localityName", - "2.5.4.8": "stateOrProvinceName", - "2.5.4.10": "organizationName", - "2.5.4.15": "businessCategory", - "2.5.4.42": "givenName", - "2.5.4.97": "organizationIdentifier", - "1.3.6.1.4.1.311.60.2.1.1": "jurisdictionLocality", - "1.3.6.1.4.1.311.60.2.1.2": "jurisdictionStateOrProvince", - "1.3.6.1.4.1.311.60.2.1.3": "jurisdictionCountry", - } - - // Attributes exempt from single-instance requirement - // domainComponent and streetAddress can have multiple instances - exemptOIDs := map[string]bool{ - "0.9.2342.19200300.100.1.25": true, // domainComponent (DC) - "2.5.4.9": true, // streetAddress - "2.5.4.11": true, // organizationalUnitName (deprecated but exempt) - } - - // Check children of subject node for duplicates - // Subject node structure: subject.commonName, subject.organizationName, etc. foundOIDs := make(map[string]int) for childName, child := range n.Children { - // childName is the attribute name (e.g., "commonName", "organizationName") - // Check if this attribute appears multiple times - - // Get OID from child if available oidNode := child.Children["oid"] - var oid string + var attrOID string if oidNode != nil && oidNode.Value != nil { - oid = fmt.Sprintf("%v", oidNode.Value) - } - - // If no OID from node, try to map name to OID - if oid == "" { - nameToOID := map[string]string{ - "commonName": "2.5.4.3", - "surname": "2.5.4.4", - "serialNumber": "2.5.4.5", - "countryName": "2.5.4.6", - "localityName": "2.5.4.7", - "stateOrProvinceName": "2.5.4.8", - "organizationName": "2.5.4.10", - "businessCategory": "2.5.4.15", - "givenName": "2.5.4.42", - "organizationIdentifier": "2.5.4.97", - } - oid = nameToOID[childName] + attrOID = fmt.Sprintf("%v", oidNode.Value) } - // Skip if no OID found - if oid == "" { - continue + if attrOID == "" { + attrOID = nameToOID[childName] } - // Skip exempt attributes - if exemptOIDs[oid] { + if attrOID == "" || exemptOIDs[attrOID] { continue } - // Only check single-instance OIDs - if singleInstanceOIDs[oid] != "" { - foundOIDs[oid]++ - if foundOIDs[oid] > 1 { + if singleInstanceOIDs[attrOID] != "" { + foundOIDs[attrOID]++ + if foundOIDs[attrOID] > 1 { return false, nil } } } return true, nil -} \ No newline at end of file +} diff --git a/internal/policy/match/match.go b/internal/policy/match/match.go new file mode 100644 index 0000000..f8ae96f --- /dev/null +++ b/internal/policy/match/match.go @@ -0,0 +1,189 @@ +package match + +import ( + "slices" + "strings" + + "github.com/zmap/zcrypto/x509" + + "github.com/cavoq/PCL/internal/crl" + "github.com/cavoq/PCL/internal/oid" + "github.com/cavoq/PCL/internal/policy" + "github.com/cavoq/PCL/internal/rule" +) + +const ( + InputCert = "cert" + InputCRL = "crl" + InputOCSP = "ocsp" + InputTST = "tst" + InputSCT = "sct" + InputAttrCert = "attrCert" +) + +func ByInput(policies []policy.Policy, inputType string) []policy.Policy { + var filtered []policy.Policy + for _, p := range policies { + if AppliesToInput(p, inputType) { + filtered = append(filtered, p) + } + } + return filtered +} + +func ByCertificate(policies []policy.Policy, cert *x509.Certificate) []policy.Policy { + var filtered []policy.Policy + for _, p := range policies { + if AppliesToCertificate(p, cert) { + filtered = append(filtered, p) + } + } + return filtered +} + +func ByCRL(policies []policy.Policy, revocationList *x509.RevocationList) []policy.Policy { + var filtered []policy.Policy + hasDeltaIndicator := crl.HasDeltaIndicator(revocationList) + isIndirect := crl.IsIndirect(revocationList) + for _, p := range policies { + if AppliesToCRL(p, hasDeltaIndicator, isIndirect) { + filtered = append(filtered, p) + } + } + return filtered +} + +func AppliesToInput(p policy.Policy, inputType string) bool { + if len(p.AppliesTo) > 0 { + return slices.Contains(p.AppliesTo, inputType) + } + + if len(p.Rules) > 0 { + inferredType := inferInputTypeFromRules(p.Rules) + return inferredType == inputType || inferredType == "" + } + + return true +} + +func AppliesToCertificate(p policy.Policy, cert *x509.Certificate) bool { + if cert == nil || !AppliesToInput(p, InputCert) { + return false + } + + if len(p.CertType) == 0 { + return true + } + + for _, ct := range p.CertType { + ct = oid.NormalizeOID(ct) + + switch ct { + case "ca": + if cert.BasicConstraintsValid && cert.IsCA { + return true + } + case "root": + if cert.BasicConstraintsValid && cert.IsCA && cert.Subject.String() == cert.Issuer.String() { + return true + } + case "intermediate": + if cert.BasicConstraintsValid && cert.IsCA && cert.Subject.String() != cert.Issuer.String() { + return true + } + case "leaf": + if !cert.BasicConstraintsValid || !cert.IsCA { + return true + } + default: + for _, eku := range cert.ExtKeyUsage { + if oid.ExtKeyUsageToOID(eku) == ct { + return true + } + } + } + } + + return false +} + +func AppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) bool { + if !appliesToCRLInput(p) { + return false + } + + if len(p.CRLType) == 0 { + return true + } + + for _, ct := range p.CRLType { + ct = oid.NormalizeOID(ct) + + switch ct { + case oid.DeltaCRLIndicator: + if hasDeltaIndicator { + return true + } + case "indirectCRL": + if isIndirectCRL { + return true + } + case "completeCRL": + if !hasDeltaIndicator { + return true + } + } + } + + return false +} + +func inferInputTypeFromRules(rules []rule.Rule) string { + if len(rules) == 0 { + return "" + } + + target := rules[0].Target + if strings.HasPrefix(target, "certificate.") || target == "certificate" { + return InputCert + } + if strings.HasPrefix(target, "crl.") || target == "crl" { + return InputCRL + } + if strings.HasPrefix(target, "ocsp.") || target == "ocsp" { + return InputOCSP + } + + if rules[0].When != nil && rules[0].When.Target != "" { + whenTarget := rules[0].When.Target + if strings.HasPrefix(whenTarget, "certificate.") || whenTarget == "certificate" { + return InputCert + } + if strings.HasPrefix(whenTarget, "crl.") || whenTarget == "crl" { + return InputCRL + } + if strings.HasPrefix(whenTarget, "ocsp.") || whenTarget == "ocsp" { + return InputOCSP + } + } + + return "" +} + +func appliesToCRLInput(p policy.Policy) bool { + if len(p.AppliesTo) > 0 { + return slices.Contains(p.AppliesTo, InputCRL) + } + + for _, r := range p.Rules { + if strings.HasPrefix(r.Target, "crl.") || r.Target == "crl" { + return true + } + if r.When != nil && (strings.HasPrefix(r.When.Target, "crl.") || r.When.Target == "crl") { + return true + } + } + + return false +} + From d1ce37bfb8e5620aed828a693b164fe0d7ff1907 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 22:29:11 +0200 Subject: [PATCH 41/44] refactor: evaluation --- internal/evaluator/evaluator.go | 9 +- internal/linter/evaluator.go | 271 ------------------ internal/linter/evaluator_test.go | 79 ------ internal/linter/filter.go | 284 ------------------- internal/linter/runner.go | 16 +- internal/policy/{match => }/match.go | 24 +- internal/rule/evaluator.go | 78 +---- internal/rule/evaluator_test.go | 102 ------- internal/rule/rule.go | 2 +- tests/policies/authority-key-identifier.yaml | 2 +- tests/policies/basic.yaml | 6 +- tests/policies/common.yaml | 2 +- tests/policies/extensions-criticality.yaml | 2 +- tests/policies/leaf-keycertsign-fails.yaml | 2 +- tests/policies/subject-alt-name.yaml | 2 +- tests/policies/with-include.yaml | 2 +- 16 files changed, 39 insertions(+), 844 deletions(-) delete mode 100644 internal/linter/evaluator.go delete mode 100644 internal/linter/evaluator_test.go delete mode 100644 internal/linter/filter.go rename internal/policy/{match => }/match.go (83%) diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index b4c39fe..2b48591 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -10,7 +10,6 @@ import ( ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/policy" - "github.com/cavoq/PCL/internal/policy/match" "github.com/cavoq/PCL/internal/source" "github.com/cavoq/PCL/internal/zcrypto" "github.com/zmap/zcrypto/x509" @@ -54,7 +53,7 @@ func Chain(ctx Context) []policy.Result { } evalCtx := operator.NewEvaluationContext(tree, c, ctx.Chain, evalOpts...) - filteredPolicies := match.ByCertificate(ctx.Policies, c.Cert) + filteredPolicies := policy.ByCertificate(ctx.Policies, c.Cert) for _, p := range filteredPolicies { res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) results = append(results, res) @@ -87,7 +86,7 @@ func OCSP(ctx Context) []policy.Result { evalOpts := []operator.ContextOption{operator.WithOCSPs(ctx.OCSPs)} evalCtx := operator.NewEvaluationContext(tree, ocspCertInfo, ctx.Chain, evalOpts...) - filteredPolicies := match.ByInput(ctx.Policies, match.InputOCSP) + filteredPolicies := policy.ByInput(ctx.Policies, policy.InputOCSP) for _, p := range filteredPolicies { res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) results = append(results, res) @@ -126,7 +125,7 @@ func CRL(ctx Context) []policy.Result { evalOpts := []operator.ContextOption{operator.WithCRLs(ctx.CRLs)} evalCtx := operator.NewEvaluationContext(tree, crlCertInfo, ctx.Chain, evalOpts...) - filteredPolicies := match.ByCRL(ctx.Policies, crlInfo.CRL) + filteredPolicies := policy.ByCRL(ctx.Policies, crlInfo.CRL) for _, p := range filteredPolicies { res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) results = append(results, res) @@ -171,7 +170,7 @@ func ocspSigningCert(policies []policy.Policy, registry *operator.Registry, ocsp evalCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, evalOpts...) var results []policy.Result - signerPolicies := match.ByCertificate(policies, zcryptoSignerCert) + signerPolicies := policy.ByCertificate(policies, zcryptoSignerCert) for _, p := range signerPolicies { res := policy.Evaluate(p, ocspSignerTree, registry, evalCtx) results = append(results, res) diff --git a/internal/linter/evaluator.go b/internal/linter/evaluator.go deleted file mode 100644 index 64a5a3f..0000000 --- a/internal/linter/evaluator.go +++ /dev/null @@ -1,271 +0,0 @@ -package linter - -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/node" - "github.com/cavoq/PCL/internal/ocsp" - ocspzcrypto "github.com/cavoq/PCL/internal/ocsp/zcrypto" - "github.com/cavoq/PCL/internal/operator" - "github.com/cavoq/PCL/internal/policy" - "github.com/cavoq/PCL/internal/source" - "github.com/cavoq/PCL/internal/zcrypto" - "github.com/zmap/zcrypto/x509" -) - -// EvaluationContext contains all data needed for policy evaluation. -type EvaluationContext struct { - Policies []policy.Policy - Registry *operator.Registry - CRLs []*crl.Info - OCSPs []*ocsp.Info - Chain []*cert.Info -} - -// evaluateChain evaluates policies for each certificate in the chain. -func evaluateChain(ctx EvaluationContext) []policy.Result { - var results []policy.Result - - for _, c := range ctx.Chain { - tree := certzcrypto.BuildTree(c.Cert) - - // Add download format to tree for PEM format detection rule - if c.Source.Format != "" && c.Source.Type != source.Local { - tree.Children["downloadFormat"] = node.New("downloadFormat", c.Source.Format) - tree.Children["downloadURL"] = node.New("downloadURL", c.Source.URL) - } - - // Add CRL node to tree if CRLs are present - if len(ctx.CRLs) > 0 { - for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL != nil { - crlNode := crlzcrypto.BuildTree(crlInfo.CRL) - if crlNode != nil { - tree.Children["crl"] = crlNode - } - break - } - } - } - - evalOpts := []operator.ContextOption{ - operator.WithCRLs(ctx.CRLs), - operator.WithOCSPs(ctx.OCSPs), - } - evalCtx := operator.NewEvaluationContext(tree, c, ctx.Chain, evalOpts...) - - // Filter policies by certificate type - filteredPolicies := filterPoliciesByCert(ctx.Policies, c.Cert) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) - results = append(results, res) - } - } - - return results -} - -// evaluateOCSP evaluates policies for OCSP responses and signing certificates. -func evaluateOCSP(ctx EvaluationContext) []policy.Result { - var results []policy.Result - - for _, ocspInfo := range ctx.OCSPs { - if ocspInfo.Response == nil { - continue - } - - ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) - if ocspNode == nil { - continue - } - - // Create synthetic cert.Info for OCSP response to set proper CertType - ocspCertInfo := &cert.Info{ - FilePath: ocspInfo.FilePath, - Type: "ocsp", - Source: ocspInfo.Source, - } - - tree := ocspNode - evalOpts := []operator.ContextOption{operator.WithOCSPs(ctx.OCSPs)} - evalCtx := operator.NewEvaluationContext(tree, ocspCertInfo, ctx.Chain, evalOpts...) - - filteredPolicies := filterPoliciesByInput(ctx.Policies, AppliesToOCSP) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) - results = append(results, res) - } - - // Evaluate OCSP signing certificate if present in response - if ocspInfo.Response.Certificate != nil { - results = append(results, evaluateOCSPSigningCert(ctx.Policies, ctx.Registry, ctx.OCSPs, ocspInfo, ctx.Chain)...) - } - } - - return results -} - -// evaluateOCSPSigningCert evaluates policies for OCSP signing certificate. -func evaluateOCSPSigningCert(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info, ocspInfo *ocsp.Info, chain []*cert.Info) []policy.Result { - // Convert standard cert to zcrypto cert - zcryptoSignerCert, err := zcrypto.FromStdCert(ocspInfo.Response.Certificate) - if err != nil || zcryptoSignerCert == nil { - return nil - } - - ocspSignerTree := certzcrypto.BuildTree(zcryptoSignerCert) - ocspSignerInfo := &cert.Info{ - Cert: zcryptoSignerCert, - FilePath: ocspInfo.FilePath + " (signing cert)", - Type: "ocspSigning", - Source: source.Info{Type: source.Extracted, Description: "extracted from OCSP response"}, - } - - evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - evalCtx := operator.NewEvaluationContext(ocspSignerTree, ocspSignerInfo, chain, evalOpts...) - - var results []policy.Result - signerPolicies := filterPoliciesByCert(policies, zcryptoSignerCert) - for _, p := range signerPolicies { - res := policy.Evaluate(p, ocspSignerTree, registry, evalCtx) - results = append(results, res) - } - - return results -} - -// evaluateCRL evaluates policies for CRLs with a certificate chain. -func evaluateCRL(ctx EvaluationContext) []policy.Result { - var results []policy.Result - - for _, crlInfo := range ctx.CRLs { - if crlInfo.CRL == nil { - continue - } - - // Build issuer certificates list from chain - issuerCerts := extractCertsFromInfo(ctx.Chain) - - crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) - if crlNode == nil { - continue - } - - // Create synthetic cert.Info for CRL to set proper CertType - crlCertInfo := &cert.Info{ - FilePath: crlInfo.FilePath, - Type: "crl", - Source: crlInfo.Source, - } - - tree := crlNode - evalOpts := []operator.ContextOption{operator.WithCRLs(ctx.CRLs)} - evalCtx := operator.NewEvaluationContext(tree, crlCertInfo, ctx.Chain, evalOpts...) - - // Filter policies by CRL type - hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) - isIndirect := isIndirectCRL(crlInfo.CRL) - filteredPolicies := filterPoliciesByCRL(ctx.Policies, hasDelta, isIndirect) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, ctx.Registry, evalCtx) - results = append(results, res) - } - } - - return results -} - -// evaluateCRLOnly evaluates CRLs independently without a certificate chain. -func evaluateCRLOnly(policies []policy.Policy, registry *operator.Registry, crls []*crl.Info, issuers []*cert.Info) []policy.Result { - var results []policy.Result - - for _, crlInfo := range crls { - if crlInfo.CRL == nil { - continue - } - - // Build issuer certificates list for CRL type detection - issuerCerts := extractCertsFromInfo(issuers) - - crlNode := crlzcrypto.BuildTreeWithChain(crlInfo.CRL, issuerCerts) - if crlNode == nil { - continue - } - - // Create synthetic cert.Info for CRL to set proper CertType - crlCertInfo := &cert.Info{ - FilePath: crlInfo.FilePath, - Type: "crl", - Source: crlInfo.Source, - } - - tree := crlNode - evalOpts := []operator.ContextOption{operator.WithCRLs(crls)} - evalCtx := operator.NewEvaluationContext(tree, crlCertInfo, issuers, evalOpts...) - - // Filter policies by CRL type - hasDelta := hasDeltaCRLIndicator(crlInfo.CRL) - isIndirect := isIndirectCRL(crlInfo.CRL) - filteredPolicies := filterPoliciesByCRL(policies, hasDelta, isIndirect) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, registry, evalCtx) - results = append(results, res) - } - } - - return results -} - -// evaluateOCSPOnly evaluates OCSP responses independently without a certificate chain. -func evaluateOCSPOnly(policies []policy.Policy, registry *operator.Registry, ocsps []*ocsp.Info) []policy.Result { - var results []policy.Result - - for _, ocspInfo := range ocsps { - if ocspInfo.Response == nil { - continue - } - - ocspNode := ocspzcrypto.BuildTree(ocspInfo.Response) - if ocspNode == nil { - continue - } - - // Create synthetic cert.Info for OCSP response to set proper CertType - ocspCertInfo := &cert.Info{ - FilePath: ocspInfo.FilePath, - Type: "ocsp", - Source: ocspInfo.Source, - } - - tree := ocspNode - evalOpts := []operator.ContextOption{operator.WithOCSPs(ocsps)} - evalCtx := operator.NewEvaluationContext(tree, ocspCertInfo, nil, evalOpts...) - - // Filter policies by input type (OCSP) - filteredPolicies := filterPoliciesByInput(policies, AppliesToOCSP) - for _, p := range filteredPolicies { - res := policy.Evaluate(p, tree, registry, evalCtx) - results = append(results, res) - } - - // Evaluate OCSP signing certificate if present in response - if ocspInfo.Response.Certificate != nil { - results = append(results, evaluateOCSPSigningCert(policies, registry, ocsps, ocspInfo, nil)...) - } - } - - return results -} - -// extractCertsFromInfo extracts x509 certificates from cert.Info slice. -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 -} diff --git a/internal/linter/evaluator_test.go b/internal/linter/evaluator_test.go deleted file mode 100644 index fe18bb6..0000000 --- a/internal/linter/evaluator_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package linter - -import ( - "testing" - - "github.com/cavoq/PCL/internal/cert" - "github.com/cavoq/PCL/internal/operator" -) - -func TestEvaluateChainWithEmptyChain(t *testing.T) { - evalCtx := EvaluationContext{ - Policies: nil, - Registry: operator.DefaultRegistry(), - CRLs: nil, - OCSPs: nil, - Chain: []*cert.Info{}, - } - - results := evaluateChain(evalCtx) - if len(results) != 0 { - t.Errorf("expected 0 results for empty chain, got %d", len(results)) - } -} - -func TestEvaluateCRLOnlyWithEmptyCRLs(t *testing.T) { - results := evaluateCRLOnly(nil, operator.DefaultRegistry(), nil, nil) - if len(results) != 0 { - t.Errorf("expected 0 results for empty CRLs, got %d", len(results)) - } -} - -func TestEvaluateOCSPOnlyWithEmptyOCSPs(t *testing.T) { - results := evaluateOCSPOnly(nil, operator.DefaultRegistry(), nil) - if len(results) != 0 { - t.Errorf("expected 0 results for empty OCSPs, got %d", len(results)) - } -} - -func TestExtractCertsFromInfoEmpty(t *testing.T) { - certs := extractCertsFromInfo(nil) - if len(certs) != 0 { - t.Errorf("expected 0 certs for nil input, got %d", len(certs)) - } - - certs = extractCertsFromInfo([]*cert.Info{}) - if len(certs) != 0 { - t.Errorf("expected 0 certs for empty slice, got %d", len(certs)) - } -} - -func TestExtractCertsFromInfoWithNilCert(t *testing.T) { - infos := []*cert.Info{ - {Cert: nil}, - {Cert: nil, FilePath: "test.pem"}, - } - certs := extractCertsFromInfo(infos) - if len(certs) != 0 { - t.Errorf("expected 0 certs for nil certs in info, got %d", len(certs)) - } -} - -func TestEvaluationContextDefaults(t *testing.T) { - evalCtx := EvaluationContext{} - - // Verify default values - if evalCtx.Registry == nil { - // Registry should be set when actually used - t.Log("Registry is nil in default context (expected)") - } - if evalCtx.Chain != nil { - t.Errorf("expected nil Chain in default context") - } - if evalCtx.CRLs != nil { - t.Errorf("expected nil CRLs in default context") - } - if evalCtx.OCSPs != nil { - t.Errorf("expected nil OCSPs in default context") - } -} diff --git a/internal/linter/filter.go b/internal/linter/filter.go deleted file mode 100644 index c7bccc9..0000000 --- a/internal/linter/filter.go +++ /dev/null @@ -1,284 +0,0 @@ -package linter - -import ( - "slices" - "strings" - - "github.com/zmap/zcrypto/x509" - - "github.com/cavoq/PCL/internal/oid" - "github.com/cavoq/PCL/internal/policy" - "github.com/cavoq/PCL/internal/rule" -) - -// AppliesTo types - fixed enumeration for PKI input types -const ( - AppliesToCert = "cert" - AppliesToCRL = "crl" - AppliesToOCSP = "ocsp" - AppliesToTST = "tst" - AppliesToSCT = "sct" - AppliesToAttrCert = "attrCert" -) - -// policyAppliesToInput checks if a policy applies to the given input type -func policyAppliesToInput(p policy.Policy, inputType string) bool { - // If AppliesTo is explicitly set, use it - if len(p.AppliesTo) > 0 { - return slices.Contains(p.AppliesTo, inputType) - } - - // Infer from rule targets if AppliesTo is not set - // If all targets start with "certificate.", it's a cert policy - // If all targets start with "crl.", it's a CRL policy - // If all targets start with "ocsp.", it's an OCSP policy - if len(p.Rules) > 0 { - inferredType := inferInputTypeFromRules(p.Rules) - return inferredType == inputType || inferredType == "" - } - - // Default: apply to all (backward compatible for empty policies) - return true -} - -// inferInputTypeFromRules determines the input type from rule targets -func inferInputTypeFromRules(rules []rule.Rule) string { - if len(rules) == 0 { - return "" - } - - // Check first rule target - target := rules[0].Target - if strings.HasPrefix(target, "certificate.") || target == "certificate" { - return AppliesToCert - } - if strings.HasPrefix(target, "crl.") || target == "crl" { - return AppliesToCRL - } - if strings.HasPrefix(target, "ocsp.") || target == "ocsp" { - return AppliesToOCSP - } - - // Check "when" condition target if main target doesn't indicate type - if rules[0].When != nil && rules[0].When.Target != "" { - whenTarget := rules[0].When.Target - if strings.HasPrefix(whenTarget, "certificate.") || whenTarget == "certificate" { - return AppliesToCert - } - if strings.HasPrefix(whenTarget, "crl.") || whenTarget == "crl" { - return AppliesToCRL - } - if strings.HasPrefix(whenTarget, "ocsp.") || whenTarget == "ocsp" { - return AppliesToOCSP - } - } - - // Unknown target format, apply to all - return "" -} - -// policyAppliesToCert checks if a policy applies to a specific certificate -func policyAppliesToCert(p policy.Policy, cert *x509.Certificate) bool { - // Check input type first - if !policyAppliesToInput(p, AppliesToCert) { - return false - } - - // If no certType filter, applies to all certs - if len(p.CertType) == 0 { - return true - } - - // Check each certType constraint - for _, ct := range p.CertType { - ct = oid.NormalizeOID(ct) - - // Built-in types - if ct == "ca" { - if cert.BasicConstraintsValid && cert.IsCA { - return true - } - continue - } - if ct == "root" { - // Root CA: self-signed (Subject == Issuer) and isCA - if cert.BasicConstraintsValid && cert.IsCA && cert.Subject.String() == cert.Issuer.String() { - return true - } - continue - } - if ct == "intermediate" { - // Intermediate CA: has different issuer (not self-signed) and isCA - if cert.BasicConstraintsValid && cert.IsCA && cert.Subject.String() != cert.Issuer.String() { - return true - } - continue - } - if ct == "leaf" { - if !cert.BasicConstraintsValid || !cert.IsCA { - return true - } - continue - } - - // EKU OID check - for _, eku := range cert.ExtKeyUsage { - ekuOID := oid.ExtKeyUsageToOID(eku) - if ekuOID == ct { - return true - } - } - } - - return false -} - -// policyAppliesToCRL checks if a policy applies to a specific CRL -func policyAppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) bool { - // Check input type first - if !policyAppliesToCRLInput(p) { - return false - } - - // If no crlType filter, applies to all CRLs - if len(p.CRLType) == 0 { - return true - } - - // Check each crlType constraint - for _, ct := range p.CRLType { - ct = oid.NormalizeOID(ct) - - if ct == oid.DeltaCRLIndicator { - if hasDeltaIndicator { - return true - } - continue - } - - if ct == "indirectCRL" { - if isIndirectCRL { - return true - } - continue - } - - if ct == "completeCRL" { - if !hasDeltaIndicator { - return true - } - continue - } - } - - return false -} - -// policyAppliesToCRLInput checks if a policy contains any CRL-related rules -func policyAppliesToCRLInput(p policy.Policy) bool { - // If AppliesTo is explicitly set, use it - if len(p.AppliesTo) > 0 { - return slices.Contains(p.AppliesTo, AppliesToCRL) - } - - // Check if any rule targets CRL - for _, r := range p.Rules { - if strings.HasPrefix(r.Target, "crl.") || r.Target == "crl" { - return true - } - if r.When != nil && (strings.HasPrefix(r.When.Target, "crl.") || r.When.Target == "crl") { - return true - } - } - - return false -} - -// hasDeltaCRLIndicator checks if CRL has the delta CRL indicator extension -func hasDeltaCRLIndicator(crl *x509.RevocationList) bool { - if crl == nil { - return false - } - for _, ext := range crl.Extensions { - if ext.Id.String() == oid.DeltaCRLIndicator { - return true - } - } - return false -} - -// isIndirectCRL checks if CRL is an indirect CRL (issued by different CA) -// Indirect CRL is indicated when the CRL issuer differs from the certificate issuer. -// This can be detected via the IssuingDistributionPoint extension's indirectCRL field. -func isIndirectCRL(crl *x509.RevocationList) bool { - if crl == nil { - return false - } - for _, ext := range crl.Extensions { - if ext.Id.String() == oid.IssuingDistributionPoint { - // The indirectCRL field is a boolean in the IssuingDistributionPoint extension - // If the extension is present, we check the raw value for indirectCRL indicator - // The ASN.1 structure includes an optional indirectCRL BOOLEAN DEFAULT FALSE - // Parsing this requires decoding the extension value - return checkIndirectCRLInExtension(ext.Value) - } - } - return false -} - -// checkIndirectCRLInExtension parses the IssuingDistributionPoint extension to find indirectCRL field -func checkIndirectCRLInExtension(extValue []byte) bool { - // IssuingDistributionPoint ASN.1 structure (RFC 5280): - // IssuingDistributionPoint ::= SEQUENCE { - // distributionPoint [0] DistributionPointName OPTIONAL, - // onlyContainsUserCerts [1] BOOLEAN DEFAULT FALSE, - // onlyContainsCACerts [2] BOOLEAN DEFAULT FALSE, - // onlySomeReasons [3] ReasonFlags OPTIONAL, - // indirectCRL [4] BOOLEAN DEFAULT FALSE, - // onlyContainsAttributeCerts [5] BOOLEAN DEFAULT FALSE - // } - // We need to check if the [4] indirectCRL field is present and TRUE - // The tag for indirectCRL is context-specific [4] which is 0x84 in DER - for i := 0; i < len(extValue)-1; i++ { - if extValue[i] == 0x84 { // context-specific tag [4] for indirectCRL - // Next byte should be the length (typically 1 for BOOLEAN TRUE) - if extValue[i+1] == 0x01 && i+2 < len(extValue) { - return extValue[i+2] == 0xFF // TRUE in ASN.1 DER - } - } - } - return false -} - -// filterPoliciesByInput returns policies that apply to the given input type -func filterPoliciesByInput(policies []policy.Policy, inputType string) []policy.Policy { - var filtered []policy.Policy - for _, p := range policies { - if policyAppliesToInput(p, inputType) { - filtered = append(filtered, p) - } - } - return filtered -} - -// filterPoliciesByCert returns policies that apply to the given certificate -func filterPoliciesByCert(policies []policy.Policy, cert *x509.Certificate) []policy.Policy { - var filtered []policy.Policy - for _, p := range policies { - if policyAppliesToCert(p, cert) { - filtered = append(filtered, p) - } - } - return filtered -} - -// filterPoliciesByCRL returns policies that apply to the given CRL -func filterPoliciesByCRL(policies []policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) []policy.Policy { - var filtered []policy.Policy - for _, p := range policies { - if policyAppliesToCRL(p, hasDeltaIndicator, isIndirectCRL) { - filtered = append(filtered, p) - } - } - return filtered -} diff --git a/internal/linter/runner.go b/internal/linter/runner.go index 483413c..ec39f72 100644 --- a/internal/linter/runner.go +++ b/internal/linter/runner.go @@ -8,6 +8,7 @@ import ( "github.com/cavoq/PCL/internal/cert" "github.com/cavoq/PCL/internal/crl" + "github.com/cavoq/PCL/internal/evaluator" "github.com/cavoq/PCL/internal/ocsp" "github.com/cavoq/PCL/internal/operator" "github.com/cavoq/PCL/internal/output" @@ -55,9 +56,9 @@ func Run(cfg Config, w io.Writer) error { if hasCert { results, cleanup = processCertificates(cfg, policies, reg, crls, ocsps, issuers, cleanup, w) } else if len(crls) > 0 { - results = evaluateCRLOnly(policies, reg, crls, issuers) + results = evaluator.CRLOnly(policies, reg, crls, issuers) } else if len(ocsps) > 0 { - results = evaluateOCSPOnly(policies, reg, ocsps) + results = evaluator.OCSPOnly(policies, reg, ocsps) } else { return fmt.Errorf("no certificates, CRLs, or OCSP responses provided") } @@ -194,24 +195,21 @@ func processCertificates(cfg Config, policies []policy.Policy, reg *operator.Reg ocsps = append(ocsps, autoOCSPs...) } - // Evaluate certificates - evalCtx := EvaluationContext{ + evalCtx := evaluator.Context{ Policies: policies, Registry: reg, CRLs: crls, OCSPs: ocsps, Chain: chain, } - results := evaluateChain(evalCtx) + results := evaluator.Chain(evalCtx) - // Evaluate OCSP if present if len(ocsps) > 0 { - results = append(results, evaluateOCSP(evalCtx)...) + results = append(results, evaluator.OCSP(evalCtx)...) } - // Evaluate CRLs if present (dual evaluation) if len(crls) > 0 { - results = append(results, evaluateCRL(evalCtx)...) + results = append(results, evaluator.CRL(evalCtx)...) } return results, cleanup diff --git a/internal/policy/match/match.go b/internal/policy/match.go similarity index 83% rename from internal/policy/match/match.go rename to internal/policy/match.go index f8ae96f..a985823 100644 --- a/internal/policy/match/match.go +++ b/internal/policy/match.go @@ -1,4 +1,4 @@ -package match +package policy import ( "slices" @@ -8,7 +8,6 @@ import ( "github.com/cavoq/PCL/internal/crl" "github.com/cavoq/PCL/internal/oid" - "github.com/cavoq/PCL/internal/policy" "github.com/cavoq/PCL/internal/rule" ) @@ -21,8 +20,8 @@ const ( InputAttrCert = "attrCert" ) -func ByInput(policies []policy.Policy, inputType string) []policy.Policy { - var filtered []policy.Policy +func ByInput(policies []Policy, inputType string) []Policy { + var filtered []Policy for _, p := range policies { if AppliesToInput(p, inputType) { filtered = append(filtered, p) @@ -31,8 +30,8 @@ func ByInput(policies []policy.Policy, inputType string) []policy.Policy { return filtered } -func ByCertificate(policies []policy.Policy, cert *x509.Certificate) []policy.Policy { - var filtered []policy.Policy +func ByCertificate(policies []Policy, cert *x509.Certificate) []Policy { + var filtered []Policy for _, p := range policies { if AppliesToCertificate(p, cert) { filtered = append(filtered, p) @@ -41,8 +40,8 @@ func ByCertificate(policies []policy.Policy, cert *x509.Certificate) []policy.Po return filtered } -func ByCRL(policies []policy.Policy, revocationList *x509.RevocationList) []policy.Policy { - var filtered []policy.Policy +func ByCRL(policies []Policy, revocationList *x509.RevocationList) []Policy { + var filtered []Policy hasDeltaIndicator := crl.HasDeltaIndicator(revocationList) isIndirect := crl.IsIndirect(revocationList) for _, p := range policies { @@ -53,7 +52,7 @@ func ByCRL(policies []policy.Policy, revocationList *x509.RevocationList) []poli return filtered } -func AppliesToInput(p policy.Policy, inputType string) bool { +func AppliesToInput(p Policy, inputType string) bool { if len(p.AppliesTo) > 0 { return slices.Contains(p.AppliesTo, inputType) } @@ -66,7 +65,7 @@ func AppliesToInput(p policy.Policy, inputType string) bool { return true } -func AppliesToCertificate(p policy.Policy, cert *x509.Certificate) bool { +func AppliesToCertificate(p Policy, cert *x509.Certificate) bool { if cert == nil || !AppliesToInput(p, InputCert) { return false } @@ -107,7 +106,7 @@ func AppliesToCertificate(p policy.Policy, cert *x509.Certificate) bool { return false } -func AppliesToCRL(p policy.Policy, hasDeltaIndicator bool, isIndirectCRL bool) bool { +func AppliesToCRL(p Policy, hasDeltaIndicator bool, isIndirectCRL bool) bool { if !appliesToCRLInput(p) { return false } @@ -170,7 +169,7 @@ func inferInputTypeFromRules(rules []rule.Rule) string { return "" } -func appliesToCRLInput(p policy.Policy) bool { +func appliesToCRLInput(p Policy) bool { if len(p.AppliesTo) > 0 { return slices.Contains(p.AppliesTo, InputCRL) } @@ -186,4 +185,3 @@ func appliesToCRLInput(p policy.Policy) bool { return false } - diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index 03a7d65..ad5a656 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -3,7 +3,6 @@ package rule import ( "fmt" "slices" - "strings" "github.com/cavoq/PCL/internal/node" "github.com/cavoq/PCL/internal/operator" @@ -45,23 +44,12 @@ func Evaluate( reg *operator.Registry, ctx *operator.EvaluationContext, ) Result { - // Check if rule target matches the input type - if !targetMatchesInputType(root, r.Target) { + if !certTypeMatches(r, ctx) { return Result{ - RuleID: r.ID, - Reference: r.Reference, - Verdict: VerdictSkip, - Severity: r.Severity, - Message: "rule target does not match input type", - } - } - - if !appliesTo(r, ctx) { - return Result{ - RuleID: r.ID, + RuleID: r.ID, Reference: r.Reference, - Verdict: VerdictSkip, - Severity: r.Severity, + Verdict: VerdictSkip, + Severity: r.Severity, } } @@ -190,14 +178,14 @@ func evaluateCondition( return op.Evaluate(n, ctx, normalizeOperands(cond.Operands)) } -func appliesTo(r Rule, ctx *operator.EvaluationContext) bool { - if len(r.AppliesTo) == 0 { +func certTypeMatches(r Rule, ctx *operator.EvaluationContext) bool { + if len(r.CertType) == 0 { return true } if ctx == nil || ctx.Cert == nil { return true } - return slices.Contains(r.AppliesTo, ctx.Cert.Type) + return slices.Contains(r.CertType, ctx.Cert.Type) } // isKeyUsageBooleanField checks if the target is a keyUsage boolean field. @@ -218,55 +206,3 @@ func isKeyUsageBooleanField(target string) bool { return slices.Contains(keyUsageFields, target) } -// targetMatchesInputType checks if the rule's target is compatible with the input type. -// Returns true if the target prefix matches the tree structure, or if target is generic. -// Returns false if target prefix doesn't match (e.g., certificate rule on CRL input). -func targetMatchesInputType(root *node.Node, target string) bool { - if root == nil { - return true // No tree, can't determine type - } - - return matchesTreePrefix(root, target) -} - -// matchesTreePrefix checks if the target's prefix matches the tree structure. -// Returns true if: -// - target prefix matches root.Name (Resolve will skip this prefix) -// - target prefix exists as child in tree -// - target is generic (no known prefix) -func matchesTreePrefix(root *node.Node, target string) bool { - // Extract prefix from target (e.g., "certificate" from "certificate.xxx") - prefix := extractPrefix(target) - if prefix == "" { - return true // Generic target, applies to all - } - - // If prefix matches root name, Resolve will skip it and work correctly - if root.Name == prefix { - return true - } - - // Check if prefix exists as child in tree - if root.Children != nil { - _, exists := root.Children[prefix] - return exists - } - - return false -} - -// extractPrefix extracts the input type prefix from a target string. -// E.g., "certificate.xxx" -> "certificate", "crl.xxx" -> "crl" -func extractPrefix(target string) string { - // Check known prefixes - if strings.HasPrefix(target, "certificate.") || target == "certificate" { - return "certificate" - } - if strings.HasPrefix(target, "crl.") || target == "crl" { - return "crl" - } - if strings.HasPrefix(target, "ocsp.") || target == "ocsp" { - return "ocsp" - } - return "" // Unknown or generic target -} \ No newline at end of file diff --git a/internal/rule/evaluator_test.go b/internal/rule/evaluator_test.go index 0b49fe4..f36436d 100644 --- a/internal/rule/evaluator_test.go +++ b/internal/rule/evaluator_test.go @@ -4,7 +4,6 @@ import ( "fmt" "testing" - "github.com/cavoq/PCL/internal/cert" "github.com/cavoq/PCL/internal/node" "github.com/cavoq/PCL/internal/operator" ) @@ -131,107 +130,6 @@ func TestRuleEvaluationWithReference(t *testing.T) { } } -func TestRuleEvaluationAppliesTo_NoContext(t *testing.T) { - root := node.New("root", nil) - root.Children["a"] = node.New("a", 42) - - reg := operator.NewRegistry() - reg.Register(operator.Eq{}) - - r := Rule{ - ID: "test", - Target: "a", - Operator: "eq", - Operands: []any{42}, - AppliesTo: []string{"leaf"}, - } - - // With nil context, rule should still apply - res := Evaluate(root, r, reg, nil) - - if res.Verdict != VerdictPass { - t.Errorf("expected pass when context is nil, got %s", res.Verdict) - } -} - -func TestRuleEvaluationAppliesTo_Matches(t *testing.T) { - root := node.New("root", nil) - root.Children["a"] = node.New("a", 42) - - reg := operator.NewRegistry() - reg.Register(operator.Eq{}) - - ctx := &operator.EvaluationContext{ - Cert: &cert.Info{Type: "leaf"}, - } - - r := Rule{ - ID: "test", - Target: "a", - Operator: "eq", - Operands: []any{42}, - AppliesTo: []string{"leaf", "intermediate"}, - } - - res := Evaluate(root, r, reg, ctx) - - if res.Verdict != VerdictPass { - t.Errorf("expected pass when cert type matches, got %s", res.Verdict) - } -} - -func TestRuleEvaluationAppliesTo_NoMatch(t *testing.T) { - root := node.New("root", nil) - root.Children["a"] = node.New("a", 42) - - reg := operator.NewRegistry() - reg.Register(operator.Eq{}) - - ctx := &operator.EvaluationContext{ - Cert: &cert.Info{Type: "root"}, - } - - r := Rule{ - ID: "test", - Target: "a", - Operator: "eq", - Operands: []any{42}, - AppliesTo: []string{"leaf"}, - } - - res := Evaluate(root, r, reg, ctx) - - if res.Verdict != VerdictSkip { - t.Errorf("expected skip when cert type doesn't match, got %s", res.Verdict) - } -} - -func TestRuleEvaluationAppliesTo_Empty(t *testing.T) { - root := node.New("root", nil) - root.Children["a"] = node.New("a", 42) - - reg := operator.NewRegistry() - reg.Register(operator.Eq{}) - - ctx := &operator.EvaluationContext{ - Cert: &cert.Info{Type: "any"}, - } - - r := Rule{ - ID: "test", - Target: "a", - Operator: "eq", - Operands: []any{42}, - AppliesTo: []string{}, // Empty applies to all - } - - res := Evaluate(root, r, reg, ctx) - - if res.Verdict != VerdictPass { - t.Errorf("expected pass when AppliesTo is empty, got %s", res.Verdict) - } -} - func TestRuleEvaluationWhenCondition_Met(t *testing.T) { root := node.New("root", nil) root.Children["a"] = node.New("a", 42) diff --git a/internal/rule/rule.go b/internal/rule/rule.go index 313c41a..d19b6e4 100644 --- a/internal/rule/rule.go +++ b/internal/rule/rule.go @@ -13,6 +13,6 @@ type Rule struct { Operator string `yaml:"operator"` Operands any `yaml:"operands"` // Can be []any or map[string]any Severity string `yaml:"severity"` - AppliesTo []string `yaml:"appliesTo,omitempty"` + CertType []string `yaml:"certType,omitempty"` When *Condition `yaml:"when,omitempty"` } diff --git a/tests/policies/authority-key-identifier.yaml b/tests/policies/authority-key-identifier.yaml index 4bc479c..78e6fc1 100644 --- a/tests/policies/authority-key-identifier.yaml +++ b/tests/policies/authority-key-identifier.yaml @@ -5,5 +5,5 @@ rules: - id: aki-present target: certificate.authorityKeyIdentifier operator: present - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] severity: error diff --git a/tests/policies/basic.yaml b/tests/policies/basic.yaml index a654799..5cb70aa 100644 --- a/tests/policies/basic.yaml +++ b/tests/policies/basic.yaml @@ -12,17 +12,17 @@ rules: target: certificate.basicConstraints.cA operator: eq operands: [true] - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error - id: leaf-subject-present target: certificate.subject.commonName operator: notEmpty - appliesTo: [leaf] + certType: [leaf] severity: error - id: root-serial-present target: certificate.serialNumber operator: present - appliesTo: [root] + certType: [root] severity: warning diff --git a/tests/policies/common.yaml b/tests/policies/common.yaml index e050aa4..9e12f6d 100644 --- a/tests/policies/common.yaml +++ b/tests/policies/common.yaml @@ -11,5 +11,5 @@ rules: - id: leaf-subject-present target: certificate.subject.commonName operator: notEmpty - appliesTo: [leaf] + certType: [leaf] severity: error diff --git a/tests/policies/extensions-criticality.yaml b/tests/policies/extensions-criticality.yaml index 3665992..9bd79b2 100644 --- a/tests/policies/extensions-criticality.yaml +++ b/tests/policies/extensions-criticality.yaml @@ -12,5 +12,5 @@ rules: target: certificate.extensions.2.5.29.19.critical operator: eq operands: [true] - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error diff --git a/tests/policies/leaf-keycertsign-fails.yaml b/tests/policies/leaf-keycertsign-fails.yaml index e92421b..954dc19 100644 --- a/tests/policies/leaf-keycertsign-fails.yaml +++ b/tests/policies/leaf-keycertsign-fails.yaml @@ -6,5 +6,5 @@ rules: target: certificate.keyUsage.keyCertSign operator: eq operands: [true] - appliesTo: [leaf] + certType: [leaf] severity: error diff --git a/tests/policies/subject-alt-name.yaml b/tests/policies/subject-alt-name.yaml index add0a11..5c9a912 100644 --- a/tests/policies/subject-alt-name.yaml +++ b/tests/policies/subject-alt-name.yaml @@ -5,5 +5,5 @@ rules: - id: san-present-for-leaf target: certificate.subjectAltName operator: present - appliesTo: [leaf] + certType: [leaf] severity: error diff --git a/tests/policies/with-include.yaml b/tests/policies/with-include.yaml index 353c3c9..52bda8e 100644 --- a/tests/policies/with-include.yaml +++ b/tests/policies/with-include.yaml @@ -8,5 +8,5 @@ rules: target: certificate.basicConstraints.cA operator: eq operands: [true] - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error From 1e3ba6569fcdef4d55b928d91abb051fa85a62cd Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 23:02:23 +0200 Subject: [PATCH 42/44] fix: linting issues --- .golangci.yml | 48 ++++---- internal/aia/convert.go | 1 + internal/asn1/der.go | 9 +- internal/asn1/time_test.go | 6 +- internal/cert/builder.go | 1 + internal/cert/chain.go | 5 +- internal/cert/zcrypto/builder.go | 1 + internal/cert/zcrypto/builder_test.go | 18 +-- internal/cert/zcrypto/cert_policies_test.go | 6 +- internal/cert/zcrypto/extension_parser.go | 23 +--- .../cert/zcrypto/extension_parser_test.go | 53 ++++++--- internal/cert/zcrypto/parser.go | 15 +-- internal/crl/crl.go | 1 + internal/crl/zcrypto/builder.go | 1 + internal/data/loader.go | 6 +- internal/data/loader_test.go | 16 ++- internal/evaluator/evaluator.go | 1 + internal/io/walker.go | 1 + internal/linter/config.go | 1 + internal/loader/loader.go | 1 + internal/node/node.go | 1 + internal/node/print.go | 2 +- internal/ocsp/fetch.go | 2 +- internal/ocsp/ocsp.go | 1 + internal/ocsp/zcrypto/builder.go | 1 + internal/oid/oid.go | 1 + internal/operator/operator.go | 1 + internal/operator/utf8_test.go | 3 +- internal/output/result.go | 6 +- internal/policy/parser.go | 1 + internal/policy/parser_test.go | 24 +++- internal/rule/rule.go | 1 + internal/source/source.go | 1 + internal/zcrypto/helpers.go | 1 + tests/certs/chain.pem | 106 ++++++++++++++++++ 35 files changed, 264 insertions(+), 102 deletions(-) create mode 100644 tests/certs/chain.pem diff --git a/.golangci.yml b/.golangci.yml index 60a4ec5..a09f039 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -16,28 +16,31 @@ linters: - gosec - prealloc -linters-settings: - errcheck: - check-type-assertions: true - check-blank: false - - govet: - disable: - - fieldalignment - - misspell: - locale: US - - gosec: - excludes: - - G104 - - G304 - - G306 - - staticcheck: - checks: - - all - - -SA1019 + settings: + errcheck: + check-type-assertions: true + check-blank: false + + govet: + disable: + - fieldalignment + + misspell: + locale: US + + gosec: + excludes: + - G104 # Errors unhandled (deferred close) + - G107 # URL from variable — intentional in fetchers + - G115 # Integer overflow in byte conversions — ASN.1 encoding + - G301 # Dir permissions 0750+ — acceptable for public data dirs + - G304 # File path via variable — intentional throughout + - G306 # File permissions — 0644 is intentional for cert/policy files + + staticcheck: + checks: + - all + - -SA1019 issues: exclude-use-default: false @@ -50,3 +53,4 @@ issues: - gosec - unparam - errcheck + - prealloc diff --git a/internal/aia/convert.go b/internal/aia/convert.go index 08d2fae..f1defca 100644 --- a/internal/aia/convert.go +++ b/internal/aia/convert.go @@ -1,3 +1,4 @@ +// Package aia provides AIA certificate extension parsing and issuer fetching. package aia import ( diff --git a/internal/asn1/der.go b/internal/asn1/der.go index 32f40a2..aa0b310 100644 --- a/internal/asn1/der.go +++ b/internal/asn1/der.go @@ -1,3 +1,4 @@ +// Package asn1 provides DER encoding/decoding helpers for X.509 ASN.1 structures. package asn1 import ( @@ -47,15 +48,17 @@ func ReadDERLength(data []byte, pos int) (int, int, error) { } length := 0 - for i := 0; i < lenBytes; i++ { + for i := range lenBytes { length = (length << 8) | int(data[pos+1+i]) } return length, pos + 1 + lenBytes, nil } func encodeTagged(tag byte, content []byte) []byte { - result := []byte{tag} - result = append(result, encodeDERLength(len(content))...) + lenBytes := encodeDERLength(len(content)) + result := make([]byte, 0, 1+len(lenBytes)+len(content)) + result = append(result, tag) + result = append(result, lenBytes...) result = append(result, content...) return result } diff --git a/internal/asn1/time_test.go b/internal/asn1/time_test.go index fdc5c7f..c1a1e51 100644 --- a/internal/asn1/time_test.go +++ b/internal/asn1/time_test.go @@ -3,7 +3,8 @@ package asn1 import "testing" func TestParseUTCTime(t *testing.T) { - der := []byte{0x17, 0x0d} + der := make([]byte, 0, 15) + der = append(der, 0x17, 0x0d) der = append(der, []byte("250101000000Z")...) info, err := ParseUTCTime(der) @@ -25,7 +26,8 @@ func TestParseUTCTime(t *testing.T) { } func TestParseGeneralizedTime(t *testing.T) { - der := []byte{0x18, 0x11} + der := make([]byte, 0, 19) + der = append(der, 0x18, 0x11) der = append(der, []byte("20250101000000.5Z")...) info, err := ParseGeneralizedTime(der) diff --git a/internal/cert/builder.go b/internal/cert/builder.go index ee05f9f..42cd87f 100644 --- a/internal/cert/builder.go +++ b/internal/cert/builder.go @@ -1,3 +1,4 @@ +// Package cert provides certificate loading, parsing and chain building. package cert import "github.com/cavoq/PCL/internal/node" diff --git a/internal/cert/chain.go b/internal/cert/chain.go index c4fcab7..c1c8597 100644 --- a/internal/cert/chain.go +++ b/internal/cert/chain.go @@ -79,10 +79,7 @@ func BuildChain(certs []*Info) ([]*Info, error) { chain := []*Info{leaf} current := leaf - for { - if IsSelfSigned(current.Cert) { - break - } + for !IsSelfSigned(current.Cert) { issuer := subjectMap[current.Cert.Issuer.String()] if issuer == nil { diff --git a/internal/cert/zcrypto/builder.go b/internal/cert/zcrypto/builder.go index 0ab8af0..5dae3c0 100644 --- a/internal/cert/zcrypto/builder.go +++ b/internal/cert/zcrypto/builder.go @@ -1,3 +1,4 @@ +// Package zcrypto provides zcrypto-based X.509 certificate parsing and node tree building. package zcrypto import ( diff --git a/internal/cert/zcrypto/builder_test.go b/internal/cert/zcrypto/builder_test.go index 82ea710..d07f3b5 100644 --- a/internal/cert/zcrypto/builder_test.go +++ b/internal/cert/zcrypto/builder_test.go @@ -271,7 +271,7 @@ func TestBuilder_ExtensionDetails(t *testing.T) { } func TestBuilder_AIAStructure(t *testing.T) { - data, err := os.ReadFile("../../../tests/certs/letsencrypt.pem") + data, err := os.ReadFile("../../../tests/certs/leaf.pem") if err != nil { t.Fatalf("failed to read cert: %v", err) } @@ -343,7 +343,8 @@ func TestBuilder_AIAStructure(t *testing.T) { if !ok { t.Fatal("accessLocation tag not found") } - if locTag.Value.(int) != 6 { + locTagInt, ok2 := locTag.Value.(int) + if !ok2 || locTagInt != 6 { t.Errorf("expected URI tag 6, got %v", locTag.Value) } @@ -359,7 +360,7 @@ func TestBuilder_AIAStructure(t *testing.T) { } func TestBuilder_CRLDPStructure(t *testing.T) { - data, err := os.ReadFile("../../../tests/certs/letsencrypt.pem") + data, err := os.ReadFile("../../../tests/certs/leaf.pem") if err != nil { t.Fatalf("failed to read cert: %v", err) } @@ -409,15 +410,15 @@ func TestBuilder_CRLDPStructure(t *testing.T) { hasReasons, ok := dp0.Children["hasReasons"] if ok { t.Logf("hasReasons: %v", hasReasons.Value) - if hasReasons.Value.(bool) { - t.Error("should not have reasons field for Let's Encrypt cert") + if hasReasonsVal, ok2 := hasReasons.Value.(bool); ok2 && hasReasonsVal { + t.Error("should not have reasons field for test cert") } } hasCRLIssuer, ok := dp0.Children["hasCRLIssuer"] if ok { t.Logf("hasCRLIssuer: %v", hasCRLIssuer.Value) - if hasCRLIssuer.Value.(bool) { - t.Error("should not have cRLIssuer field for Let's Encrypt cert") + if hasCRLIssuerVal, ok2 := hasCRLIssuer.Value.(bool); ok2 && hasCRLIssuerVal { + t.Error("should not have cRLIssuer field for test cert") } } @@ -439,7 +440,8 @@ func TestBuilder_CRLDPStructure(t *testing.T) { t.Fatal("generalName type not found") } t.Logf("GeneralName type: %v", gnType.Value) - if gnType.Value.(string) != "uniformResourceIdentifier" { + gnTypeStr, ok2 := gnType.Value.(string) + if !ok2 || gnTypeStr != "uniformResourceIdentifier" { t.Errorf("expected URI type, got %v", gnType.Value) } scheme, ok := gn0.Children["scheme"] diff --git a/internal/cert/zcrypto/cert_policies_test.go b/internal/cert/zcrypto/cert_policies_test.go index 868ea35..86ff4ad 100644 --- a/internal/cert/zcrypto/cert_policies_test.go +++ b/internal/cert/zcrypto/cert_policies_test.go @@ -150,7 +150,8 @@ func TestParseCertPolicies(t *testing.T) { if !ok { return false } - return encoding.Value.(string) == "ia5String" + s, ok := encoding.Value.(string) + return ok && s == "ia5String" }, expected: true, }, @@ -186,7 +187,8 @@ func TestParseCertPolicies(t *testing.T) { if !ok { return false } - return encoding.Value.(string) == "utf8String" + s, ok := encoding.Value.(string) + return ok && s == "utf8String" }, expected: true, }, diff --git a/internal/cert/zcrypto/extension_parser.go b/internal/cert/zcrypto/extension_parser.go index 0fd364e..bee0c54 100644 --- a/internal/cert/zcrypto/extension_parser.go +++ b/internal/cert/zcrypto/extension_parser.go @@ -9,12 +9,6 @@ import ( cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" ) -// AIA OID: 1.3.6.1.5.5.7.1.1 -const aiaOID = "1.3.6.1.5.5.7.1.1" - -// CRL Distribution Points OID: 2.5.29.31 -const crlDPOID = "2.5.29.31" - // ParseAIA parses the Authority Information Access extension (OID 1.3.6.1.5.5.7.1.1) // and returns a node tree with accessDescriptions. // @@ -89,25 +83,21 @@ func ParseAIA(extValue []byte) *node.Node { locationNode.Children["type"] = node.New("type", generalNameType(contextTag)) locationNode.Children["tag"] = node.New("tag", contextTag) - // For URI (tag 6), extract the URI string - if contextTag == 6 { + switch contextTag { + case 6: uri := string(location) locationNode.Children["value"] = node.New("value", uri) - // Extract scheme for convenience if strings.Contains(uri, ":") { scheme := strings.Split(uri, ":")[0] locationNode.Children["scheme"] = node.New("scheme", scheme) } - } else if contextTag == 2 { - // DNS name + case 2: locationNode.Children["value"] = node.New("value", string(location)) - } else if contextTag == 7 { - // IP address - 4 bytes for IPv4, 16 bytes for IPv6 + case 7: if len(location) == 4 || len(location) == 16 { locationNode.Children["value"] = node.New("value", location) } - } else { - // Other types - store raw bytes + default: locationNode.Children["value"] = node.New("value", location) } @@ -339,9 +329,6 @@ func decodeReasonFlags(n *node.Node, bytes []byte, unusedBits int) { } } -// Certificate Policies OID: 2.5.29.32 -const certPoliciesOID = "2.5.29.32" - // Policy Qualifier Type OIDs const ( idQtCps = "1.3.6.1.5.5.7.2.1" // CPS URI diff --git a/internal/cert/zcrypto/extension_parser_test.go b/internal/cert/zcrypto/extension_parser_test.go index c3189a2..8b3777b 100644 --- a/internal/cert/zcrypto/extension_parser_test.go +++ b/internal/cert/zcrypto/extension_parser_test.go @@ -73,7 +73,7 @@ func TestParseAIA(t *testing.T) { checkFunc: func(n *node.Node) bool { // Check count if countNode, ok := n.Children["count"]; ok { - if countNode.Value.(int) != 2 { + if v, ok2 := countNode.Value.(int); !ok2 || v != 2 { return false } } @@ -84,7 +84,8 @@ func TestParseAIA(t *testing.T) { } // Check accessMethod method, ok := ad0.Children["accessMethod"] - if !ok || method.Value.(string) != "1.3.6.1.5.5.7.48.1" { + methodStr, methodOk := method.Value.(string) + if !ok || !methodOk || methodStr != "1.3.6.1.5.5.7.48.1" { return false } // Check accessLocation type is URI @@ -93,15 +94,18 @@ func TestParseAIA(t *testing.T) { return false } locType, ok := loc.Children["type"] - if !ok || locType.Value.(string) != "uniformResourceIdentifier" { + locTypeStr, locTypeOk := locType.Value.(string) + if !ok || !locTypeOk || locTypeStr != "uniformResourceIdentifier" { return false } locTag, ok := loc.Children["tag"] - if !ok || locTag.Value.(int) != 6 { + locTagInt, locTagOk := locTag.Value.(int) + if !ok || !locTagOk || locTagInt != 6 { return false } scheme, ok := loc.Children["scheme"] - if !ok || scheme.Value.(string) != "http" { + schemeStr, schemeOk := scheme.Value.(string) + if !ok || !schemeOk || schemeStr != "http" { return false } return true @@ -125,7 +129,8 @@ func TestParseAIA(t *testing.T) { return false } // Should be dNSName, not uniformResourceIdentifier - return locType.Value.(string) == "dNSName" + s, ok := locType.Value.(string) + return ok && s == "dNSName" }, expected: true, }, @@ -137,7 +142,8 @@ func TestParseAIA(t *testing.T) { if !ok { return false } - return empty.Value.(bool) == true + v, ok := empty.Value.(bool) + return ok && v }, expected: true, }, @@ -149,7 +155,8 @@ func TestParseAIA(t *testing.T) { if !ok { return false } - return hasOCSP.Value.(bool) == true + v, ok := hasOCSP.Value.(bool) + return ok && v }, expected: true, }, @@ -161,7 +168,8 @@ func TestParseAIA(t *testing.T) { if !ok { return false } - return hasCaIssuers.Value.(bool) == true + v, ok := hasCaIssuers.Value.(bool) + return ok && v }, expected: true, }, @@ -265,7 +273,8 @@ func TestParseCRLDP(t *testing.T) { checkFunc: func(n *node.Node) bool { // Check not empty empty, ok := n.Children["empty"] - if ok && empty.Value.(bool) { + emptyVal, ok2 := empty.Value.(bool) + if ok && ok2 && emptyVal { return false } // Check first distributionPoint @@ -275,17 +284,20 @@ func TestParseCRLDP(t *testing.T) { } // Check hasFullName hasFullName, ok := dp0.Children["hasFullName"] - if !ok || !hasFullName.Value.(bool) { + hasFullNameVal, ok2 := hasFullName.Value.(bool) + if !ok || !ok2 || !hasFullNameVal { return false } // Check no reasons hasReasons, ok := dp0.Children["hasReasons"] - if ok && hasReasons.Value.(bool) { + hasReasonsVal, ok2 := hasReasons.Value.(bool) + if ok && ok2 && hasReasonsVal { return false } // Check no cRLIssuer hasCRLIssuer, ok := dp0.Children["hasCRLIssuer"] - if ok && hasCRLIssuer.Value.(bool) { + hasCRLIssuerVal, ok2 := hasCRLIssuer.Value.(bool) + if ok && ok2 && hasCRLIssuerVal { return false } // Check URI type @@ -302,11 +314,13 @@ func TestParseCRLDP(t *testing.T) { return false } gnType, ok := gn0.Children["type"] - if !ok || gnType.Value.(string) != "uniformResourceIdentifier" { + gnTypeStr, ok2 := gnType.Value.(string) + if !ok || !ok2 || gnTypeStr != "uniformResourceIdentifier" { return false } scheme, ok := gn0.Children["scheme"] - if !ok || scheme.Value.(string) != "http" { + schemeStr, ok2 := scheme.Value.(string) + if !ok || !ok2 || schemeStr != "http" { return false } return true @@ -325,7 +339,8 @@ func TestParseCRLDP(t *testing.T) { if !ok { return false } - return hasReasons.Value.(bool) == true + v, ok := hasReasons.Value.(bool) + return ok && v }, expected: true, }, @@ -341,7 +356,8 @@ func TestParseCRLDP(t *testing.T) { if !ok { return false } - return hasCRLIssuer.Value.(bool) == true + v, ok := hasCRLIssuer.Value.(bool) + return ok && v }, expected: true, }, @@ -357,7 +373,8 @@ func TestParseCRLDP(t *testing.T) { if !ok { return false } - return empty.Value.(bool) == true + v, ok := empty.Value.(bool) + return ok && v }, expected: true, }, diff --git a/internal/cert/zcrypto/parser.go b/internal/cert/zcrypto/parser.go index cfc0f81..1e82532 100644 --- a/internal/cert/zcrypto/parser.go +++ b/internal/cert/zcrypto/parser.go @@ -323,7 +323,8 @@ func TimeToUTCTime(t time.Time) []byte { // encodeASN1String creates a DER-encoded ASN.1 string. func encodeASN1String(tag int, value string) []byte { length := len(value) - result := []byte{byte(tag), byte(length)} + result := make([]byte, 0, 2+len(value)) + result = append(result, byte(tag), byte(length)) result = append(result, []byte(value)...) return result } @@ -367,14 +368,13 @@ func ParseRawCertificateTimes(rawCert []byte) (notBefore, notAfter time.Time, no } notBeforeTag = int(nbTag) - // Parse the time value - if notBeforeTag == 23 { + switch notBeforeTag { + case 23: var utcTime string if _, err := stdasn1.Unmarshal([]byte(nb), &utcTime); err == nil { - // Parse YYMMDDHHMMSSZ notBefore, _ = time.Parse("060102150405Z", utcTime) } - } else if notBeforeTag == 24 { + case 24: var genTime string if _, err := stdasn1.Unmarshal([]byte(nb), &genTime); err == nil { notBefore, _ = time.Parse("20060102150405Z", genTime) @@ -389,12 +389,13 @@ func ParseRawCertificateTimes(rawCert []byte) (notBefore, notAfter time.Time, no } notAfterTag = int(naTag) - if notAfterTag == 23 { + switch notAfterTag { + case 23: var utcTime string if _, err := stdasn1.Unmarshal([]byte(na), &utcTime); err == nil { notAfter, _ = time.Parse("060102150405Z", utcTime) } - } else if notAfterTag == 24 { + case 24: var genTime string if _, err := stdasn1.Unmarshal([]byte(na), &genTime); err == nil { notAfter, _ = time.Parse("20060102150405Z", genTime) diff --git a/internal/crl/crl.go b/internal/crl/crl.go index d9d5ecd..02f7a01 100644 --- a/internal/crl/crl.go +++ b/internal/crl/crl.go @@ -1,3 +1,4 @@ +// Package crl provides CRL data types and properties. package crl import ( diff --git a/internal/crl/zcrypto/builder.go b/internal/crl/zcrypto/builder.go index 9e55449..222fee3 100644 --- a/internal/crl/zcrypto/builder.go +++ b/internal/crl/zcrypto/builder.go @@ -1,3 +1,4 @@ +// Package zcrypto provides zcrypto-based CRL parsing. package zcrypto import ( diff --git a/internal/data/loader.go b/internal/data/loader.go index 332bda3..d85425f 100644 --- a/internal/data/loader.go +++ b/internal/data/loader.go @@ -125,7 +125,7 @@ func parsePSLFile(filePath string) (*PSL, error) { if err != nil { return nil, err } - defer file.Close() + defer func() { _ = file.Close() }() psl := &PSL{ ICANNDomains: make(map[string]bool), @@ -270,7 +270,7 @@ func DownloadPSL(url string, destPath string) error { if err != nil { return fmt.Errorf("failed to download PSL: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to download PSL: HTTP %d", resp.StatusCode) @@ -287,7 +287,7 @@ func DownloadPSL(url string, destPath string) error { if err != nil { return fmt.Errorf("failed to create file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() _, err = io.Copy(file, resp.Body) if err != nil { diff --git a/internal/data/loader_test.go b/internal/data/loader_test.go index e22e6af..6db3a07 100644 --- a/internal/data/loader_test.go +++ b/internal/data/loader_test.go @@ -77,7 +77,9 @@ net github.io // ===END PRIVATE DOMAINS=== ` - os.WriteFile(testFile, []byte(content), 0644) + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } loader := &Loader{dataDir: tmpDir} if err := loader.LoadPSL(testFile); err != nil { @@ -115,7 +117,9 @@ uk co.uk // ===END ICANN DOMAINS=== ` - os.WriteFile(testFile, []byte(content), 0644) + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } loader := &Loader{dataDir: tmpDir} if err := loader.LoadPSL(testFile); err != nil { @@ -186,7 +190,9 @@ func TestParsePSLWithCommentsAndWhitespace(t *testing.T) { github.io // ===END PRIVATE DOMAINS=== ` - os.WriteFile(testFile, []byte(content), 0644) + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } psl, err := parsePSLFile(testFile) if err != nil { @@ -215,7 +221,9 @@ func TestPSLWildcardDomains(t *testing.T) { *.jp // ===END ICANN DOMAINS=== ` - os.WriteFile(testFile, []byte(content), 0644) + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } psl, err := parsePSLFile(testFile) if err != nil { diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index 2b48591..4270b9f 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -1,3 +1,4 @@ +// Package evaluator provides certificate evaluation against policies. package evaluator import ( diff --git a/internal/io/walker.go b/internal/io/walker.go index 1b4ff6c..90e1b1b 100644 --- a/internal/io/walker.go +++ b/internal/io/walker.go @@ -1,3 +1,4 @@ +// Package io provides filesystem walker utilities. package io import ( diff --git a/internal/linter/config.go b/internal/linter/config.go index c769d23..120438a 100644 --- a/internal/linter/config.go +++ b/internal/linter/config.go @@ -1,3 +1,4 @@ +// Package linter provides PCL lint runner orchestration. package linter import "time" diff --git a/internal/loader/loader.go b/internal/loader/loader.go index 1554e6a..c7b9292 100644 --- a/internal/loader/loader.go +++ b/internal/loader/loader.go @@ -1,3 +1,4 @@ +// Package loader provides certificate and policy loading. package loader import ( diff --git a/internal/node/node.go b/internal/node/node.go index c0fa92f..58bf577 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -1,3 +1,4 @@ +// Package node provides a tree node type for representing certificate field values. package node type Node struct { diff --git a/internal/node/print.go b/internal/node/print.go index 22d5306..37b65c0 100644 --- a/internal/node/print.go +++ b/internal/node/print.go @@ -28,7 +28,7 @@ func printNode(b *strings.Builder, n *Node, prefix string, last bool) { b.WriteString(n.Name) if n.Value != nil { b.WriteString(": ") - b.WriteString(fmt.Sprintf("%v", n.Value)) + fmt.Fprintf(b, "%v", n.Value) } b.WriteString("\n") diff --git a/internal/ocsp/fetch.go b/internal/ocsp/fetch.go index 3ef50c4..ad3152f 100644 --- a/internal/ocsp/fetch.go +++ b/internal/ocsp/fetch.go @@ -58,7 +58,7 @@ func postOCSPRequest(url string, req []byte, timeout time.Duration) (*ocsp.Respo if err != nil { return nil, fmt.Errorf("failed to send OCSP request: %w", err) } - defer httpResp.Body.Close() + defer func() { _ = httpResp.Body.Close() }() if httpResp.StatusCode != http.StatusOK { return nil, fmt.Errorf("OCSP server returned status %d", httpResp.StatusCode) diff --git a/internal/ocsp/ocsp.go b/internal/ocsp/ocsp.go index 6241b48..073e529 100644 --- a/internal/ocsp/ocsp.go +++ b/internal/ocsp/ocsp.go @@ -1,3 +1,4 @@ +// Package ocsp provides OCSP request building and response parsing. package ocsp import ( diff --git a/internal/ocsp/zcrypto/builder.go b/internal/ocsp/zcrypto/builder.go index f8820e5..efc5df9 100644 --- a/internal/ocsp/zcrypto/builder.go +++ b/internal/ocsp/zcrypto/builder.go @@ -1,3 +1,4 @@ +// Package zcrypto provides zcrypto-based OCSP response parsing. package zcrypto import ( diff --git a/internal/oid/oid.go b/internal/oid/oid.go index fe65b34..f237c04 100644 --- a/internal/oid/oid.go +++ b/internal/oid/oid.go @@ -1,3 +1,4 @@ +// Package oid provides X.509 OID constants and lookups. package oid import "github.com/zmap/zcrypto/x509" diff --git a/internal/operator/operator.go b/internal/operator/operator.go index 8c67ea5..9926a29 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -1,3 +1,4 @@ +// Package operator provides rule operators for evaluating certificate field values. package operator import "github.com/cavoq/PCL/internal/node" diff --git a/internal/operator/utf8_test.go b/internal/operator/utf8_test.go index 97b6261..8050187 100644 --- a/internal/operator/utf8_test.go +++ b/internal/operator/utf8_test.go @@ -11,7 +11,8 @@ func TestUTF8NoBOM(t *testing.T) { op := UTF8NoBOM{} // UTF-8 BOM byte sequence: EF BB BF - utf8BOM := []byte{0xEF, 0xBB, 0xBF} + utf8BOM := make([]byte, 0, 17) + utf8BOM = append(utf8BOM, 0xEF, 0xBB, 0xBF) tests := []struct { name string diff --git a/internal/output/result.go b/internal/output/result.go index f0b4a97..78f0374 100644 --- a/internal/output/result.go +++ b/internal/output/result.go @@ -1,3 +1,4 @@ +// Package output provides lint result formatting and output. package output import ( @@ -25,8 +26,9 @@ func FromPolicyResults(policyResults []policy.Result) LintOutput { var passed, failed, skipped, totalRules int for i := range policyResults { + pr := &policyResults[i] counts := policy.Counts{} - for _, rr := range policyResults[i].Results { + for _, rr := range pr.Results { totalRules++ switch rr.Verdict { case rule.VerdictPass: @@ -43,7 +45,7 @@ func FromPolicyResults(policyResults []policy.Result) LintOutput { counts.Skipped++ } } - policyResults[i].Counts = counts + pr.Counts = counts } checkedAt := time.Now() diff --git a/internal/policy/parser.go b/internal/policy/parser.go index 4090bb8..b06d061 100644 --- a/internal/policy/parser.go +++ b/internal/policy/parser.go @@ -1,3 +1,4 @@ +// Package policy provides policy file parsing and evaluation engine. package policy import ( diff --git a/internal/policy/parser_test.go b/internal/policy/parser_test.go index f131f48..648a93b 100644 --- a/internal/policy/parser_test.go +++ b/internal/policy/parser_test.go @@ -163,7 +163,9 @@ rules: operator: eq operands: [3] `) - os.WriteFile(path, data, 0644) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } p, err := ParseFile(path) if err != nil { @@ -341,9 +343,15 @@ rules: operands: [3] `) - os.WriteFile(filepath.Join(dir, "p1.yaml"), p1, 0644) - os.WriteFile(filepath.Join(dir, "p2.yml"), p2, 0644) - os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("not yaml"), 0644) + if err := os.WriteFile(filepath.Join(dir, "p1.yaml"), p1, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "p2.yml"), p2, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("not yaml"), 0644); err != nil { + t.Fatal(err) + } policies, err := ParseDir(dir) if err != nil { @@ -371,8 +379,12 @@ rules: operator: eq operands: [3] `) - os.WriteFile(filepath.Join(dir, "p.yaml"), p, 0644) - os.Mkdir(filepath.Join(dir, "subdir"), 0755) + if err := os.WriteFile(filepath.Join(dir, "p.yaml"), p, 0644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil { + t.Fatal(err) + } policies, err := ParseDir(dir) if err != nil { diff --git a/internal/rule/rule.go b/internal/rule/rule.go index d19b6e4..d6cb5a7 100644 --- a/internal/rule/rule.go +++ b/internal/rule/rule.go @@ -1,3 +1,4 @@ +// Package rule provides rule types and verdict constants. package rule type Condition struct { diff --git a/internal/source/source.go b/internal/source/source.go index 2668990..e3d8ee8 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -1,3 +1,4 @@ +// Package source provides certificate source metadata. package source type Type string diff --git a/internal/zcrypto/helpers.go b/internal/zcrypto/helpers.go index 35ffd0f..7423645 100644 --- a/internal/zcrypto/helpers.go +++ b/internal/zcrypto/helpers.go @@ -1,3 +1,4 @@ +// Package zcrypto provides zcrypto conversion helpers. package zcrypto import ( diff --git a/tests/certs/chain.pem b/tests/certs/chain.pem new file mode 100644 index 0000000..1eaf7fd --- /dev/null +++ b/tests/certs/chain.pem @@ -0,0 +1,106 @@ +-----BEGIN CERTIFICATE----- +MIIFojCCA4qgAwIBAgIUYJ4iMHK1wAHlrmubNC1/sTfIK8swDQYJKoZIhvcNAQEL +BQAweTELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEPMA0GA1UEBwwGQmVy +bGluMRMwEQYDVQQKDApFeGFtcGxlT3JnMRUwEwYDVQQLDAxJbnRlcm1lZGlhdGUx +HDAaBgNVBAMME0JTSSBJbnRlcm1lZGlhdGUgQ0EwHhcNMjUxMjIwMTI0NTU1WhcN +MjgwMzI0MTI0NTU1WjBvMQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8w +DQYDVQQHDAZCZXJsaW4xEzARBgNVBAoMCkV4YW1wbGVPcmcxDTALBgNVBAsMBExl +YWYxGjAYBgNVBAMMEWxlYWYuZXhhbXBsZS50ZXN0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAtDmmWsCpS7QdFT69sJpuj447VHSsmzT6MO36xfoKjFvf +2/N2DqcVH1Y1rHaHGeiAKrNxJVVz2HO4tYmLZdqfVoA2VEqQJALobCI3laI1zaHs +GhiA280em83QXWzU1qozDcX6Ro3sWj+kWyUFuJmX/pzz9b+Y9ihVgj2XRsLH1FPD +lwljjnU8ld4fGPlLbweoxYX56ZWKY8BqRZ9X1YSQmJJNDQfNUgszW+We2J+makS6 +a4nbSg1ct4ChYhsInX/r7SQo7aMcNdjnS4Of5W2De46pLCapQ40zsF5zrxPqqe/j +3c8vTZro5Okb81u9YZWaZ9DymTd/IXEfD2xO7nMnvwIDAQABo4IBKjCCASYwCQYD +VR0TBAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHAYD +VR0RBBUwE4IRbGVhZi5leGFtcGxlLnRlc3QwHwYDVR0jBBgwFoAUmMfGRI2JP0xl +4iy2Er4RXKjBC4wwMAYDVR0fBCkwJzAloCOgIYYfaHR0cDovL2NybC5leGFtcGxl +LnRlc3QvYnNpLmNybDBkBggrBgEFBQcBAQRYMFYwJAYIKwYBBQUHMAGGGGh0dHA6 +Ly9vY3NwLmV4YW1wbGUudGVzdDAuBggrBgEFBQcwAoYiaHR0cDovL2NydC5leGFt +cGxlLnRlc3QvaXNzdWVyLmNydDAdBgNVHQ4EFgQUS1jhRNqInABF12++Hcf9ryS6 +nVkwDQYJKoZIhvcNAQELBQADggIBAH0/wlTBWZgQ2ODougvPDQdTA7fN/Ofcy3XE +26CbpO/X/9Js1k2uCWVrfCGPbLX4/PJgYiE7y+ZoHDRXfiWQTtWI29MCTGlFW7ve +MoDiKfb4oZX634+BI7prr+OOxf9Kw5b/jlTQMtBuWZbnJCxl638BYeU/XnSp5v02 +1wBxoaWYLF2FHwLXHCuzMAbqEEl1gr6ClLW2i1eIJn6TyaV4rgBKyvw9Yo9Zsjyn +vw/AMgKpF0YQSKHECWBVgVdHqJRo0o4NA+NNewoDvqX5H/QNum3cWWnt40gdppYQ +MI6rmLm+6Mw+sh7pqfr5jwfbwiYYTlAByC/uqpxU9uKoJmOJigE/rce3aOKTeTSd +SVIMVzt1dQEH5kh8i7oJz1FevOua8J5CHEgRPjWFr6NNp0AwiDLEre50L1TMttoK +4tjZEgmcDnqlYf6eJOMWclNCqDm0N6ceTEsXQ6R/Yfm0YStFlDpNLkKExe1HFEJD +Xr9aL3kag0Njtb3GGB+xQGCdp84T1KTouK38tzo4EdhUzs7ty8Mwk9BeRrmTd1fc +KXRKFkOaGCa01ISVWl83RY2H5eTeCsBLTRYvY3KoRj8WILuteA+TcWkMeoQ+GWo8 ++wA6xuCqSQM0Q1ICpTHP92HcILFP2nWE2dJ/c7knmDQInG0dKbDPEOXtj9tOuKTe +Wh0Qp7Kb +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGcDCCBFigAwIBAgIURuxES1WsBT0oFfGkcgnMvEWd+PQwDQYJKoZIhvcNAQEL +BQAwaTELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEPMA0GA1UEBwwGQmVy +bGluMRMwEQYDVQQKDApFeGFtcGxlT3JnMQ0wCwYDVQQLDARSb290MRQwEgYDVQQD +DAtCU0kgUm9vdCBDQTAeFw0yNTEyMjAxMjQ1NTVaFw0zMDEyMTkxMjQ1NTVaMHkx +CzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJlcmxpbjET +MBEGA1UECgwKRXhhbXBsZU9yZzEVMBMGA1UECwwMSW50ZXJtZWRpYXRlMRwwGgYD +VQQDDBNCU0kgSW50ZXJtZWRpYXRlIENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEAnxiDecKP2Ip9H8CtYb+y5Ekufc4HF5o2PN4DaF9GTKmF3Voww2g+ +l4iFxgst62wlaOF0qN6sZIExkiFGPNZOYFS4II95/7HeC2FBynJRQY2B9nCHrPI9 +ihUCj25GjTMLDsIyOtAXMll9fe+NUyRVyTE2f2cdsmrduK/xBtaYUqhV5NmP1awW +y90I3fQWnLriaPK1GtwmhD4CdVXIAIrZyk92JtJJJO5kNOokJr/bAXYJOka1d41F +rhIe3EJEWMlvR/k4SV63Rmx3IoGBZFXyJv2jkVa2c+QfLli7UwuKiVtYg7e0cnVE +z/D77gXGiKzgxancsx84OeChO4CNB25lo/Km8KAlK5RcTHazk/v0G8Geby+bRjmE +fpYkoPEOP685Gibw9XMozg7EKIpx7gw5L6BGneMbPdI1fLuwP8txwIh7SF4eNSZg +/FVPSKxGSqTnOMH/v1AB9Jg0FambXE6NNHqywHkPCMLzBkBzWCzktucmj16fPTQq +3NeF5Tgqz41BcLV1zw7YupMYQLABEi3kOBsQRomhBzInAu024SqyBFvSdho7IdT8 +MsvJ2e4qbeILcTqXsr1ouV4gnOsN7HFNIGnxZjw0IyIC5/AkVpLqhQuyZ4TVBjVU +qg4zx5Wg2BVH0VoamAgou7zdUaXlo980+eRnIOc61m6LMw2Tb+gEbzsCAwEAAaOB +/zCB/DASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4E +FgQUmMfGRI2JP0xl4iy2Er4RXKjBC4wwHwYDVR0jBBgwFoAU14eO/Au1BU/RtuLJ +rYYROXOL/6swMAYDVR0fBCkwJzAloCOgIYYfaHR0cDovL2NybC5leGFtcGxlLnRl +c3QvYnNpLmNybDBkBggrBgEFBQcBAQRYMFYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9v +Y3NwLmV4YW1wbGUudGVzdDAuBggrBgEFBQcwAoYiaHR0cDovL2NydC5leGFtcGxl +LnRlc3QvaXNzdWVyLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAJzrOItxJmOsAl62T +UoPitJBTBKlM9NSd/B/6nxiLMvTyISKGw4diqaaNhCVjbf5ST3K9hDvVMxzjHPbO +lB96CxzSLMYhiGIrm2UUHiCoj0Fj9qO1Ht3p8LcVsmifRo12dd7F7ILOMlDViCdj +GqZytw+UO5GIpz6vDR8kZfKO/ui9M0XZ5zcVAVSFNqrtvdo0srJCLFfwCmicGNzT +Ujd+QmqcN8G4eALqI14C1a9NnO8l1VJSrs+GZDvBz+7onaDifKiJ3fyqTFG5YRL9 +96MLCM/TKFxTQBepP0j7NergfOqy+E09cXUO3k52zZRYYeHYn/aoDtAQiAMuO1BR +4zyuMYlkoRw2DVOgtOo2KzwQtleDHlOZyDmSOpJu2/y4w/uPs3OqNcuZyxpwoa/H +TJXcTyDaBGcLciJbPbjYZNdPdcOEyGDKICzBMxoAYw11FHDXwu2xj4QVJ9idhd1S +A63BA9azN6mULpX9up3gE+jzmbL9m3etPhERMF2kIBcDW09u7QTEMrjsu6ErdM/C +mimTfCIsInVeaFKN+jMLVOo27YDxMvfe9DYkBnhgw6LwYroA6gSVDP762XNfm5OX +txw2o5GWaBk3ETrhyPnIK+I5mAG/mauLsOIb6c3c36UVNWCWWQapj+47IpT0ogxF +R4YfAWgzAGEaG/oApv6/bda5iP4= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGPzCCBCegAwIBAgIUah61Yw2ktBMOj2+7DDOVdCRiNtQwDQYJKoZIhvcNAQEL +BQAwaTELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEPMA0GA1UEBwwGQmVy +bGluMRMwEQYDVQQKDApFeGFtcGxlT3JnMQ0wCwYDVQQLDARSb290MRQwEgYDVQQD +DAtCU0kgUm9vdCBDQTAeFw0yNTEyMjAxMjQ1NTRaFw0zMTEyMTkxMjQ1NTRaMGkx +CzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJlcmxpbjET +MBEGA1UECgwKRXhhbXBsZU9yZzENMAsGA1UECwwEUm9vdDEUMBIGA1UEAwwLQlNJ +IFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwJq2st/E8 +Ue92h1ntZcHulrNuBHixs+UoOwcamNynDdYS5CSe2bz2mej1BhZS57Ubb9KGFvhg +JWCxGULfWDocZ5tV9yi+b9lM2GwQoHtU4j4Fv5yijcw0njABxWH65V16eB9oSd26 +L8a4ESvOwEkoWBJjtSpgoktHq8ICj3KhX3asZ9sEMPQ5iTwB+aTAx7izg4y9wptZ +F47oKBLBiR6nluYFWwrZ64+u8rIds7keyRUbUmuz5oRELOGEYWwz9A2/uBV8zWd+ +fWGP70teEcviYP7BY6nqtlMk/HRjRcU3LCm5PQ44WPsPtUB32pT/VcYYvspLI54Z +VLuTZ+BOyJAIao7s7nL7RaHNC//f6cE49mYVqjH5XPj7Q3yjGu/DB/MIGGO16Yku +8rkYg4H7R1cN+DFfkJB61L9WOx8NEkh1WCBBr/aym6pP7liX+KCpT+v9HgOHopBf +Mwubf7oonUFP85Zy0Pin8FNVfk6MiaRA22tGiGKmsYZXlMgAAz+UbPl1QZZGttQz +9wNJwvv5qSuSfDCye3tZXx9rINPUm09koYS1+O/5jfP5h7S3Br/zLJZshPRbMJt2 +QA4CZTXDcxDk//L7rrELW2Hj+ajegPl1g4fFQ13RZjdLq05w6FrKGi5z2bPezFBF +IA+kCnUB89qEIW2foQGTQ68HwExpxdd0UwIDAQABo4HeMIHbMBIGA1UdEwEB/wQI +MAYBAf8CAQIwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTXh478C7UFT9G24smt +hhE5c4v/qzAwBgNVHR8EKTAnMCWgI6Ahhh9odHRwOi8vY3JsLmV4YW1wbGUudGVz +dC9ic2kuY3JsMGQGCCsGAQUFBwEBBFgwVjAkBggrBgEFBQcwAYYYaHR0cDovL29j +c3AuZXhhbXBsZS50ZXN0MC4GCCsGAQUFBzAChiJodHRwOi8vY3J0LmV4YW1wbGUu +dGVzdC9pc3N1ZXIuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQB5FO1YPJb4xGLouyFX +4KdNi3O4BHzJqIVRn6hYvbFgDXUCr9Uw1QbXSC1ZaVzyLxDJsFoyVqnRCdAs+vxW +CtrPb3sLkKqEJDmAWkmhQ03oXcPsM+kq+qTHTS1a2pannHOxtEk++VegrVUOO7AS +SgyWmC299QOVEyGpUHHkqJ2ls/JQ/4DphpqV1mTJ9bnoroxBIDs+Nf3Lu16Sq8TU +ugtK2Ckw2w9wBMxFOnuJa9+DQaHcDGYfF2Flj6TwDB1CokVFtIG5A0qZah1aXLcv +H86FgOAWCIabpHyXJyFYBlMsTch5UbWTPdqFQx9aFuVkFQOs7C4Hvc4jUByWrgya +7cKAB9ZWguE3QtgEqU1xpmByhvG/IROWeyFy6XX66t3tMY0gCMS/k50tw1w41jje +dLsj6CHB9qZyI+77D8tmGa5ErkcjgRMoGo9Cnq+7vouQxgI3eFVhLoT+naxeL0eY +5N6+m/4lHG8q+oiE0yvK22UFW2y1I02AIxqPOn4eZwRXJL79TYEMnyGBsg5A/ZVM +068W9hO1xFhvOBFTnoyDi+q9Je9C8wwMX9S3d6trZj53e6S+WiackccX/2lSPF36 +Dy84Ts3DpDySj/2cOP/5RAr4Sz9w22M4k+bDcfiA10HirZFkf4jyus1LdCAarv4z +Mc5hHIY3Xh7KPoUPSETmFRLvQA== +-----END CERTIFICATE----- From 7aeded33b6069c899b989652e382c9dac492ffb5 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 23:41:02 +0200 Subject: [PATCH 43/44] fix: appliesTo -> certType --- internal/rule/evaluator.go | 1 + policies/RFC5280.yaml | 94 +++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/internal/rule/evaluator.go b/internal/rule/evaluator.go index ad5a656..caccbfa 100644 --- a/internal/rule/evaluator.go +++ b/internal/rule/evaluator.go @@ -206,3 +206,4 @@ func isKeyUsageBooleanField(target string) bool { return slices.Contains(keyUsageFields, target) } + diff --git a/policies/RFC5280.yaml b/policies/RFC5280.yaml index e850526..73b49ae 100644 --- a/policies/RFC5280.yaml +++ b/policies/RFC5280.yaml @@ -91,14 +91,14 @@ rules: reference: RFC5280 4.1.2.6 target: certificate.subject operator: notEmpty - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error - id: issuer-matches-subject-for-root reference: RFC5280 6 target: certificate operator: issuedBy - appliesTo: [root] + certType: [root] severity: error @@ -155,7 +155,7 @@ rules: reference: RFC5280 4.2.1.1 target: certificate.authorityKeyIdentifier operator: present - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] severity: warning - id: aki-not-critical @@ -183,7 +183,7 @@ rules: reference: RFC5280 4.2.1.2 target: certificate.subjectKeyIdentifier operator: present - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: warning - id: ski-not-critical @@ -223,14 +223,14 @@ rules: target: certificate.keyUsage.keyCertSign operator: eq operands: [true] - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error - id: leaf-key-usage-valid reference: RFC5280 4.2.1.3 target: certificate operator: keyUsageLeaf - appliesTo: [leaf] + certType: [leaf] severity: error @@ -257,7 +257,7 @@ rules: reference: RFC5280 4.2.1.6 target: certificate operator: sanRequiredIfEmptySubject - appliesTo: [leaf] + certType: [leaf] severity: error - id: san-critical-if-subject-empty @@ -309,7 +309,7 @@ rules: reference: RFC5280 4.2.1.9 target: certificate.basicConstraints operator: present - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error - id: basic-constraints-critical-for-ca @@ -328,7 +328,7 @@ rules: target: certificate.basicConstraints.cA operator: eq operands: [true] - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error - id: leaf-not-ca @@ -336,14 +336,14 @@ rules: target: certificate.basicConstraints.cA operator: neq operands: [true] - appliesTo: [leaf] + certType: [leaf] severity: error - id: ca-path-len-valid reference: RFC5280 4.2.1.9 target: certificate operator: pathLenValid - appliesTo: [root, intermediate] + certType: [root, intermediate] severity: error @@ -633,7 +633,7 @@ rules: operands: [64] severity: error message: "commonName must not exceed 64 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # organizationName max 64 characters (RFC 5280) - id: subject-org-max-length @@ -646,7 +646,7 @@ rules: operands: [64] severity: error message: "organizationName must not exceed 64 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # organizationalUnitName max 64 characters (RFC 5280) - id: subject-ou-max-length @@ -659,7 +659,7 @@ rules: operands: [64] severity: error message: "organizationalUnitName must not exceed 64 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # localityName max 128 characters (RFC 5280) - id: subject-locality-max-length @@ -672,7 +672,7 @@ rules: operands: [128] severity: error message: "localityName must not exceed 128 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # stateOrProvinceName max 128 characters (RFC 5280) - id: subject-state-max-length @@ -685,7 +685,7 @@ rules: operands: [128] severity: error message: "stateOrProvinceName must not exceed 128 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # countryName exactly 2 characters (ISO 3166-1) - id: subject-country-length @@ -698,7 +698,7 @@ rules: operands: [2] severity: error message: "countryName must be exactly 2 characters (ISO 3166-1)" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] - id: subject-country-min-length reference: RFC5280 Appendix A.1 + ISO 3166-1 @@ -710,7 +710,7 @@ rules: operands: [2] severity: error message: "countryName must be exactly 2 characters (ISO 3166-1)" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # serialNumber (subject) max 64 characters (CABF BR) - id: subject-serial-number-max-length @@ -723,7 +723,7 @@ rules: operands: [64] severity: error message: "subject serialNumber must not exceed 64 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # givenName max 64 characters (RFC 5280) - id: subject-givenname-max-length @@ -736,7 +736,7 @@ rules: operands: [64] severity: error message: "givenName must not exceed 64 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # surname max 64 characters (RFC 5280) - id: subject-surname-max-length @@ -749,7 +749,7 @@ rules: operands: [64] severity: error message: "surname must not exceed 64 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # businessCategory max 128 characters (RFC 5280) - id: subject-business-category-max-length @@ -762,7 +762,7 @@ rules: operands: [128] severity: error message: "businessCategory must not exceed 128 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # emailAddress in subject max 255 characters (RFC 5280) - id: subject-email-max-length @@ -775,7 +775,7 @@ rules: operands: [255] severity: error message: "subject emailAddress must not exceed 255 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # emailAddress format validation (RFC 822) - id: subject-email-format @@ -788,7 +788,7 @@ rules: operands: ["^[^@]+@[^@]+$"] severity: warning message: "subject emailAddress should be valid RFC 822 format" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # SAN rfc822Name format validation (RFC 822) - id: san-rfc822-name-format @@ -801,7 +801,7 @@ rules: operands: ["^[^@]+@[^@]+$"] severity: error message: "SAN rfc822Name must be valid RFC 822 email format" - appliesTo: [leaf] + certType: [leaf] # IAN rfc822Name format validation (RFC 822) - id: ian-rfc822-name-format @@ -814,7 +814,7 @@ rules: operands: ["^[^@]+@[^@]+$"] severity: warning message: "IAN rfc822Name should be valid RFC 822 email format" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # IAN dNSName must not contain wildcards (RFC 5280 4.2.1.6) - id: ian-no-wildcard-dns @@ -827,7 +827,7 @@ rules: operands: ["^\\*"] severity: warning message: "IAN dNSName should not contain wildcard" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # IAN dNSName must be valid DNS label format (RFC 5280 + RFC 9549) - id: ian-dns-valid-label @@ -840,7 +840,7 @@ rules: operands: ["^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$"] severity: warning message: "IAN dNSName components must be valid DNS labels" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # IAN URI must use HTTP or HTTPS scheme (best practice) - id: ian-uri-http-scheme @@ -853,7 +853,7 @@ rules: operands: ["^https?://"] severity: warning message: "IAN URI should use HTTP or HTTPS scheme" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # IAN must not contain internal names (RFC 5280 best practice) - id: ian-no-internal-names @@ -865,7 +865,7 @@ rules: operator: componentTLDRegistered severity: warning message: "IAN dNSName should not contain internal names with unregistered TLDs" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # ================================================== # URI Validation (RFC 5280 4.2.1.6) @@ -882,7 +882,7 @@ rules: operands: ["^https?://"] severity: warning message: "SAN URI should use HTTP or HTTPS scheme" - appliesTo: [leaf] + certType: [leaf] # SAN URI must not contain fragment identifier - id: san-uri-no-fragment @@ -895,7 +895,7 @@ rules: operands: ["#"] severity: warning message: "SAN URI should not contain fragment identifier" - appliesTo: [leaf] + certType: [leaf] # SAN URI max length (reasonable limit) - id: san-uri-max-length @@ -908,7 +908,7 @@ rules: operands: [1024] severity: warning message: "SAN URI should not exceed 1024 characters" - appliesTo: [leaf] + certType: [leaf] # domainComponent max 63 characters per label (DNS label limit) - id: subject-dc-label-max-length @@ -921,7 +921,7 @@ rules: operands: [63, "."] severity: error message: "Each domainComponent label must not exceed 63 characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # ------------------------------------------------- # Time Format Validation (RFC 5280 4.1.2.5) @@ -939,7 +939,7 @@ rules: operator: utctimeHasSeconds severity: error message: "UTCTime MUST include seconds per RFC 5280 4.1.2.5.1" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] - id: validity-utctime-has-seconds-notafter reference: RFC5280 4.1.2.5.1 @@ -951,7 +951,7 @@ rules: operator: utctimeHasSeconds severity: error message: "UTCTime MUST include seconds per RFC 5280 4.1.2.5.1" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] # UTCTime MUST end with 'Z' (Zulu time indicator) - id: validity-utctime-has-zulu-notbefore @@ -964,7 +964,7 @@ rules: operator: utctimeHasZulu severity: error message: "UTCTime MUST end with 'Z' (Zulu indicator) per RFC 5280 4.1.2.5.1" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] - id: validity-utctime-has-zulu-notafter reference: RFC5280 4.1.2.5.1 @@ -976,7 +976,7 @@ rules: operator: utctimeHasZulu severity: error message: "UTCTime MUST end with 'Z' (Zulu indicator) per RFC 5280 4.1.2.5.1" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] # GeneralizedTime MUST end with 'Z' - id: validity-generalizedtime-has-zulu-notbefore @@ -989,7 +989,7 @@ rules: operator: generalizedTimeHasZulu severity: error message: "GeneralizedTime MUST end with 'Z' per RFC 5280 4.1.2.5.2" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] - id: validity-generalizedtime-has-zulu-notafter reference: RFC5280 4.1.2.5.2 @@ -1001,7 +1001,7 @@ rules: operator: generalizedTimeHasZulu severity: error message: "GeneralizedTime MUST end with 'Z' per RFC 5280 4.1.2.5.2" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] # GeneralizedTime should not have fractional seconds (recommended) - id: validity-generalizedtime-no-fraction @@ -1014,7 +1014,7 @@ rules: operator: generalizedTimeNoFraction severity: warning message: "GeneralizedTime SHOULD NOT include fractional seconds per RFC 5280" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] - id: validity-generalizedtime-no-fraction-notafter reference: RFC5280 4.1.2.5.2 @@ -1026,7 +1026,7 @@ rules: operator: generalizedTimeNoFraction severity: warning message: "GeneralizedTime SHOULD NOT include fractional seconds per RFC 5280" - appliesTo: [leaf, intermediate, root] + certType: [leaf, intermediate, root] # ------------------------------------------------- # Encoding Validation (ASN.1 String Types) @@ -1043,7 +1043,7 @@ rules: operator: validPrintableString severity: warning message: "countryName should use PrintableString compatible characters" - appliesTo: [leaf, intermediate] + certType: [leaf, intermediate] # Common name should use PrintableString or UTF8String for legacy compatibility - id: subject-cn-valid-encoding @@ -1055,7 +1055,7 @@ rules: operator: validIA5String severity: warning message: "commonName should use IA5String compatible characters (ASCII)" - appliesTo: [leaf] + certType: [leaf] # Organization name should use PrintableString or UTF8String # Note: BR 7.1.4.2 permits both UTF8String and PrintableString encoding for @@ -1073,7 +1073,7 @@ rules: operator: validIA5String severity: error message: "DNS names in SAN must use IA5String encoding (ASCII only)" - appliesTo: [leaf] + certType: [leaf] # URIs in SAN must be IA5String (ASCII) - id: san-uri-valid-ia5string @@ -1085,7 +1085,7 @@ rules: operator: validIA5String severity: error message: "URIs in SAN must use IA5String encoding (ASCII only)" - appliesTo: [leaf] + certType: [leaf] # Email addresses must be IA5String (ASCII) - id: san-email-valid-ia5string @@ -1097,4 +1097,4 @@ rules: operator: validIA5String severity: error message: "Email addresses in SAN must use IA5String encoding (ASCII only)" - appliesTo: [leaf] + certType: [leaf] From a924f1ea8233ca7eb81a73969571be71db13a302 Mon Sep 17 00:00:00 2001 From: David Stromberger Date: Thu, 7 May 2026 23:54:12 +0200 Subject: [PATCH 44/44] fix: update CI --- .github/workflows/ci.yml | 19 +++++++++---------- .golangci.yml | 20 ++++++++++---------- codecov.yml | 8 ++++++++ 3 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 723690f..9cf659d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,28 +15,27 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: latest - cache: true + version: v2.12.2 unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -45,7 +44,7 @@ jobs: run: go test -v -race -coverprofile=coverage.out -covermode=atomic $(go list ./... | grep -v '/tests$') - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v6 with: files: ./coverage.out fail_ci_if_error: false @@ -57,10 +56,10 @@ jobs: name: Integration Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true diff --git a/.golangci.yml b/.golangci.yml index a09f039..f8c8b00 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" run: timeout: 5m @@ -42,15 +42,15 @@ linters: - all - -SA1019 + exclusions: + rules: + - path: _test\.go + linters: + - gosec + - unparam + - errcheck + - prealloc + issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 - - exclude-rules: - - path: _test\.go - linters: - - gosec - - unparam - - errcheck - - prealloc diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..bfdc987 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true