From db7e2736991e871b8101d5b4c34d00e201406c00 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Sun, 1 Nov 2026 08:38:14 +0800 Subject: [PATCH 1/2] fix(server)!: refuse more signing roles in DefaultAttesterLeafPolicy DefaultAttesterLeafPolicy refuses a signing certificate issued for another role that signs things, but its list of extended key usages crypto/x509 doesn't name held only the ISO/IEC 18013-5 mDL document signer and mdoc reader authentication purposes. It missed the mDL JWS signer, which signs JWTs just as an attester does, so a key certified for that role under a shared anchor could sign Wallet Attestations. The policy now also refuses these purposes, in a signing certificate and in a CA certificate that lists nothing else: - ISO/IEC 18013-5 id-mdl-kp-mdlJWS (1.0.18013.5.1.3) and id-mdl-kp-mdlVICAL (1.0.18013.5.1.8) - ISO/IEC TS 23220-4 mdoc document signer (1.0.23220.4.1.2) and mdoc reader authentication (1.0.23220.4.1.6) - RFC 9336 id-kp-documentSigning (1.3.6.1.5.5.7.3.36) - RFC 6187 id-kp-secureShellClient and id-kp-secureShellServer (1.3.6.1.5.5.7.3.21 and .22) Purposes it doesn't list, such as an ecosystem's own Wallet Provider purpose, stay accepted. The fuzz target's independent oracle and OID set cover the new list. Sources for the OIDs: - RFC 9336 section 3 and RFC 6187 section 2.2.2 (id-kp arc 1.3.6.1.5.5.7.3). - ISO/IEC 18013-5 Annex B and C values as implemented in django-ca (ca/django_ca/constants.py, mdlDS .2 and mdlJWS .3), SUNET/vc pkg/mdoc/ISO_18013_5_SUMMARY.md (id-mdl-kp-mdlDS .2, id-mdl-kp-mdlJWS .3, id-mdl-kp-mdlReaderAuth .6), spruceid/isomdl (DS .2, reader .6, VICAL .8) and the OpenID conformance suite's ValidateVicalSignerCertificateProfile (id-mdl-kp-mdlVICAL .8, Annex C.1.7.2). - ISO/IEC TS 23220-4 Annex B.2.5 values as implemented in openwallet-foundation/multipaz (asn1/OID.kt) and spruceid/isomdl (MdocProfile::ISO_23220). BREAKING CHANGE: X5CAttesterChain with the default LeafPolicy refuses an attestation whose signing certificate lists the ISO/IEC 18013-5 mDL JWS or VICAL signer, ISO/IEC TS 23220-4 mdoc document signer or reader, RFC 9336 document signing, or RFC 6187 SSH extended key usage, or that chains only through a CA restricted to such roles. Issue attesters a dedicated certificate, or set LeafPolicy to AnyAttesterLeaf or your own AttesterLeafPolicyFunc. See UPGRADING.md for v0.68.0. Co-Authored-By: Claude Opus 5.5 --- UPGRADING.md | 46 ++++++++++++++++++++++ docs/guides/native-wallet.md | 3 +- server/attester_chain_fuzz_test.go | 33 +++++++++++----- server/attester_leaf_policy.go | 61 ++++++++++++++++++++++------- server/attester_leaf_policy_test.go | 15 ++++++- server/attester_trust.go | 4 +- 6 files changed, 134 insertions(+), 28 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index e16e91eb..cea67af7 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -15,6 +15,52 @@ Most production-assurance changes only affect `Config.Assurance = AssuranceProduction`. A development-assurance setup built on `memstore` and `keys/ephemeral` needs only the steps not marked *production only*. +## v0.68.0 + +### `DefaultAttesterLeafPolicy` refuses more signing roles (server) + +**Affects:** a server whose `Dependencies.AttesterTrust` is +`server.X5CAttesterChain` with the default `LeafPolicy` (nil or +`server.DefaultAttesterLeafPolicy{}`, including inside an +`AttesterLeafPolicyFunc`), when an attester's signing certificate +lists one of these extended key usages, or chains to the anchor only +through a CA whose extended key usage lists nothing but such roles: + +- ISO/IEC 18013-5 mDL JWS signer (`1.0.18013.5.1.3`) or VICAL signer + (`1.0.18013.5.1.8`); +- ISO/IEC TS 23220-4 mdoc document signer (`1.0.23220.4.1.2`) or mdoc + reader authentication (`1.0.23220.4.1.6`); +- RFC 9336 document signing (`1.3.6.1.5.5.7.3.36`); +- RFC 6187 SSH client or server (`1.3.6.1.5.5.7.3.21`, + `1.3.6.1.5.5.7.3.22`). + +**Why:** the default policy refuses certificates issued for another +role that signs things, so an anchor that also certifies those roles +can't turn their keys into attestation keys. v0.66.0 listed only the +mDL document signer and mdoc reader purposes of ISO/IEC 18013-5. It +missed the mDL JWS signer, which signs JWTs just as an attester does, +the same purposes on the ISO/IEC 23220-4 arc, and the IETF document +signing and SSH purposes. + +**What fails:** nothing at startup. An attestation signed by such a +certificate is refused with `invalid_client`. Purposes the policy +doesn't list, such as an ecosystem's own Wallet Provider purpose, are +accepted as before. + +**What to change:** issue the attester a dedicated attestation signing +certificate without another role's extended key usage. If the anchors +certify nothing but attesters, you may opt out with +`LeafPolicy: server.AnyAttesterLeaf{}`, or write a +`server.AttesterLeafPolicyFunc` that applies your own role rules +instead of calling `DefaultAttesterLeafPolicy`: + +```go +deps.AttesterTrust = server.X5CAttesterChain{ + // ... + LeafPolicy: server.AnyAttesterLeaf{}, +} +``` + ## v0.66.0 ### `X5CAttesterChain` refuses other roles' signing certificates (server) diff --git a/docs/guides/native-wallet.md b/docs/guides/native-wallet.md index 9ba0d4ee..6457e18c 100644 --- a/docs/guides/native-wallet.md +++ b/docs/guides/native-wallet.md @@ -51,7 +51,8 @@ a trust list shared by several attesters, use its the attesters it's bound to. Its `LeafPolicy`, by default `server.DefaultAttesterLeafPolicy{}`, refuses a signing certificate issued for another role (a TLS server or client, an OCSP responder, an -mdoc reader or document signer), or chaining only through a CA +ISO/IEC 18013-5 or 23220-4 mdoc reader, document signer, JWS or VICAL +signer, RFC 9336 document signing, or SSH), or chaining only through a CA restricted to such roles, so an anchor that also certifies those can't turn their keys into attestation keys. Wrap it in a `server.AttesterLeafPolicyFunc` to also require your ecosystem's own diff --git a/server/attester_chain_fuzz_test.go b/server/attester_chain_fuzz_test.go index 08fd9720..e2585522 100644 --- a/server/attester_chain_fuzz_test.go +++ b/server/attester_chain_fuzz_test.go @@ -27,14 +27,23 @@ func (b *fuzzBytes) next() byte { } // fuzzEKUOIDs are the UnknownExtKeyUsage OIDs FuzzAttesterLeafPolicy -// draws from: both ISO/IEC 18013-5 roles, OIDs one arc either side of -// them, and an ecosystem's own purpose. +// draws from: the other signing roles the default refuses, OIDs one arc +// either side of some of them, and an ecosystem's own purpose. var fuzzEKUOIDs = []asn1.ObjectIdentifier{ {1, 0, 18013, 5, 1, 2}, + {1, 0, 18013, 5, 1, 3}, {1, 0, 18013, 5, 1, 6}, + {1, 0, 18013, 5, 1, 8}, + {1, 0, 23220, 4, 1, 2}, + {1, 0, 23220, 4, 1, 6}, + {1, 3, 6, 1, 5, 5, 7, 3, 21}, + {1, 3, 6, 1, 5, 5, 7, 3, 22}, + {1, 3, 6, 1, 5, 5, 7, 3, 36}, {1, 0, 18013, 5, 1}, {1, 0, 18013, 5, 1, 2, 1}, {1, 0, 18013, 5, 1, 7}, + {1, 0, 23220, 4, 1, 3}, + {1, 3, 6, 1, 5, 5, 7, 3, 37}, {1, 3, 6, 1, 4, 1, 99999, 1}, } @@ -53,15 +62,21 @@ func fuzzAttesterCert(b *fuzzBytes, i int) *x509.Certificate { return cert } -// isISO18013RoleOracle is isISO18013Role written out by dotted form. -func isISO18013RoleOracle(oid asn1.ObjectIdentifier) bool { - s := oid.String() - return s == "1.0.18013.5.1.2" || s == "1.0.18013.5.1.6" +// isOtherSigningRoleOracle is isOtherSigningRole written out by dotted +// form, from the documented list. +func isOtherSigningRoleOracle(oid asn1.ObjectIdentifier) bool { + switch oid.String() { + case "1.0.18013.5.1.2", "1.0.18013.5.1.3", "1.0.18013.5.1.6", "1.0.18013.5.1.8", + "1.0.23220.4.1.2", "1.0.23220.4.1.6", + "1.3.6.1.5.5.7.3.36", "1.3.6.1.5.5.7.3.21", "1.3.6.1.5.5.7.3.22": + return true + } + return false } // attesterChainRefusedOracle is DefaultAttesterLeafPolicy's documented // rule, independently: refuse an empty chain, a signing certificate -// listing any purpose but anyExtendedKeyUsage or an ISO/IEC 18013-5 +// listing any purpose but anyExtendedKeyUsage or another signing // role, and a chain through a CA whose extended key usage is present // and lists neither anyExtendedKeyUsage nor a purpose other than those. func attesterChainRefusedOracle(chain []*x509.Certificate) bool { @@ -70,12 +85,12 @@ func attesterChainRefusedOracle(chain []*x509.Certificate) bool { } leaf := chain[0] if slices.ContainsFunc(leaf.ExtKeyUsage, func(e x509.ExtKeyUsage) bool { return e != x509.ExtKeyUsageAny }) || - slices.ContainsFunc(leaf.UnknownExtKeyUsage, isISO18013RoleOracle) { + slices.ContainsFunc(leaf.UnknownExtKeyUsage, isOtherSigningRoleOracle) { return true } for _, ca := range chain[1:] { listsAny := slices.Contains(ca.ExtKeyUsage, x509.ExtKeyUsageAny) - listsOwnPurpose := slices.ContainsFunc(ca.UnknownExtKeyUsage, func(o asn1.ObjectIdentifier) bool { return !isISO18013RoleOracle(o) }) + listsOwnPurpose := slices.ContainsFunc(ca.UnknownExtKeyUsage, func(o asn1.ObjectIdentifier) bool { return !isOtherSigningRoleOracle(o) }) listsSome := len(ca.ExtKeyUsage)+len(ca.UnknownExtKeyUsage) > 0 if listsSome && !listsAny && !listsOwnPurpose { return true diff --git a/server/attester_leaf_policy.go b/server/attester_leaf_policy.go index bca2f98a..98e12d35 100644 --- a/server/attester_leaf_policy.go +++ b/server/attester_leaf_policy.go @@ -65,9 +65,16 @@ func (f AttesterLeafPolicyFunc) CheckAttesterChain(chain []*x509.Certificate) er // server-gated-crypto and code-signing purposes — even alongside // other purposes, since the certificate then also serves that role // and its key may be held where that role is; -// - a signing certificate whose extended key usage lists an -// ISO/IEC 18013-5 role: mDL document signer (1.0.18013.5.1.2) or -// mdoc reader authentication (1.0.18013.5.1.6); +// - a signing certificate whose extended key usage lists one of +// these other signing roles, which crypto/x509 doesn't name: +// ISO/IEC 18013-5 mDL document signer (1.0.18013.5.1.2), mDL JWS +// signer for server retrieval (1.0.18013.5.1.3), mdoc reader +// authentication (1.0.18013.5.1.6) or VICAL signer +// (1.0.18013.5.1.8); ISO/IEC TS 23220-4 mdoc document signer +// (1.0.23220.4.1.2) or mdoc reader authentication +// (1.0.23220.4.1.6); RFC 9336 document signing +// (id-kp-documentSigning); or RFC 6187 SSH client or server +// authentication (id-kp-secureShellClient, id-kp-secureShellServer); // - a chain through a CA certificate (an intermediate or the anchor) // whose extended key usage lists only such purposes, such as a CA // restricted to issuing TLS certificates, so a certificate under it @@ -92,8 +99,8 @@ func (DefaultAttesterLeafPolicy) CheckAttesterChain(chain []*x509.Certificate) e } } for _, oid := range leaf.UnknownExtKeyUsage { - if isISO18013Role(oid) { - return fmt.Errorf("attester certificate's extended key usage lists an ISO/IEC 18013-5 role (%s)", oid) + if name, ok := otherSigningRole(oid); ok { + return fmt.Errorf("attester certificate's extended key usage lists another role (%s, %s)", name, oid) } } for _, ca := range chain[1:] { @@ -118,15 +125,41 @@ func (AnyAttesterLeaf) CheckAttesterChain([]*x509.Certificate) error { return nil } -// ISO/IEC 18013-5 Annex B extended key usages, which crypto/x509 leaves -// in UnknownExtKeyUsage. -var ( - oidMDLDocumentSigner = asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 2} - oidMdocReaderAuth = asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 6} -) +// otherSigningRoles are the extended key usages, left in +// UnknownExtKeyUsage by crypto/x509, of roles whose keys sign something +// other than attestations. +var otherSigningRoles = []struct { + oid asn1.ObjectIdentifier + name string +}{ + // ISO/IEC 18013-5 Annex B (id-mdl-kp) and Annex C (VICAL). + {asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 2}, "mDL document signer"}, + {asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 3}, "mDL JWS signer"}, + {asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 6}, "mdoc reader authentication"}, + {asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 8}, "mDL VICAL signer"}, + // ISO/IEC TS 23220-4 Annex B, the same purposes on the mdoc arc. + {asn1.ObjectIdentifier{1, 0, 23220, 4, 1, 2}, "mdoc document signer"}, + {asn1.ObjectIdentifier{1, 0, 23220, 4, 1, 6}, "mdoc reader authentication"}, + // RFC 9336 and RFC 6187, under id-kp (1.3.6.1.5.5.7.3). + {asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 36}, "document signing"}, + {asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 21}, "SSH client"}, + {asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 22}, "SSH server"}, +} + +// otherSigningRole names the role oid is the extended key usage of, if +// it is one of otherSigningRoles. +func otherSigningRole(oid asn1.ObjectIdentifier) (string, bool) { + for _, r := range otherSigningRoles { + if oid.Equal(r.oid) { + return r.name, true + } + } + return "", false +} -func isISO18013Role(oid asn1.ObjectIdentifier) bool { - return oid.Equal(oidMDLDocumentSigner) || oid.Equal(oidMdocReaderAuth) +func isOtherSigningRole(oid asn1.ObjectIdentifier) bool { + _, ok := otherSigningRole(oid) + return ok } // restrictedToOtherRoles reports whether ca's extended key usage is @@ -137,7 +170,7 @@ func restrictedToOtherRoles(ca *x509.Certificate) bool { return false } return !slices.Contains(ca.ExtKeyUsage, x509.ExtKeyUsageAny) && - !slices.ContainsFunc(ca.UnknownExtKeyUsage, func(oid asn1.ObjectIdentifier) bool { return !isISO18013Role(oid) }) + !slices.ContainsFunc(ca.UnknownExtKeyUsage, func(oid asn1.ObjectIdentifier) bool { return !isOtherSigningRole(oid) }) } // extKeyUsageName names eku for an error message. diff --git a/server/attester_leaf_policy_test.go b/server/attester_leaf_policy_test.go index 202eff0e..999b2b9b 100644 --- a/server/attester_leaf_policy_test.go +++ b/server/attester_leaf_policy_test.go @@ -15,6 +15,7 @@ import ( var ( oidMDLDocumentSigner = asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 2} oidMdocReaderAuth = asn1.ObjectIdentifier{1, 0, 18013, 5, 1, 6} + oidDocumentSigning = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 36} oidWalletProvider = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 1} // a private purpose ) @@ -42,11 +43,19 @@ func roleLeaves(t *testing.T, root testCert) (refused, accepted map[string]attes "OCSP responder": leaf("ocsp", certOptions{extKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}}), "mdoc reader": leaf("mdoc reader", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidMdocReaderAuth}}), "mDL document signer": leaf("mdl ds", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidMDLDocumentSigner}}), + "mDL JWS signer": leaf("mdl jws", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 0, 18013, 5, 1, 3}}}), + "mDL VICAL signer": leaf("mdl vical", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 0, 18013, 5, 1, 8}}}), + "mdoc document signer": leaf("mdoc ds", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 0, 23220, 4, 1, 2}}}), + "mdoc reader (ISO/IEC 23220-4)": leaf("mdoc reader 23220", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 0, 23220, 4, 1, 6}}}), + "document signing": leaf("document signing", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidDocumentSigning}}), + "SSH client": leaf("ssh client", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 3, 6, 1, 5, 5, 7, 3, 21}}}), + "SSH server": leaf("ssh server", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 3, 6, 1, 5, 5, 7, 3, 22}}}), "only path through a TLS-only CA": {underTLSCA, x5cOf(underTLSCA, tlsCA)}, } accepted = map[string]attesterLeaf{ "no extended key usage": leaf("plain", certOptions{}), "private purpose only": leaf("private", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidWalletProvider}}), + "unlisted ISO arc": leaf("unlisted", certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{{1, 0, 18013, 5, 1, 7}}}), "anyExtendedKeyUsage": leaf("any", certOptions{extKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}}), } return refused, accepted @@ -84,8 +93,9 @@ func checkAttesterLeaf(t *testing.T, h harness, l attesterLeaf, wantOK bool) { // TestX5CAttesterChainDefaultLeafPolicy covers the default role policy: // with LeafPolicy nil, a certificate issued for another role (TLS, OCSP, -// an ISO/IEC 18013-5 role, or under a TLS-only CA) can't sign an -// attestation, while a certificate naming no other role can. +// an ISO/IEC 18013-5 or 23220-4 role, document signing, SSH, or under +// a TLS-only CA) can't sign an attestation, while a certificate naming +// no other role, or only a purpose the policy doesn't list, can. func TestX5CAttesterChainDefaultLeafPolicy(t *testing.T) { root := newTestCert(t, "ecosystem CA", certOptions{isCA: true}) refused, accepted := roleLeaves(t, root) @@ -221,6 +231,7 @@ func TestDefaultAttesterLeafPolicyRoles(t *testing.T) { "anyExtendedKeyUsage and TLS leaf": {leafUnder(plainCA, certOptions{extKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny, x509.ExtKeyUsageServerAuth}}), false}, "private purpose and mdoc reader": {leafUnder(plainCA, certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidWalletProvider, oidMdocReaderAuth}}), false}, "CA restricted to mDL signing": {leafUnder(ca(certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidMDLDocumentSigner}}), certOptions{}), false}, + "CA restricted to document signing": {leafUnder(ca(certOptions{unknownExtKeyUsage: []asn1.ObjectIdentifier{oidDocumentSigning}}), certOptions{}), false}, "CA restricted to TLS and OCSP": {leafUnder(ca(certOptions{extKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageOCSPSigning}}), certOptions{}), false}, "anchor restricted to TLS": {[]*x509.Certificate{newTestCert(t, "leaf", certOptions{parent: &root}).cert, ca(certOptions{extKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}).cert}, false}, "CA with TLS and a private purpose": {leafUnder(ca(certOptions{extKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, unknownExtKeyUsage: []asn1.ObjectIdentifier{oidWalletProvider}}), certOptions{}), true}, diff --git a/server/attester_trust.go b/server/attester_trust.go index f9d18860..2c7b52ae 100644 --- a/server/attester_trust.go +++ b/server/attester_trust.go @@ -147,8 +147,8 @@ func (r RegisteredAttesterKeys) validate() error { // No standard extended key usage exists for attestation signing, so // none is required, but LeafPolicy (DefaultAttesterLeafPolicy unless // set) refuses a signing certificate issued for another role — TLS -// server or client, OCSP responder, an ISO/IEC 18013-5 mdoc reader or -// document signer — or reachable only through a CA restricted to such +// server or client, OCSP responder, an ISO/IEC 18013-5 or 23220-4 +// mdoc role, document signing, SSH — or reachable only through a CA restricted to such // roles; only the chains it accepts are checked further. Revocation asks whether a certificate in the // verified chain below its anchor has been revoked, once the // attestation's PoP has verified; an anchor itself is withdrawn by an From 6ce2b00689be0c1fa4d862b5e5af88dc9fda44e9 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Sun, 1 Nov 2026 08:38:51 +0800 Subject: [PATCH 2/2] fix(server)!: refuse a re-issued or cross-signed anchor in attester x5c checkX5CExcludesAnchors kept HAIP 1.0's rule that x5c leaves out the trust anchor by comparing each x5c certificate's DER with the anchor each verified chain ends at. For an anchor that isn't self-signed, such as an intermediate configured as an anchor, another certificate for the same CA (re-issued by the anchor's own issuer, or cross-signed by a different root) has different bytes and got through, although path building treats it as the anchor itself. The check now compares subject and SubjectPublicKeyInfo instead, so any certificate for the anchor's CA is refused. A certificate with the anchor's subject but another key is still an ordinary intermediate. The fuzz target's oracle for the rule compares the same identity. BREAKING CHANGE: X5CAttesterChain refuses an attestation whose x5c carries, below the signing certificate, a certificate with the subject and public key of the anchor a verified chain ends at, even when it is not byte-identical to the configured anchor. Send x5c as the signing certificate and its intermediates only. See UPGRADING.md for v0.68.0. Co-Authored-By: Claude Opus 5.5 --- UPGRADING.md | 28 ++++++++++++++++++++++++++++ docs/guides/native-wallet.md | 5 +++-- server/attester_chain_fuzz_test.go | 23 ++++++++++++++++++----- server/attester_trust.go | 25 +++++++++++++++---------- server/attester_trust_test.go | 30 ++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 17 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index cea67af7..1d2466f6 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -61,6 +61,34 @@ deps.AttesterTrust = server.X5CAttesterChain{ } ``` +### `X5CAttesterChain` refuses a re-issued or cross-signed anchor in `x5c` (server) + +**Affects:** a server whose `Dependencies.AttesterTrust` is +`server.X5CAttesterChain`, when a wallet's attestation `x5c` carries, +after the signing certificate, a certificate with the same subject and +public key as an anchor a verified chain ends at, but not +byte-identical to it: a copy re-issued by the anchor's own issuer, or +cross-signed by another CA. + +**Why:** HAIP 1.0 keeps the trust anchor out of `x5c`. v0.66.0 refused +an anchor in `x5c` only when its DER bytes matched the configured +anchor exactly, so another certificate for the same CA (same subject, +same key) got through. Path building treats such a certificate as the +anchor itself, so the check now compares subject and public key. + +**What fails:** nothing at startup. Such an attestation is refused +with `invalid_client`. There is no option to keep the old behaviour. + +**What to change:** have the wallet (or its Wallet Provider) send +`x5c` as the signing certificate followed by its intermediates only, +leaving out every certificate for a CA the server trusts as an +anchor, including cross-signed copies: + +```go +// x5c: signing certificate, then intermediates, never the anchor. +x5c := [][]byte{leaf.Raw, intermediate.Raw} +``` + ## v0.66.0 ### `X5CAttesterChain` refuses other roles' signing certificates (server) diff --git a/docs/guides/native-wallet.md b/docs/guides/native-wallet.md index 6457e18c..d914b199 100644 --- a/docs/guides/native-wallet.md +++ b/docs/guides/native-wallet.md @@ -44,8 +44,9 @@ own keys, by issuer (a `keys.AttesterKeySource` such as `server.X5CAttesterChain` to verify the attestation's `x5c` chain. That `x5c` carries the signing certificate and its intermediates only, as HAIP 1.0 requires: an attestation whose `x5c` also includes a trust -anchor (any self-signed certificate, or a configured anchor) is -`invalid_client`. With +anchor (any self-signed certificate, or a certificate with a +configured anchor's subject and public key, such as a cross-signed +copy of it) is `invalid_client`. With a trust list shared by several attesters, use its `AttesterIssuerBoundToAnchor` binding, so each anchor vouches only for the attesters it's bound to. Its `LeafPolicy`, by default diff --git a/server/attester_chain_fuzz_test.go b/server/attester_chain_fuzz_test.go index e2585522..fafef382 100644 --- a/server/attester_chain_fuzz_test.go +++ b/server/attester_chain_fuzz_test.go @@ -48,9 +48,14 @@ var fuzzEKUOIDs = []asn1.ObjectIdentifier{ } // fuzzAttesterCert is a certificate struct (never DER: the policy reads -// only parsed fields) with extended key usages drawn from b. +// only parsed fields) with extended key usages, and a subject and +// public key for the anchor rule, drawn from b. func fuzzAttesterCert(b *fuzzBytes, i int) *x509.Certificate { - cert := &x509.Certificate{Subject: pkix.Name{CommonName: string(rune('a' + i))}, Raw: []byte{b.next() % 4}} + cert := &x509.Certificate{ + Subject: pkix.Name{CommonName: string(rune('a' + i))}, + RawSubject: []byte{1 + b.next()%3}, + RawSubjectPublicKeyInfo: []byte{1 + b.next()%3}, + } for range b.next() % 4 { // 0 is anyExtendedKeyUsage; values past crypto/x509's named ones // are refused like any other non-Any purpose. @@ -109,7 +114,8 @@ func attesterChainRefusedOracle(chain []*x509.Certificate) bool { // - approvedAttesterChains keeps exactly the chains the policy // accepts, in order, and fails closed when it keeps none; // - checkX5CExcludesAnchors refuses exactly when an x5c certificate -// below the leaf equals the last certificate of some chain. +// below the leaf has the subject and public key of the last +// certificate of some chain. func FuzzAttesterLeafPolicy(f *testing.F) { f.Add([]byte{}) f.Add([]byte{1, 0, 0, 0}) @@ -170,10 +176,17 @@ func checkX5CAnchorRule(t *testing.T, extra []*x509.Certificate, chains [][]*x50 nonEmpty = append(nonEmpty, c) } } - want := false + // A certificate is identified, for path building, by its subject + // and public key together. + type identity struct{ subject, key string } + anchors := map[identity]bool{} for _, c := range nonEmpty { - want = want || slices.ContainsFunc(extra, func(e *x509.Certificate) bool { return bytes.Equal(e.Raw, c[len(c)-1].Raw) }) + a := c[len(c)-1] + anchors[identity{string(a.RawSubject), string(a.RawSubjectPublicKeyInfo)}] = true } + want := slices.ContainsFunc(extra, func(e *x509.Certificate) bool { + return anchors[identity{string(e.RawSubject), string(e.RawSubjectPublicKeyInfo)}] + }) if err := checkX5CExcludesAnchors(extra, nonEmpty); (err != nil) != want { t.Fatalf("checkX5CExcludesAnchors = %v, want refusal %v", err, want) } diff --git a/server/attester_trust.go b/server/attester_trust.go index 2c7b52ae..5fe36226 100644 --- a/server/attester_trust.go +++ b/server/attester_trust.go @@ -137,9 +137,10 @@ func (r RegisteredAttesterKeys) validate() error { // rotated or revoked without redistributing anchors; // - come with intermediates only: "x5c" must not include the trust // anchor (HAIP 1.0), so an attestation is refused when any other -// "x5c" certificate is self-signed, or equals the anchor a verified -// chain ends at — even an anchor that is not self-signed, such as -// an intermediate configured as an anchor; +// "x5c" certificate is self-signed, or has the subject and public +// key of the anchor a verified chain ends at — even an anchor that +// is not self-signed, such as an intermediate configured as an +// anchor, and even a re-issued or cross-signed copy of it; // - permit digital signatures, if it restricts key usage at all; // - hold a key of the type the client's registered attestation // algorithm needs. @@ -588,18 +589,22 @@ func verifyAttesterChain(ders [][]byte, roots *x509.CertPool, now time.Time) (*x } // checkX5CExcludesAnchors refuses x5c certificates (below the leaf) -// equal to the anchor any verified chain ends at — an anchor that is not -// self-signed, such as an intermediate configured as an anchor. It looks -// at every verified chain, not only those LeafPolicy or the issuer -// binding later keep, so an x5c that carries an anchor is refused -// whichever chain would otherwise be used. A pool's anchors can't be -// listed, so only those a chain reached are compared. +// for the anchor any verified chain ends at — an anchor that is not +// self-signed, such as an intermediate configured as an anchor. A +// certificate with the anchor's subject and public key is the anchor +// for path building, whatever else differs, so a copy re-issued by the +// anchor's own issuer or cross-signed by another CA is refused just as +// the configured certificate is. It looks at every verified chain, not +// only those LeafPolicy or the issuer binding later keep, so an x5c +// that carries an anchor is refused whichever chain would otherwise be +// used. A pool's anchors can't be listed, so only those a chain reached +// are compared. func checkX5CExcludesAnchors(extra []*x509.Certificate, chains [][]*x509.Certificate) error { for _, chain := range chains { // x509.Certificate.Verify never returns an empty chain. anchor := chain[len(chain)-1] for i, c := range extra { - if c.Equal(anchor) { + if bytes.Equal(c.RawSubject, anchor.RawSubject) && bytes.Equal(c.RawSubjectPublicKeyInfo, anchor.RawSubjectPublicKeyInfo) { return fmt.Errorf("attester x5c must not include the trust anchor (HAIP 1.0): x5c certificate %d is an anchor", i+1) } } diff --git a/server/attester_trust_test.go b/server/attester_trust_test.go index afa02e53..769dec2b 100644 --- a/server/attester_trust_test.go +++ b/server/attester_trust_test.go @@ -286,6 +286,36 @@ func TestX5CAttesterChainRefusesAnchorInX5C(t *testing.T) { } } +// TestX5CAttesterChainRefusesReissuedAnchorInX5C covers an anchor that +// is not self-signed (an intermediate trusted directly) reaching x5c as +// another certificate for the same subject and key: re-issued by the +// anchor's own parent, or cross-signed by an unrelated root. Either +// copy is the anchor, so HAIP 1.0 keeps it out of x5c just as it does +// the configured certificate itself. +func TestX5CAttesterChainRefusesReissuedAnchorInX5C(t *testing.T) { + root := newTestCert(t, "offline root", certOptions{isCA: true}) + anchor := newTestCert(t, "attester CA", certOptions{parent: &root, isCA: true}) + reissued := newTestCert(t, "attester CA", certOptions{parent: &root, isCA: true, key: anchor.key}) + otherRoot := newTestCert(t, "other root", certOptions{isCA: true}) + crossSigned := newTestCert(t, "attester CA", certOptions{parent: &otherRoot, isCA: true, key: anchor.key}) + otherKey := newTestCert(t, "attester CA", certOptions{parent: &root, isCA: true}) + leaf := newTestCert(t, "attester", certOptions{parent: &anchor}) + for name, tc := range map[string]struct { + x5c json.RawMessage + wantOK bool + }{ + "anchor itself": {x5cOf(leaf, anchor), false}, + "anchor re-issued by its parent": {x5cOf(leaf, reissued), false}, + "anchor cross-signed by other root": {x5cOf(leaf, crossSigned), false}, + "same subject, another key": {x5cOf(leaf, otherKey), true}, + "leaf alone": {x5cOf(leaf), true}, + } { + t.Run(name, func(t *testing.T) { + checkAttesterLeaf(t, leafPolicyHarness(t, anchor, nil), attesterLeaf{leaf, tc.x5c}, tc.wantOK) + }) + } +} + // TestX5CAttesterChainChecksValidityAtServerClock covers certificate // validity being judged at the server's injected Clock, not the wall // clock. The server's clock is set a month in the past, so each case