diff --git a/.gitignore b/.gitignore index 8c16dcf0..917e42d2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ mocks/ # install location for project-local tools such as golangci-lint tools-bin/ + +# Private keys. Test CA keys (rootCA.key, intermediateCA.key) are generated +# locally by scripts/gen-certs.sh and must never be committed. +# endEntity.key is tracked because it is go:embed-ed in testdata/testdata.go. +*.key +!testdata/endEntity.key diff --git a/README.md b/README.md index d8cd53fe..26bcd07a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,18 @@ make presubmit ``` and check its output to make sure your code coverage figures are in line with the set target and that there are no newly introduced lint problems. +## x5chain trust verification + +Signed CoRIM messages may carry an X.509 chain in the COSE `x5chain` protected header. +Use [`SignedCorim.VerifyWithX5Chain`](https://pkg.go.dev/github.com/veraison/corim/corim#SignedCorim.VerifyWithX5Chain) +after [`FromCOSE`](https://pkg.go.dev/github.com/veraison/corim/corim#SignedCorim.FromCOSE) to validate PKIX trust, optional CRL revocation, and the COSE signature. + +Load trust material with [`LoadTrustAnchors`](https://pkg.go.dev/github.com/veraison/corim/corim#LoadTrustAnchors). +When no trust-anchor paths are supplied, verification uses the OS certificate store; for production deployments, pass explicit anchors. +When no CRL paths are supplied, revocation checks are skipped; when CRLs are loaded, [`CrlPolicyStrict`](https://pkg.go.dev/github.com/veraison/corim/corim#CrlPolicyStrict) is the default. + +For external-key verification without PKIX path validation, use [`SignedCorim.Verify`](https://pkg.go.dev/github.com/veraison/corim/corim#SignedCorim.Verify) instead. + ## Extending CoRIM/CoMID The CoRIM specification provides a mechanism for adding extensions to the base diff --git a/corim/example_x5chain_test.go b/corim/example_x5chain_test.go new file mode 100644 index 00000000..1fd8225f --- /dev/null +++ b/corim/example_x5chain_test.go @@ -0,0 +1,97 @@ +// Copyright 2021-2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package corim + +import ( + "fmt" + "log" + "time" + + "github.com/veraison/corim/testdata" +) + +func x5chainExampleMeta() *Meta { + notAfter := time.Date(2021, time.October, 0, 0, 0, 0, 0, time.UTC) + + return NewMeta(). + SetSigner("ACME Ltd.", nil). + SetValidity(notAfter, nil) +} + +func exampleSignedCorimWithX5Chain() ([]byte, error) { + signer, err := NewSignerFromJWK(testEndEntityKey) + if err != nil { + return nil, err + } + + var unsigned UnsignedCorim + if err := unsigned.FromCBOR(testGoodUnsignedCorimCBOR); err != nil { + return nil, err + } + + var signed SignedCorim + signed.UnsignedCorim = unsigned + signed.Meta = *x5chainExampleMeta() + + if err := signed.AddSigningCert(testdata.EndEntityDer); err != nil { + return nil, err + } + + intermediates := make([]byte, len(testdata.IntermediateCA)+len(testdata.RootCA)) + copy(intermediates, testdata.IntermediateCA) + copy(intermediates[len(testdata.IntermediateCA):], testdata.RootCA) + + if err := signed.AddIntermediateCerts(intermediates); err != nil { + return nil, err + } + + return signed.Sign(signer) +} + +func ExampleSignedCorim_VerifyWithX5Chain() { + cbor, err := exampleSignedCorimWithX5Chain() + if err != nil { + log.Fatal(err) + } + + anchors, err := LoadTrustAnchors(func(path string) ([]byte, error) { + if path != "anchor.der" { + return nil, fmt.Errorf("unknown path %q", path) + } + + return testdata.RootCA, nil + }, []string{"anchor.der"}, nil) + if err != nil { + log.Fatal(err) + } + + var signed SignedCorim + if err := signed.FromCOSE(cbor); err != nil { + log.Fatal(err) + } + + if err := signed.VerifyWithX5Chain(anchors); err != nil { + log.Fatal(err) + } + // Output: +} + +func ExampleLoadTrustAnchors() { + anchors, err := LoadTrustAnchors(func(path string) ([]byte, error) { + switch path { + case "anchor.der": + return testdata.RootCA, nil + default: + return nil, fmt.Errorf("unknown path %q", path) + } + }, []string{"anchor.der"}, nil) + if err != nil { + log.Fatal(err) + } + + if anchors.Pool == nil { + log.Fatal("expected explicit trust-anchor pool") + } + // Output: +} diff --git a/corim/signedcorim.go b/corim/signedcorim.go index 2236df87..5f772d26 100644 --- a/corim/signedcorim.go +++ b/corim/signedcorim.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/veraison/corim/extensions" cose "github.com/veraison/go-cose" @@ -20,6 +21,8 @@ var ( ContentType = "application/rim+cbor" NoExternalData = []byte("") HeaderLabelCorimMeta = int64(8) + + errNoSign1Message = errors.New("no Sign1 message found") ) // SignedCorim encodes a signed-corim message (i.e., a COSE Sign1 wrapped CoRIM) @@ -117,48 +120,119 @@ func (o *SignedCorim) extractMeta(v interface{}) error { } func (o *SignedCorim) extractX5Chain(x5chain interface{}) error { - var buf bytes.Buffer + var ( + signingCert *x509.Certificate + intermediateCerts []*x509.Certificate + err error + ) switch t := x5chain.(type) { case []interface{}: + elems := make([][]byte, len(t)) for i, elem := range t { - cert, ok := elem.([]byte) + certDER, ok := elem.([]byte) if !ok { return fmt.Errorf("accessing x5chain[%d]: got %T, want []byte", i, elem) } - switch i { - case 0: - if err := o.AddSigningCert(cert); err != nil { - return fmt.Errorf("decoding x5chain: %w", err) - } - default: - buf.Write(cert) - } + elems[i] = certDER } - if buf.Len() > 0 { - if err := o.AddIntermediateCerts(buf.Bytes()); err != nil { - return fmt.Errorf("decoding x5chain: %w", err) - } - } + signingCert, intermediateCerts, err = parseX5ChainFromCertDERs(elems) + case [][]byte: + signingCert, intermediateCerts, err = parseX5ChainFromCertDERs(t) case []byte: - if err := o.AddSigningCert(t); err != nil { - return fmt.Errorf("decoding x5chain: %w", err) - } + signingCert, err = parseX5ChainLeafDER(t) default: - return fmt.Errorf("decoding x5chain: got %T, want []interface{} or []byte", t) + return fmt.Errorf("decoding x5chain: got %T, want []interface{}, [][]byte, or []byte", t) + } + + if err != nil { + return err } + o.SigningCert = signingCert + o.IntermediateCerts = intermediateCerts + return nil } +func parseX5ChainFromCertDERs(elems [][]byte) (leaf *x509.Certificate, intermediates []*x509.Certificate, err error) { + if len(elems) == 0 { + return nil, nil, fmt.Errorf("decoding x5chain: empty certificate array") + } + + leaf, err = parseX5ChainLeafDER(elems[0]) + if err != nil { + return nil, nil, err + } + + intermediates = make([]*x509.Certificate, 0, len(elems)-1) + for i := 1; i < len(elems); i++ { + var parsed *x509.Certificate + parsed, err = parseX5ChainIntermediateDER(elems[i], i) + if err != nil { + return nil, nil, err + } + + intermediates = append(intermediates, parsed) + } + + return leaf, intermediates, nil +} + +func parseX5ChainLeafDER(der []byte) (*x509.Certificate, error) { + if der == nil { + return nil, fmt.Errorf("decoding x5chain: nil signing cert") + } + if len(der) == 0 { + return nil, fmt.Errorf("decoding x5chain: empty signing cert") + } + + parsed, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("decoding x5chain: invalid signing certificate: %w", err) + } + + return parsed, nil +} + +func parseX5ChainIntermediateDER(der []byte, index int) (*x509.Certificate, error) { + if len(der) == 0 { + return nil, fmt.Errorf("decoding x5chain: empty intermediate cert at index %d", index) + } + + certs, err := x509.ParseCertificates(der) + if err != nil { + return nil, fmt.Errorf("decoding x5chain: invalid intermediate certificate at index %d: %w", index, err) + } + + if len(certs) != 1 { + return nil, fmt.Errorf("decoding x5chain: expected 1 certificate at index %d, got %d", index, len(certs)) + } + + return certs[0], nil +} + // FromCOSE decodes and effects syntactic validation on the supplied // signed-corim message, including the embedded unsigned-corim and corim-meta. // On success, the unsigned-corim-map is made available via the UnsignedCorim // field while the corim-meta-map is decoded into the Meta field. func (o *SignedCorim) FromCOSE(buf []byte) error { o.message = cose.NewSign1Message() + o.SigningCert = nil + o.IntermediateCerts = nil + + var err error + // Roll back partial decode on any failure. Later steps must assign to err (not :=) + // or this cleanup is skipped. + defer func() { + if err != nil { + o.message = nil + o.SigningCert = nil + o.IntermediateCerts = nil + } + }() // If a tagged-corim-type-choice #6.500 of tagged-signed-corim #6.502, strip the prefix. // This is a remnant of an older draft of the specification before @@ -166,19 +240,19 @@ func (o *SignedCorim) FromCOSE(buf []byte) error { corimTypeChoice := []byte("\xd9\x01\xf4\xd9\x01\xf6") buf, _ = bytes.CutPrefix(buf, corimTypeChoice) - if err := o.message.UnmarshalCBOR(buf); err != nil { + if err = o.message.UnmarshalCBOR(buf); err != nil { return fmt.Errorf("failed CBOR decoding for COSE-Sign1 signed CoRIM: %w", err) } - if err := o.processHdrs(); err != nil { + if err = o.processHdrs(); err != nil { return fmt.Errorf("processing COSE headers: %w", err) } - if err := o.UnsignedCorim.FromCBOR(o.message.Payload); err != nil { + if err = o.UnsignedCorim.FromCBOR(o.message.Payload); err != nil { return fmt.Errorf("failed CBOR decoding of unsigned CoRIM: %w", err) } - if err := o.UnsignedCorim.Valid(); err != nil { + if err = o.UnsignedCorim.Valid(); err != nil { return fmt.Errorf("failed validation of unsigned CoRIM: %w", err) } @@ -294,7 +368,7 @@ func (o *SignedCorim) Sign(signer cose.Signer) ([]byte, error) { // supplied public key func (o *SignedCorim) Verify(pk crypto.PublicKey) error { if o.message == nil { - return errors.New("no Sign1 message found") + return errNoSign1Message } protected := o.message.Headers.Protected @@ -316,3 +390,47 @@ func (o *SignedCorim) Verify(pk crypto.PublicKey) error { return nil } + +// VerifyWithX5Chain validates the embedded x5chain and CoRIM COSE signature. +// Call [SignedCorim.FromCOSE] first. For external-key verify without PKIX, use [SignedCorim.Verify]. +// Load trust material via [LoadTrustAnchors] when reading anchors/CRLs from files. +// +// Leaf policy rejects CA certificates. keyUsage is optional; when present, +// digitalSignature is required. PKIX validation uses ExtKeyUsageAny. +func (o *SignedCorim) VerifyWithX5Chain(anchors TrustAnchors) error { + if o.message == nil { + return errNoSign1Message + } + + if o.SigningCert == nil { + return errors.New("x5chain: header not set in CoRIM") + } + + chain := make([]*x509.Certificate, 0, 1+len(o.IntermediateCerts)) + chain = append(chain, o.SigningCert) + chain = append(chain, o.IntermediateCerts...) + + now := anchors.CurrentTime + if now.IsZero() { + now = time.Now() + } + + if err := validateLeafSigningCert(o.SigningCert); err != nil { + return err + } + + verifiedChain, err := verifyPKIXChain(chain, anchors, now) + if err != nil { + return err + } + + if err := checkChainRevocation(verifiedChain, anchors.CRLs, anchors.CrlPolicy, now); err != nil { + return err + } + + if err := o.Verify(verifiedChain[0].PublicKey); err != nil { + return fmt.Errorf("x5chain: COSE signature verification failed: %w", err) + } + + return nil +} diff --git a/corim/signedcorim_test.go b/corim/signedcorim_test.go index 84f795d7..f98183af 100644 --- a/corim/signedcorim_test.go +++ b/corim/signedcorim_test.go @@ -4,14 +4,17 @@ package corim import ( + "crypto/x509" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/veraison/corim/encoding" "github.com/veraison/corim/extensions" "github.com/veraison/corim/testdata" + cose "github.com/veraison/go-cose" ) var ( @@ -668,3 +671,181 @@ func TestSignedCorim_SignVerify_with_kid_ok(t *testing.T) { assert.Equal(t, signedCorimIn.KeyID, signedCorimOut.KeyID) } + +func TestSignedCorim_FromCOSE_x5chain_idempotent(t *testing.T) { + cbor, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + require.Len(t, signed.IntermediateCerts, 2) + + require.NoError(t, signed.FromCOSE(cbor)) + assert.Len(t, signed.IntermediateCerts, 2) +} + +func TestSignedCorim_FromCOSE_x5chain_resetsIntermediatesOnShrink(t *testing.T) { + _, _, full := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + require.Len(t, full.IntermediateCerts, 2) + + singleCBOR, _, _ := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, nil) + + require.NoError(t, full.FromCOSE(singleCBOR)) + assert.NotNil(t, full.SigningCert) + assert.Empty(t, full.IntermediateCerts) +} + +func TestSignedCorim_FromCOSE_clearsX5ChainWhenHeaderAbsent(t *testing.T) { + _, _, withChain := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + require.NotNil(t, withChain.SigningCert) + + signer, err := NewSignerFromJWK(testEndEntityKey) + require.NoError(t, err) + + var withoutChain SignedCorim + withoutChain.UnsignedCorim = *unsignedCorimFromCBOR(t, testGoodUnsignedCorimCBOR) + withoutChain.Meta = *metaGood(t) + + noChainCBOR, err := withoutChain.Sign(signer) + require.NoError(t, err) + + require.NoError(t, withChain.FromCOSE(noChainCBOR)) + assert.Nil(t, withChain.SigningCert) + assert.Empty(t, withChain.IntermediateCerts) +} + +func TestSignedCorim_FromCOSE_resetsStaleIntermediateCerts(t *testing.T) { + cbor, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + signed.IntermediateCerts = append(signed.IntermediateCerts, &x509.Certificate{Raw: []byte("stale")}) + require.Len(t, signed.IntermediateCerts, 3) + + require.NoError(t, signed.FromCOSE(cbor)) + assert.Len(t, signed.IntermediateCerts, 2) +} + +func TestSignedCorim_FromCOSE_clearsX5ChainOnPayloadFailure(t *testing.T) { + cbor, _, _ := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + msg := cose.NewSign1Message() + require.NoError(t, msg.UnmarshalCBOR(cbor)) + msg.Payload = []byte{0xff} + + badCBOR, err := msg.MarshalCBOR() + require.NoError(t, err) + + var signed SignedCorim + err = signed.FromCOSE(badCBOR) + require.Error(t, err) + assert.Nil(t, signed.message) + assert.Nil(t, signed.SigningCert) + assert.Empty(t, signed.IntermediateCerts) +} + +func unsignedCorimPayloadSkippingValid(t *testing.T, u *UnsignedCorim) []byte { + t.Helper() + + data, err := encoding.SerializeStructToCBOR(em, u) + require.NoError(t, err) + + return append(UnsignedCorimTag, data...) +} + +func TestSignedCorim_FromCOSE_clearsX5ChainOnValidFailure(t *testing.T) { + cbor, _, _ := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + msg := cose.NewSign1Message() + require.NoError(t, msg.UnmarshalCBOR(cbor)) + + unsigned := unsignedCorimFromCBOR(t, testGoodUnsignedCorimCBOR) + unsigned.Tags = nil + msg.Payload = unsignedCorimPayloadSkippingValid(t, unsigned) + + badCBOR, err := msg.MarshalCBOR() + require.NoError(t, err) + + var signed SignedCorim + err = signed.FromCOSE(badCBOR) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed validation of unsigned CoRIM") + assert.Nil(t, signed.message) + assert.Nil(t, signed.SigningCert) + assert.Empty(t, signed.IntermediateCerts) +} + +func TestSignedCorim_extractX5Chain_nilSigningCert(t *testing.T) { + var signed SignedCorim + + err := signed.extractX5Chain([]interface{}{[]byte(nil)}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil signing cert") + + err = signed.extractX5Chain([]byte(nil)) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil signing cert") + + err = signed.extractX5Chain([]interface{}{[]byte{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty signing cert") + + err = signed.extractX5Chain([]byte{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty signing cert") +} + +func TestSignedCorim_extractX5Chain_preservesStateOnError(t *testing.T) { + _, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + origSigning := signed.SigningCert + origIntermediates := append([]*x509.Certificate(nil), signed.IntermediateCerts...) + + err := signed.extractX5Chain([]interface{}{testdata.EndEntityDer, "not-bytes"}) + require.Error(t, err) + + assert.Same(t, origSigning, signed.SigningCert) + require.Len(t, signed.IntermediateCerts, len(origIntermediates)) + + for i := range origIntermediates { + assert.Same(t, origIntermediates[i], signed.IntermediateCerts[i]) + } +} + +func TestSignedCorim_extractX5Chain_preservesStateOnIntermediateParseError(t *testing.T) { + _, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + origSigning := signed.SigningCert + origIntermediates := append([]*x509.Certificate(nil), signed.IntermediateCerts...) + + err := signed.extractX5Chain([]interface{}{testdata.EndEntityDer, []byte{0xff}}) + require.Error(t, err) + + assert.Same(t, origSigning, signed.SigningCert) + require.Len(t, signed.IntermediateCerts, len(origIntermediates)) +} + +func TestSignedCorim_extractX5Chain_emptyArray(t *testing.T) { + var signed SignedCorim + + err := signed.extractX5Chain([]interface{}{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty certificate array") + + err = signed.extractX5Chain([][]byte{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty certificate array") +} + +func TestSignedCorim_extractX5Chain_byteSlices(t *testing.T) { + var signed SignedCorim + + err := signed.extractX5Chain([][]byte{testdata.EndEntityDer, testdata.IntermediateCA, testdata.RootCA}) + require.NoError(t, err) + assert.NotNil(t, signed.SigningCert) + require.Len(t, signed.IntermediateCerts, 2) +} + +func TestSignedCorim_extractX5Chain_rejectsMultipleCertsPerElement(t *testing.T) { + var signed SignedCorim + + concat := append(append([]byte{}, testdata.IntermediateCA...), testdata.RootCA...) + err := signed.extractX5Chain([]interface{}{testdata.EndEntityDer, concat}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected 1 certificate at index 1, got 2") +} diff --git a/corim/x509chain.go b/corim/x509chain.go new file mode 100644 index 00000000..1651dec2 --- /dev/null +++ b/corim/x509chain.go @@ -0,0 +1,351 @@ +// Copyright 2021-2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package corim + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +// TrustAnchors holds trust material for x5chain validation. +// Prefer building via [LoadTrustAnchors]. +// +// Pool semantics: +// - nil — load OS trust store at verify time (used when no trust anchors are +// supplied via [LoadTrustAnchors]) +// - non-nil pool — verify only against those anchors (explicit override; no +// system roots) +// +// CRL semantics: +// - empty CRLs — skip revocation checks +// - non-empty CRLs — post-PKIX revocation checks; [CrlPolicy] selects strict vs +// permissive behavior when an in-chain issuer has no matching CRL. +// [CrlPolicyStrict] also requires each matching CRL to carry a nextUpdate that +// has not passed. +// +// See [SignedCorim.VerifyWithX5Chain]. +type TrustAnchors struct { + Pool *x509.CertPool + CRLs []*x509.RevocationList + // CrlPolicy selects revocation behavior when CRLs is non-empty. Zero value is + // [CrlPolicyStrict] (OpenSSL CRL_CHECK_ALL). + CrlPolicy CrlPolicy + CurrentTime time.Time +} + +// CrlPolicy selects how missing issuer CRLs are handled when CRLs is non-empty. +type CrlPolicy int + +const ( + // CrlPolicyStrict requires every in-chain issuer to have a valid matching CRL + // (OpenSSL CRL_CHECK_ALL) with a non-zero nextUpdate that has not passed. + // This is the default (zero value). + CrlPolicyStrict CrlPolicy = iota + // CrlPolicyPermissive skips revocation for in-chain issuers with no matching CRL. + // When matching CRLs exist but are all invalid, verification still fails. + CrlPolicyPermissive +) + +func newSystemCertPool() (*x509.CertPool, error) { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("loading system cert pool: %w", err) + } + if pool == nil { + pool = x509.NewCertPool() + } + + return pool, nil +} + +func intermediatesFromChain(chain []*x509.Certificate) *x509.CertPool { + pool := x509.NewCertPool() + + for i := 1; i < len(chain); i++ { + pool.AddCert(chain[i]) + } + + return pool +} + +func filterCRLsForIssuer(issuer *x509.Certificate, crls []*x509.RevocationList) []*x509.RevocationList { + matched := make([]*x509.RevocationList, 0, len(crls)) + + for _, crl := range crls { + if crl == nil { + continue + } + + if crl.CheckSignatureFrom(issuer) == nil { + matched = append(matched, crl) + } + } + + return matched +} + +func checkCRLValidity(crl *x509.RevocationList, now time.Time, policy CrlPolicy) error { + issuer := crl.Issuer.String() + + if !crl.ThisUpdate.IsZero() && now.Before(crl.ThisUpdate) { + return fmt.Errorf("x5chain: CRL from %q is not yet valid", issuer) + } + + if policy == CrlPolicyStrict && crl.NextUpdate.IsZero() { + return fmt.Errorf("x5chain: CRL from %q has no nextUpdate", issuer) + } + + if !crl.NextUpdate.IsZero() && now.After(crl.NextUpdate) { + return fmt.Errorf("x5chain: CRL from %q has expired", issuer) + } + + return nil +} + +func isSerialRevoked(serial *big.Int, crl *x509.RevocationList) bool { + for _, entry := range crl.RevokedCertificateEntries { + if entry.SerialNumber.Cmp(serial) == 0 { + return true + } + } + + return false +} + +func checkChainRevocation( + chain []*x509.Certificate, + crls []*x509.RevocationList, + policy CrlPolicy, + now time.Time, +) error { + if len(crls) == 0 { + return nil + } + + for i, cert := range chain { + if i+1 >= len(chain) { + break + } + + issuer := chain[i+1] + issuerCRLs := filterCRLsForIssuer(issuer, crls) + if len(issuerCRLs) == 0 { + if policy == CrlPolicyPermissive { + continue + } + + return fmt.Errorf("x5chain verification failed: unable to get certificate CRL") + } + + var ( + validityErr error + validCRLFound bool + ) + + for _, crl := range issuerCRLs { + if err := checkCRLValidity(crl, now, policy); err != nil { + validityErr = err + continue + } + + validCRLFound = true + + if isSerialRevoked(cert.SerialNumber, crl) { + return fmt.Errorf("x5chain: certificate %q is revoked", cert.Subject) + } + } + + if !validCRLFound { + return validityErr + } + } + + return nil +} + +// validateLeafSigningCert checks leaf signing-cert policy before PKIX. +// keyUsage is optional; when the extension is present, digitalSignature is required. +func validateLeafSigningCert(cert *x509.Certificate) error { + if cert.IsCA { + return fmt.Errorf("x5chain: signing certificate must not be a CA") + } + + if cert.KeyUsage != 0 && cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return fmt.Errorf("x5chain: signing certificate lacks digitalSignature key usage") + } + + return nil +} + +// selectVerifiedChain prefers the PKIX result with the most x5chain (DER) overlap. +func selectVerifiedChain(presented []*x509.Certificate, verifiedChains [][]*x509.Certificate) []*x509.Certificate { + if len(verifiedChains) == 0 { + return nil + } + + bestIdx := 0 + bestScore := countDEROverlap(presented, verifiedChains[0]) + for i := 1; i < len(verifiedChains); i++ { + if score := countDEROverlap(presented, verifiedChains[i]); score > bestScore { + bestScore = score + bestIdx = i + } + } + + return verifiedChains[bestIdx] +} + +func countDEROverlap(presented, verified []*x509.Certificate) int { + presentedDER := make(map[string]struct{}, len(presented)) + for _, cert := range presented { + presentedDER[string(cert.Raw)] = struct{}{} + } + + score := 0 + for _, cert := range verified { + if _, ok := presentedDER[string(cert.Raw)]; ok { + score++ + } + } + + return score +} + +// verifyPKIXChain validates chain against anchors. KeyUsages uses ExtKeyUsageAny +// (permissive EKU policy; full cert profile validation is out of scope). +func verifyPKIXChain( + chain []*x509.Certificate, + anchors TrustAnchors, + now time.Time, +) ([]*x509.Certificate, error) { + pool := anchors.Pool + if pool == nil { + var err error + pool, err = newSystemCertPool() + if err != nil { + return nil, fmt.Errorf("x5chain verification failed: %w", err) + } + } + + verifiedChains, err := chain[0].Verify(x509.VerifyOptions{ + Roots: pool, + Intermediates: intermediatesFromChain(chain), + CurrentTime: now, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }) + if err != nil { + return nil, fmt.Errorf("x5chain verification failed: %w", err) + } + if len(verifiedChains) == 0 { + return nil, fmt.Errorf("x5chain verification failed: no verified chain") + } + + return selectVerifiedChain(chain, verifiedChains), nil +} + +func addTrustAnchorsFromDEROrPEM(pool *x509.CertPool, addedAnchors map[string]struct{}, data []byte) error { + if pool.AppendCertsFromPEM(data) { + return nil + } + + cert, err := x509.ParseCertificate(data) + if err != nil { + return fmt.Errorf("parsing certificate: %w", err) + } + + if _, seen := addedAnchors[string(cert.Raw)]; seen { + return nil + } + + addedAnchors[string(cert.Raw)] = struct{}{} + pool.AddCert(cert) + + return nil +} + +func crlsFromDEROrPEM(data []byte) ([]*x509.RevocationList, error) { + block, rest := pem.Decode(data) + if block == nil { + crl, err := x509.ParseRevocationList(data) + if err != nil { + return nil, fmt.Errorf("parsing CRL: %w", err) + } + + return []*x509.RevocationList{crl}, nil + } + + crls := make([]*x509.RevocationList, 0, 1) + + for { + if block.Type != "X509 CRL" { + return nil, fmt.Errorf("invalid PEM block type %q", block.Type) + } + + crl, err := x509.ParseRevocationList(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing CRL: %w", err) + } + + crls = append(crls, crl) + + block, rest = pem.Decode(rest) + if block == nil { + break + } + } + + return crls, nil +} + +// LoadTrustAnchors loads trust anchors and CRLs from files into a [TrustAnchors] value. +// PEM trust-anchor files may bundle multiple certificates +// ([x509.CertPool.AppendCertsFromPEM]); PEM CRL files may contain multiple blocks. +// Duplicate DER anchors in trustAnchorPaths are added once. +// +// When trustAnchorPaths is empty, Pool is nil and verification uses the OS trust +// store. When trustAnchorPaths is non-empty, only those anchors are trusted. +func LoadTrustAnchors( + readFile func(string) ([]byte, error), + trustAnchorPaths, crlPaths []string, +) (TrustAnchors, error) { + anchors := TrustAnchors{ + CRLs: make([]*x509.RevocationList, 0, len(crlPaths)), + } + + if len(trustAnchorPaths) > 0 { + pool := x509.NewCertPool() + anchors.Pool = pool + addedAnchors := make(map[string]struct{}) + + for _, path := range trustAnchorPaths { + data, err := readFile(path) + if err != nil { + return TrustAnchors{}, fmt.Errorf("loading trust anchor from %s: %w", path, err) + } + + if err := addTrustAnchorsFromDEROrPEM(pool, addedAnchors, data); err != nil { + return TrustAnchors{}, fmt.Errorf("parsing trust anchor from %s: %w", path, err) + } + } + } + + for _, path := range crlPaths { + data, err := readFile(path) + if err != nil { + return TrustAnchors{}, fmt.Errorf("loading CRL from %s: %w", path, err) + } + + crls, err := crlsFromDEROrPEM(data) + if err != nil { + return TrustAnchors{}, fmt.Errorf("parsing CRL from %s: %w", path, err) + } + + anchors.CRLs = append(anchors.CRLs, crls...) + } + + return anchors, nil +} diff --git a/corim/x509chain_test.go b/corim/x509chain_test.go new file mode 100644 index 00000000..a32b0e59 --- /dev/null +++ b/corim/x509chain_test.go @@ -0,0 +1,876 @@ +// Copyright 2021-2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package corim + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/veraison/corim/testdata" +) + +func trustAnchorsWithCert(t *testing.T, der []byte) TrustAnchors { + t.Helper() + + anchor, err := x509.ParseCertificate(der) + require.NoError(t, err) + + pool := x509.NewCertPool() + pool.AddCert(anchor) + + return TrustAnchors{ + Pool: pool, + } +} + +type testPKI struct { + rootKey *ecdsa.PrivateKey + root *x509.Certificate + intermediateKey *ecdsa.PrivateKey + intermediate *x509.Certificate + intermediateDER []byte + leafKey *ecdsa.PrivateKey + leaf *x509.Certificate + leafDER []byte +} + +func buildTestPKI(t *testing.T) testPKI { + t.Helper() + + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + rootDER := mustCreateCA(t, rootKey, "Root CA") + root, err := x509.ParseCertificate(rootDER) + require.NoError(t, err) + + intermediateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + intermediateTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Intermediate CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + intermediateDER, err := x509.CreateCertificate( + rand.Reader, intermediateTemplate, root, &intermediateKey.PublicKey, rootKey, + ) + require.NoError(t, err) + + intermediate, err := x509.ParseCertificate(intermediateDER) + require.NoError(t, err) + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "Leaf"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + leafDER, err := x509.CreateCertificate( + rand.Reader, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey, + ) + require.NoError(t, err) + + leaf, err := x509.ParseCertificate(leafDER) + require.NoError(t, err) + + return testPKI{ + rootKey: rootKey, + root: root, + intermediateKey: intermediateKey, + intermediate: intermediate, + intermediateDER: intermediateDER, + leafKey: leafKey, + leaf: leaf, + leafDER: leafDER, + } +} + +func makeValidCRL(t *testing.T, issuer *x509.Certificate, issuerKey *ecdsa.PrivateKey) *x509.RevocationList { + t.Helper() + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, issuer, issuerKey) + require.NoError(t, err) + + crl, err := x509.ParseRevocationList(crlDER) + require.NoError(t, err) + + return crl +} + +func makeValidChainCRLs(t *testing.T, pki *testPKI) []*x509.RevocationList { + t.Helper() + + return []*x509.RevocationList{ + makeValidCRL(t, pki.intermediate, pki.intermediateKey), + makeValidCRL(t, pki.root, pki.rootKey), + } +} + +func mustECPrivateKeyJWK(t *testing.T, key *ecdsa.PrivateKey) []byte { + t.Helper() + + pad32 := func(b []byte) []byte { + out := make([]byte, 32) + copy(out[32-len(b):], b) + + return out + } + + jwk := map[string]string{ + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(pad32(key.X.Bytes())), + "y": base64.RawURLEncoding.EncodeToString(pad32(key.Y.Bytes())), + "d": base64.RawURLEncoding.EncodeToString(pad32(key.D.Bytes())), + } + + out, err := json.Marshal(jwk) + require.NoError(t, err) + + return out +} + +func signWithChain(t *testing.T, keyJWK, leafDER, intermediates []byte) (cbor []byte, signedIn, signedOut SignedCorim) { + t.Helper() + + signer, err := NewSignerFromJWK(keyJWK) + require.NoError(t, err) + + signedIn.UnsignedCorim = *unsignedCorimFromCBOR(t, testGoodUnsignedCorimCBOR) + signedIn.Meta = *metaGood(t) + require.NoError(t, signedIn.AddSigningCert(leafDER)) + + if len(intermediates) > 0 { + require.NoError(t, signedIn.AddIntermediateCerts(intermediates)) + } + + var errSign error + cbor, errSign = signedIn.Sign(signer) + require.NoError(t, errSign) + + require.NoError(t, signedOut.FromCOSE(cbor)) + + return cbor, signedIn, signedOut +} + +func TestSignedCorim_VerifyWithX5Chain_ok(t *testing.T) { + _, _, SignedCorimOut := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + err := SignedCorimOut.VerifyWithX5Chain(trustAnchorsWithCert(t, testdata.RootCA)) + assert.NoError(t, err) +} + +func TestSignedCorim_VerifyWithX5Chain_noX5Chain(t *testing.T) { + signer, err := NewSignerFromJWK(testEndEntityKey) + require.NoError(t, err) + + var withoutChain SignedCorim + withoutChain.UnsignedCorim = *unsignedCorimFromCBOR(t, testGoodUnsignedCorimCBOR) + withoutChain.Meta = *metaGood(t) + + noChainCBOR, err := withoutChain.Sign(signer) + require.NoError(t, err) + + var signed SignedCorim + require.NoError(t, signed.FromCOSE(noChainCBOR)) + + err = signed.VerifyWithX5Chain(TrustAnchors{}) + assert.EqualError(t, err, "x5chain: header not set in CoRIM") +} + +func TestSignedCorim_VerifyWithX5Chain_noSign1Message(t *testing.T) { + cert, err := x509.ParseCertificate(testdata.EndEntityDer) + require.NoError(t, err) + + s := NewSignedCorim() + s.SigningCert = cert + + err = s.VerifyWithX5Chain(trustAnchorsWithCert(t, testdata.RootCA)) + assert.EqualError(t, err, "no Sign1 message found") +} + +func TestSignedCorim_VerifyWithX5Chain_untrustedAnchor(t *testing.T) { + _, _, SignedCorimOut := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + err := SignedCorimOut.VerifyWithX5Chain(TrustAnchors{Pool: x509.NewCertPool()}) + assert.ErrorContains(t, err, "x5chain verification failed") + var unknownAuthority x509.UnknownAuthorityError + assert.ErrorAs(t, err, &unknownAuthority) +} + +func TestSignedCorim_VerifyWithX5Chain_intermediateOnlyChain(t *testing.T) { + _, _, SignedCorimOut := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, testdata.IntermediateCA) + + err := SignedCorimOut.VerifyWithX5Chain(trustAnchorsWithCert(t, testdata.RootCA)) + assert.NoError(t, err) +} + +func TestSignedCorim_VerifyWithX5Chain_expired(t *testing.T) { + pki := buildTestPKI(t) + + shortLeafTemplate := &x509.Certificate{ + SerialNumber: pki.leaf.SerialNumber, + Subject: pki.leaf.Subject, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(30 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + shortLeafDER, err := x509.CreateCertificate( + rand.Reader, shortLeafTemplate, pki.intermediate, &pki.leafKey.PublicKey, pki.intermediateKey, + ) + require.NoError(t, err) + + shortLeaf, err := x509.ParseCertificate(shortLeafDER) + require.NoError(t, err) + + require.True(t, pki.intermediate.NotAfter.After(shortLeaf.NotAfter)) + require.True(t, pki.root.NotAfter.After(shortLeaf.NotAfter)) + + _, _, SignedCorimOut := signWithChain( + t, mustECPrivateKeyJWK(t, pki.leafKey), shortLeafDER, pki.intermediateDER, + ) + + pool := x509.NewCertPool() + pool.AddCert(pki.root) + + err = SignedCorimOut.VerifyWithX5Chain(TrustAnchors{ + Pool: pool, + CurrentTime: shortLeaf.NotAfter.Add(time.Hour), + }) + assert.ErrorContains(t, err, "expired") +} + +func TestSignedCorim_VerifyWithX5Chain_signingCertIsCA_fromCOSE(t *testing.T) { + _, _, SignedCorimOut := signWithChain(t, testEndEntityKey, testdata.RootCA, nil) + + err := SignedCorimOut.VerifyWithX5Chain(trustAnchorsWithCert(t, testdata.RootCA)) + assert.EqualError(t, err, "x5chain: signing certificate must not be a CA") +} + +func TestSignedCorim_VerifyWithX5Chain_revokedIntermediateViaRootCRL(t *testing.T) { + pki := buildTestPKI(t) + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + RevokedCertificateEntries: []x509.RevocationListEntry{ + { + SerialNumber: pki.intermediate.SerialNumber, + RevocationTime: time.Now().Add(-time.Minute), + }, + }, + }, pki.root, pki.rootKey) + require.NoError(t, err) + + crl, err := x509.ParseRevocationList(crlDER) + require.NoError(t, err) + + _, _, SignedCorimOut := signWithChain( + t, mustECPrivateKeyJWK(t, pki.leafKey), pki.leafDER, pki.intermediateDER, + ) + + pool := x509.NewCertPool() + pool.AddCert(pki.root) + + err = SignedCorimOut.VerifyWithX5Chain(TrustAnchors{ + Pool: pool, + CRLs: []*x509.RevocationList{ + makeValidCRL(t, pki.intermediate, pki.intermediateKey), + crl, + }, + }) + assert.ErrorContains(t, err, "revoked") +} + +func TestSignedCorim_VerifyWithX5Chain_revokedLeafUsesVerifiedChain(t *testing.T) { + pki := buildTestPKI(t) + + unrelatedKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + unrelatedCA, err := x509.ParseCertificate(mustCreateCA(t, unrelatedKey, "Unrelated CA")) + require.NoError(t, err) + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + RevokedCertificateEntries: []x509.RevocationListEntry{ + { + SerialNumber: pki.leaf.SerialNumber, + RevocationTime: time.Now().Add(-time.Minute), + }, + }, + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + crl, err := x509.ParseRevocationList(crlDER) + require.NoError(t, err) + + intermediates := append(append([]byte{}, unrelatedCA.Raw...), pki.intermediateDER...) + _, _, SignedCorimOut := signWithChain( + t, mustECPrivateKeyJWK(t, pki.leafKey), pki.leafDER, intermediates, + ) + + pool := x509.NewCertPool() + pool.AddCert(pki.root) + + err = SignedCorimOut.VerifyWithX5Chain(TrustAnchors{ + Pool: pool, + CRLs: []*x509.RevocationList{ + crl, + makeValidCRL(t, pki.root, pki.rootKey), + }, + }) + assert.ErrorContains(t, err, "revoked") +} + +func TestSignedCorim_VerifyWithX5Chain_tamperedPayload(t *testing.T) { + cbor, _, SignedCorimOut := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + cbor[len(cbor)-1] ^= 0xff + require.NoError(t, SignedCorimOut.FromCOSE(cbor)) + + err := SignedCorimOut.VerifyWithX5Chain(trustAnchorsWithCert(t, testdata.RootCA)) + assert.ErrorContains(t, err, "x5chain: COSE signature verification failed") + assert.ErrorContains(t, err, "verification error") +} + +func TestSignedCorim_VerifyWithX5Chain_signingKeyMismatch(t *testing.T) { + pki := buildTestPKI(t) + + wrongKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + _, _, SignedCorimOut := signWithChain( + t, mustECPrivateKeyJWK(t, wrongKey), pki.leafDER, pki.intermediateDER, + ) + + pool := x509.NewCertPool() + pool.AddCert(pki.root) + + err = SignedCorimOut.VerifyWithX5Chain(TrustAnchors{Pool: pool}) + assert.ErrorContains(t, err, "x5chain: COSE signature verification failed") + assert.ErrorContains(t, err, "verification error") +} + +func TestValidateLeafSigningCert_missingDigitalSignature(t *testing.T) { + cert := &x509.Certificate{ + IsCA: false, + KeyUsage: x509.KeyUsageCertSign, + } + + err := validateLeafSigningCert(cert) + assert.EqualError(t, err, "x5chain: signing certificate lacks digitalSignature key usage") +} + +func TestValidateLeafSigningCert_zeroKeyUsagePasses(t *testing.T) { + cert := &x509.Certificate{ + IsCA: false, + } + + err := validateLeafSigningCert(cert) + assert.NoError(t, err) +} + +func TestCheckChainRevocation_revokedReportedBeforeExpiredCRL(t *testing.T) { + pki := buildTestPKI(t) + + validCRLDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + RevokedCertificateEntries: []x509.RevocationListEntry{ + { + SerialNumber: pki.leaf.SerialNumber, + RevocationTime: time.Now().Add(-time.Minute), + }, + }, + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + expiredCRLDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(2), + ThisUpdate: time.Now().Add(-2 * time.Hour), + NextUpdate: time.Now().Add(-time.Hour), + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + validCRL, err := x509.ParseRevocationList(validCRLDER) + require.NoError(t, err) + + expiredCRL, err := x509.ParseRevocationList(expiredCRLDER) + require.NoError(t, err) + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + crls := makeValidChainCRLs(t, &pki) + crls[0] = validCRL + crls = append(crls, expiredCRL) + + err = checkChainRevocation(chain, crls, CrlPolicyStrict, time.Now()) + assert.ErrorContains(t, err, "revoked") + assert.NotContains(t, err.Error(), "has expired") +} + +func TestCheckChainRevocation_validCRLIgnoresExpiredSibling(t *testing.T) { + pki := buildTestPKI(t) + + expiredCRLDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(2), + ThisUpdate: time.Now().Add(-2 * time.Hour), + NextUpdate: time.Now().Add(-time.Hour), + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + expiredCRL, err := x509.ParseRevocationList(expiredCRLDER) + require.NoError(t, err) + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + crls := append(makeValidChainCRLs(t, &pki), expiredCRL) + + err = checkChainRevocation(chain, crls, CrlPolicyStrict, time.Now()) + assert.NoError(t, err) +} + +func TestCheckChainRevocation_allMatchingCRLsExpired(t *testing.T) { + pki := buildTestPKI(t) + + expiredCRL1DER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-2 * time.Hour), + NextUpdate: time.Now().Add(-time.Hour), + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + expiredCRL2DER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(2), + ThisUpdate: time.Now().Add(-3 * time.Hour), + NextUpdate: time.Now().Add(-2 * time.Hour), + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + expiredCRL1, err := x509.ParseRevocationList(expiredCRL1DER) + require.NoError(t, err) + + expiredCRL2, err := x509.ParseRevocationList(expiredCRL2DER) + require.NoError(t, err) + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + crls := makeValidChainCRLs(t, &pki) + crls[0] = expiredCRL1 + crls = append(crls, expiredCRL2) + + err = checkChainRevocation(chain, crls, CrlPolicyStrict, time.Now()) + assert.ErrorContains(t, err, "has expired") +} + +func TestCheckChainRevocation_strictRejectsMissingNextUpdate(t *testing.T) { + pki := buildTestPKI(t) + + crl := makeValidCRL(t, pki.intermediate, pki.intermediateKey) + crl.NextUpdate = time.Time{} + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + + err := checkChainRevocation(chain, []*x509.RevocationList{crl}, CrlPolicyStrict, time.Now()) + assert.ErrorContains(t, err, "no nextUpdate") +} + +func TestCheckChainRevocation_permissiveAllowsMissingNextUpdate(t *testing.T) { + pki := buildTestPKI(t) + + crl := makeValidCRL(t, pki.intermediate, pki.intermediateKey) + crl.NextUpdate = time.Time{} + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + crls := []*x509.RevocationList{crl, makeValidCRL(t, pki.root, pki.rootKey)} + + err := checkChainRevocation(chain, crls, CrlPolicyPermissive, time.Now()) + assert.NoError(t, err) +} + +func TestCheckChainRevocation_skipsNilCRL(t *testing.T) { + pki := buildTestPKI(t) + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + + err := checkChainRevocation(chain, append([]*x509.RevocationList{nil}, makeValidChainCRLs(t, &pki)...), CrlPolicyStrict, time.Now()) + assert.NoError(t, err) +} + +func TestCheckChainRevocation_missingIssuerCRLFails(t *testing.T) { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + chainCA, err := x509.ParseCertificate(mustCreateCA(t, caKey, "Chain CA")) + require.NoError(t, err) + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, chainCA, &leafKey.PublicKey, caKey) + require.NoError(t, err) + + leaf, err := x509.ParseCertificate(leafDER) + require.NoError(t, err) + + unrelatedKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + unrelatedCA, err := x509.ParseCertificate(mustCreateCA(t, unrelatedKey, "Unrelated CA")) + require.NoError(t, err) + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, unrelatedCA, unrelatedKey) + require.NoError(t, err) + + crl, err := x509.ParseRevocationList(crlDER) + require.NoError(t, err) + + err = checkChainRevocation([]*x509.Certificate{leaf, chainCA}, []*x509.RevocationList{crl}, CrlPolicyStrict, time.Now()) + assert.ErrorContains(t, err, "unable to get certificate CRL") +} + +func TestCheckChainRevocation_permissiveSkipsMissingIssuerCRL(t *testing.T) { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + chainCA, err := x509.ParseCertificate(mustCreateCA(t, caKey, "Chain CA")) + require.NoError(t, err) + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, chainCA, &leafKey.PublicKey, caKey) + require.NoError(t, err) + + leaf, err := x509.ParseCertificate(leafDER) + require.NoError(t, err) + + unrelatedKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + unrelatedCA, err := x509.ParseCertificate(mustCreateCA(t, unrelatedKey, "Unrelated CA")) + require.NoError(t, err) + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, unrelatedCA, unrelatedKey) + require.NoError(t, err) + + crl, err := x509.ParseRevocationList(crlDER) + require.NoError(t, err) + + err = checkChainRevocation( + []*x509.Certificate{leaf, chainCA}, + []*x509.RevocationList{crl}, + CrlPolicyPermissive, + time.Now(), + ) + assert.NoError(t, err) +} + +func TestCheckChainRevocation_okWithFullChainCRLs(t *testing.T) { + pki := buildTestPKI(t) + + chain := []*x509.Certificate{pki.leaf, pki.intermediate, pki.root} + + err := checkChainRevocation(chain, makeValidChainCRLs(t, &pki), CrlPolicyStrict, time.Now()) + assert.NoError(t, err) +} + +func mustCreateCA(t *testing.T, key *ecdsa.PrivateKey, commonName string) []byte { + t.Helper() + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return der +} + +func TestLoadTrustAnchors_readFileError(t *testing.T) { + _, err := LoadTrustAnchors(func(string) ([]byte, error) { + return nil, errors.New("read failed") + }, []string{"missing.der"}, nil) + assert.ErrorContains(t, err, "loading trust anchor from missing.der") + assert.ErrorContains(t, err, "read failed") +} + +func TestLoadTrustAnchors_invalidTrustAnchorParse(t *testing.T) { + _, err := LoadTrustAnchors(func(string) ([]byte, error) { + return []byte("not-a-cert"), nil + }, []string{"bad.der"}, nil) + assert.ErrorContains(t, err, "parsing trust anchor from bad.der") +} + +func TestLoadTrustAnchors_invalidCRLParse(t *testing.T) { + _, err := LoadTrustAnchors(func(path string) ([]byte, error) { + switch path { + case "anchor.der": + return testdata.RootCA, nil + case "bad.crl": + return []byte("not-a-crl"), nil + default: + t.Fatalf("unexpected path %q", path) + return nil, nil + } + }, []string{"anchor.der"}, []string{"bad.crl"}) + assert.ErrorContains(t, err, "parsing CRL from bad.crl") +} + +func TestSelectVerifiedChain_prefersPresentedOverlap(t *testing.T) { + leaf := &x509.Certificate{Raw: []byte("leaf")} + inter := &x509.Certificate{Raw: []byte("inter")} + rootA := &x509.Certificate{Raw: []byte("root-a")} + rootB := &x509.Certificate{Raw: []byte("root-b")} + + presented := []*x509.Certificate{leaf, inter, rootA} + chains := [][]*x509.Certificate{ + {leaf, inter, rootB}, + {leaf, inter, rootA}, + } + + selected := selectVerifiedChain(presented, chains) + require.NotNil(t, selected) + assert.Equal(t, rootA.Raw, selected[len(selected)-1].Raw) +} + +func TestLoadTrustAnchors_loadsPemTrustAnchorBundle(t *testing.T) { + bundle := append( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: testdata.RootCA}), + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: testdata.IntermediateCA})..., + ) + + anchors, err := LoadTrustAnchors(func(string) ([]byte, error) { + return bundle, nil + }, []string{"bundle.pem"}, nil) + require.NoError(t, err) + + _, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + assert.NoError(t, signed.VerifyWithX5Chain(anchors)) +} + +func TestLoadTrustAnchors_loadsPemCrlBundle(t *testing.T) { + pki := buildTestPKI(t) + + firstCRLDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + secondCRLDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(2), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, pki.root, pki.rootKey) + require.NoError(t, err) + + bundle := append( + pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: firstCRLDER}), + pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: secondCRLDER})..., + ) + + anchors, err := LoadTrustAnchors(func(path string) ([]byte, error) { + switch path { + case "anchor.der": + return testdata.RootCA, nil + case "crls.pem": + return bundle, nil + default: + t.Fatalf("unexpected path %q", path) + return nil, nil + } + }, []string{"anchor.der"}, []string{"crls.pem"}) + require.NoError(t, err) + require.Len(t, anchors.CRLs, 2) +} + +func TestLoadTrustAnchors_dedupesDuplicateTrustAnchors(t *testing.T) { + anchors, err := LoadTrustAnchors(func(string) ([]byte, error) { + return testdata.RootCA, nil + }, []string{"anchor-a.der", "anchor-b.der"}, nil) + require.NoError(t, err) + + _, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + err = signed.VerifyWithX5Chain(anchors) + assert.NoError(t, err) +} + +func TestLoadTrustAnchors_wrongTrustAnchorFails(t *testing.T) { + _, _, signed := signWithChain(t, testEndEntityKey, testdata.EndEntityDer, certChain()) + + wrongAnchorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + wrongAnchors, err := LoadTrustAnchors(func(string) ([]byte, error) { + return mustCreateCA(t, wrongAnchorKey, "Wrong Trust Anchor"), nil + }, []string{"wrong-anchor.der"}, nil) + require.NoError(t, err) + + err = signed.VerifyWithX5Chain(wrongAnchors) + assert.ErrorContains(t, err, "x5chain verification failed") + var unknownAuthority x509.UnknownAuthorityError + assert.ErrorAs(t, err, &unknownAuthority) +} + +func TestLoadTrustAnchors_emptyPathsUsesSystemStore(t *testing.T) { + anchors, err := LoadTrustAnchors(func(string) ([]byte, error) { + t.Fatal("readFile should not be called when trustAnchorPaths is empty") + return nil, nil + }, nil, nil) + require.NoError(t, err) + require.Nil(t, anchors.Pool) +} + +func TestLoadTrustAnchors_loadsCRLs(t *testing.T) { + pki := buildTestPKI(t) + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(time.Hour), + }, pki.intermediate, pki.intermediateKey) + require.NoError(t, err) + + anchors, err := LoadTrustAnchors(func(path string) ([]byte, error) { + switch path { + case "anchor.der": + return testdata.RootCA, nil + case "issuer.crl": + return crlDER, nil + default: + t.Fatalf("unexpected path %q", path) + return nil, nil + } + }, []string{"anchor.der"}, []string{"issuer.crl"}) + require.NoError(t, err) + + require.Len(t, anchors.CRLs, 1) + assert.Equal(t, crlDER, anchors.CRLs[0].Raw) +} + +func TestLoadTrustAnchors_invalidPemCrlType(t *testing.T) { + pemCRL := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{0x01, 0x02}}) + + _, err := LoadTrustAnchors(func(path string) ([]byte, error) { + switch path { + case "anchor.der": + return testdata.RootCA, nil + case "bad.crl": + return pemCRL, nil + default: + t.Fatalf("unexpected path %q", path) + return nil, nil + } + }, []string{"anchor.der"}, []string{"bad.crl"}) + assert.ErrorContains(t, err, "parsing CRL from bad.crl") + assert.ErrorContains(t, err, `invalid PEM block type "CERTIFICATE"`) +} + +func TestSignedCorim_VerifyWithX5Chain_crlNotYetValidFails(t *testing.T) { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + caDER := mustCreateCA(t, caKey, "Test CA") + ca, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, ca, &leafKey.PublicKey, caKey) + require.NoError(t, err) + + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(time.Hour), + NextUpdate: time.Now().Add(2 * time.Hour), + }, ca, caKey) + require.NoError(t, err) + + crl, err := x509.ParseRevocationList(crlDER) + require.NoError(t, err) + + _, _, SignedCorimOut := signWithChain(t, mustECPrivateKeyJWK(t, leafKey), leafDER, caDER) + + pool := x509.NewCertPool() + pool.AddCert(ca) + + err = SignedCorimOut.VerifyWithX5Chain(TrustAnchors{ + Pool: pool, + CRLs: []*x509.RevocationList{crl}, + }) + assert.ErrorContains(t, err, "not yet valid") +} diff --git a/scripts/gen-certs.sh b/scripts/gen-certs.sh index 102533e2..9e5ebfa0 100644 --- a/scripts/gen-certs.sh +++ b/scripts/gen-certs.sh @@ -14,22 +14,48 @@ mkdir -p "$MISC_DIR" trap '[[ $_should_clean_certs_artifacts == true ]] && clean_certs_artifacts' EXIT +function write_intermediate_extfile() { + cat > "${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.ext" <<'EOF' +[ v3_intermediate ] +basicConstraints = critical, CA:TRUE, pathlen:0 +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +EOF +} + +function write_end_entity_extfile() { + cat > "${MISC_DIR}/${END_ENTITY_CERT_NAME}.ext" <<'EOF' +[ v3_end_entity ] +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +EOF +} + function create_root_cert() { _check_openssl - if [[ -f "${MISC_DIR}/${ROOT_CERT_NAME}.der" ]]; then + if [[ -f "${MISC_DIR}/${ROOT_CERT_NAME}.der" ]] && [[ "${FORCE:-}" != "1" ]]; then echo "Root certificate already exists. Skipping creation." return fi - openssl ecparam -name prime256v1 -genkey -noout -out ${MISC_DIR}/${ROOT_CERT_NAME}.key + if [[ ! -f "${MISC_DIR}/${ROOT_CERT_NAME}.key" ]]; then + openssl ecparam -name prime256v1 -genkey -noout -out ${MISC_DIR}/${ROOT_CERT_NAME}.key + fi + openssl req -x509 -new -nodes -key ${MISC_DIR}/${ROOT_CERT_NAME}.key \ -sha256 -days 3650 \ -subj "/CN=Acme Inc." \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" \ -out ${MISC_DIR}/${ROOT_CERT_NAME}.crt openssl x509 -in ${MISC_DIR}/${ROOT_CERT_NAME}.crt -outform der \ -out ${MISC_DIR}/${ROOT_CERT_NAME}.der - rm -f ${MISC_DIR}/${ROOT_CERT_NAME}.crt + rm -f ${MISC_DIR}/${ROOT_CERT_NAME}.crt echo "Created ${MISC_DIR}/${ROOT_CERT_NAME}.der and ${MISC_DIR}/${ROOT_CERT_NAME}.key" } @@ -38,12 +64,16 @@ function create_intermediate_cert() { _check_openssl _check_root_cert - if [[ -f "${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.der" ]]; then + if [[ -f "${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.der" ]] && [[ "${FORCE:-}" != "1" ]]; then echo "Intermediate certificate already exists. Skipping creation." return fi - openssl ecparam -name prime256v1 -genkey -noout -out ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.key + if [[ ! -f "${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.key" ]]; then + openssl ecparam -name prime256v1 -genkey -noout -out ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.key + fi + + write_intermediate_extfile openssl req -new -key ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.key \ -out ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.csr \ -subj "/CN=Acme Gizmos" @@ -52,10 +82,13 @@ function create_intermediate_cert() { -CAkey ${MISC_DIR}/${ROOT_CERT_NAME}.key \ -CAcreateserial \ -out ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.crt \ - -days 3650 -sha256 + -days 3650 -sha256 \ + -CAform der \ + -extensions v3_intermediate \ + -extfile ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.ext openssl x509 -in ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.crt \ -outform der -out ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.der - rm -f ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.crt + rm -f ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.crt ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.ext echo "Created ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.der and ${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.key" } @@ -63,13 +96,21 @@ function create_intermediate_cert() { function create_end_entity_cert() { _check_openssl _check_root_cert - - if ([[ -f "${MISC_DIR}/${END_ENTITY_CERT_NAME}.der" ]] && [[ -f "${MISC_DIR}/${END_ENTITY_CERT_NAME}.key" ]]); then + + if [[ ! -f "${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.der" ]]; then + create_intermediate_cert + fi + + if ([[ -f "${MISC_DIR}/${END_ENTITY_CERT_NAME}.der" ]] && [[ -f "${MISC_DIR}/${END_ENTITY_CERT_NAME}.key" ]] && [[ "${FORCE:-}" != "1" ]]); then echo "End-entity certificate and key already exist. Skipping creation." return fi - openssl ecparam -name prime256v1 -genkey -noout -out ${MISC_DIR}/${END_ENTITY_CERT_NAME}.key + if [[ ! -f "${MISC_DIR}/${END_ENTITY_CERT_NAME}.key" ]]; then + openssl ecparam -name prime256v1 -genkey -noout -out ${MISC_DIR}/${END_ENTITY_CERT_NAME}.key + fi + + write_end_entity_extfile openssl req -new -key ${MISC_DIR}/${END_ENTITY_CERT_NAME}.key \ -out ${MISC_DIR}/${END_ENTITY_CERT_NAME}.csr \ -subj "/CN=Acme Gizmo CoRIM signer" @@ -79,18 +120,20 @@ function create_end_entity_cert() { -CAcreateserial \ -out ${MISC_DIR}/${END_ENTITY_CERT_NAME}.crt \ -days 1825 -sha256 \ - -CAform der + -CAform der \ + -extensions v3_end_entity \ + -extfile ${MISC_DIR}/${END_ENTITY_CERT_NAME}.ext openssl x509 -in ${MISC_DIR}/${END_ENTITY_CERT_NAME}.crt \ -outform der -out ${MISC_DIR}/${END_ENTITY_CERT_NAME}.der - rm -f ${MISC_DIR}/${END_ENTITY_CERT_NAME}.crt + rm -f ${MISC_DIR}/${END_ENTITY_CERT_NAME}.crt ${MISC_DIR}/${END_ENTITY_CERT_NAME}.ext echo "Created ${MISC_DIR}/${END_ENTITY_CERT_NAME}.der and ${MISC_DIR}/${END_ENTITY_CERT_NAME}.key" } function clean_certs_artifacts() { pushd "$MISC_DIR" > /dev/null || exit 1 - echo "rm -f -- *.csr *.srl" - rm -f -- *.csr *.srl + echo "rm -f -- *.csr *.srl *.ext" + rm -f -- *.csr *.srl *.ext popd > /dev/null || exit 1 } @@ -109,6 +152,19 @@ function clean_all() { clean_cert "$END_ENTITY_CERT_NAME" } +function regenerate() { + FORCE=1 + rm -f "${MISC_DIR}/${ROOT_CERT_NAME}.der" \ + "${MISC_DIR}/${INTERMEDIATE_CERT_NAME}.der" \ + "${MISC_DIR}/${END_ENTITY_CERT_NAME}.der" + create_root_cert + create_intermediate_cert + create_end_entity_cert + if [[ $_should_clean_certs_artifacts == true ]]; then + clean_certs_artifacts + fi +} + function help() { set +e read -r -d '' usage <<-EOF @@ -124,6 +180,9 @@ function help() { create Create the root, intermediate, and end-entity certificates. + regenerate + Re-issue DER certificates with PKIX extensions, keeping existing keys. + clean_certs_artifacts Clean output artifacts for the certificates. @@ -188,6 +247,9 @@ case $command in clean_all) clean_all ;; + regenerate) + regenerate + ;; create) create_root_cert create_intermediate_cert diff --git a/testdata/endEntity.der b/testdata/endEntity.der index 0d1c6ce6..a82ed8ad 100644 Binary files a/testdata/endEntity.der and b/testdata/endEntity.der differ diff --git a/testdata/intermediateCA.der b/testdata/intermediateCA.der index dfc1c688..f0692d62 100644 Binary files a/testdata/intermediateCA.der and b/testdata/intermediateCA.der differ diff --git a/testdata/rootCA.der b/testdata/rootCA.der index 12d59f05..ac2388a9 100644 Binary files a/testdata/rootCA.der and b/testdata/rootCA.der differ diff --git a/testdata/testdata.go b/testdata/testdata.go index 0a29f56a..55f61293 100644 --- a/testdata/testdata.go +++ b/testdata/testdata.go @@ -1,5 +1,8 @@ // Copyright 2021-2024 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 + +// Package testdata holds PUBLIC test-only X.509 material embedded for unit tests. +// Do not use these keys or certificates as production trust material. package testdata import (