Skip to content

Commit 3239ed0

Browse files
feat(protocol): relying party attestation policy (#741)
Introduces AttestationPolicy to carry the attestation verification decisions §8 delegates to the Relying Party, configured on webauthn.Config and threaded through to the format handlers. Its first member selects the Android Key authorization lists the origin and purpose requirements are evaluated against, defaulting to teeEnforced alone so an unset policy accepts only keys generated within a trusted execution environment. BREAKING CHANGE: AttestationObject.Verify and AttestationObject.VerifyAttestation take an AttestationPolicy, and ConfigProvider requires GetAttestationPolicy.
1 parent 34d324b commit 3239ed0

28 files changed

Lines changed: 655 additions & 117 deletions

protocol/attestation.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ type NonCompoundAttestationObject struct {
9999
AttStatement map[string]any `json:"attStmt,omitempty"`
100100
}
101101

102-
type attestationFormatValidationHandler func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error)
102+
type attestationFormatValidationHandler func(att AttestationObject, clientDataHash []byte, mds metadata.Provider, policy AttestationPolicy) (attestationType string, x5cs []any, err error)
103103

104104
var attestationRegistry = make(map[AttestationFormat]attestationFormatValidationHandler)
105105

@@ -149,7 +149,7 @@ func (ccr *AuthenticatorAttestationResponse) Parse() (p *ParsedAttestationRespon
149149
//
150150
// Steps 13 through 15 are verified against the auth data. These steps are identical to 15 through 18 for assertion so we
151151
// handle them with AuthData.
152-
func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, userVerificationRequired bool, userPresenceRequired bool, mds metadata.Provider, credParams []CredentialParameter) (err error) {
152+
func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, userVerificationRequired bool, userPresenceRequired bool, mds metadata.Provider, credParams []CredentialParameter, policy AttestationPolicy) (err error) {
153153
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
154154

155155
// Begin Step 13 through 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
@@ -177,12 +177,15 @@ func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte,
177177
return ErrAttestationFormat.WithInfo("Credential public key algorithm not supported")
178178
}
179179

180-
return a.VerifyAttestation(clientDataHash, mds)
180+
return a.VerifyAttestation(clientDataHash, mds, policy)
181181
}
182182

183183
// VerifyAttestation only verifies the attestation object excluding the AuthData values. If you wish to also verify the
184184
// AuthData values you should use [Verify].
185-
func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadata.Provider) (err error) {
185+
//
186+
// The policy carries the Relying Party decisions which §8 leaves to the Relying Party. Its zero value selects the
187+
// most restrictive behavior available. See [AttestationPolicy].
188+
func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadata.Provider, policy AttestationPolicy) (err error) {
186189
// Step 18. Determine the attestation statement format by performing a
187190
// USASCII case-sensitive match on fmt against the set of supported
188191
// WebAuthn Attestation Statement Format Identifier values. The up-to-date
@@ -223,7 +226,7 @@ func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadat
223226
// Step 19. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using
224227
// the attestation statement format fmt’s verification procedure given attStmt, authData and the hash of the serialized
225228
// client data computed in step 7.
226-
if attestationType, x5cs, err = handler(*a, clientDataHash, mds); err != nil {
229+
if attestationType, x5cs, err = handler(*a, clientDataHash, mds, policy); err != nil {
227230
var e *Error
228231

229232
if errors.As(err, &e) {

protocol/attestation_androidkey.go

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import (
3737
// See: https://www.w3.org/TR/webauthn/#sctn-android-key-attestation
3838
//
3939
//nolint:gocyclo
40-
func attestationFormatValidationHandlerAndroidKey(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
40+
func attestationFormatValidationHandlerAndroidKey(att AttestationObject, clientDataHash []byte, _ metadata.Provider, policy AttestationPolicy) (attestationType string, x5cs []any, err error) {
4141
var (
4242
alg int64
4343
sig []byte
@@ -145,22 +145,30 @@ func attestationFormatValidationHandlerAndroidKey(att AttestationObject, clientD
145145
return "", nil, ErrAttestationFormat.WithDetails("Attestation challenge not equal to clientDataHash")
146146
}
147147

148-
if protoErr := androidKeyValidateAuthorizationLists(&decoded); protoErr != nil {
148+
if protoErr := androidKeyValidateAuthorizationLists(&decoded, policy.AndroidKey.AuthorizationScope); protoErr != nil {
149149
return "", nil, protoErr
150150
}
151151

152152
return string(metadata.BasicFull), x5c, err
153153
}
154154

155-
// androidKeyValidateAuthorizationLists performs the §8.4 verification steps which apply to the authorization lists of
156-
// the Android key attestation certificate extension.
157-
func androidKeyValidateAuthorizationLists(decoded *androidkeyDescription) *Error {
155+
// androidKeyValidateAuthorizationLists performs the §8.4 verification steps which apply to the authorization lists
156+
// of the Android key attestation certificate extension.
157+
//
158+
// The scope selects the lists the origin and purpose requirements are evaluated against, which §8.4 leaves to the
159+
// Relying Party. See [AndroidKeyAuthorizationScope].
160+
func androidKeyValidateAuthorizationLists(decoded *androidkeyDescription, scope AndroidKeyAuthorizationScope) *Error {
158161
// The AuthorizationList.allApplications field is not present on either authorization list (softwareEnforced nor teeEnforced), since PublicKeyCredential MUST be scoped to the RP ID.
162+
//
163+
// This requirement precedes the sentence which introduces the Relying Party's choice of scope, so it applies to
164+
// both lists regardless of the scope in effect.
159165
if len(decoded.SoftwareEnforced.AllApplications.FullBytes) != 0 || len(decoded.TeeEnforced.AllApplications.FullBytes) != 0 {
160166
return ErrAttestationFormat.WithDetails("Attestation certificate extensions contains all applications field")
161167
}
162168

163169
// For the following, use only the teeEnforced authorization list if the RP wants to accept only keys from a trusted execution environment, otherwise use the union of teeEnforced and softwareEnforced.
170+
union := scope.union()
171+
164172
// The value in the AuthorizationList.origin field is equal to KM_ORIGIN_GENERATED (which == 0).
165173
var (
166174
originTee, originSoftware int
@@ -172,26 +180,51 @@ func androidKeyValidateAuthorizationLists(decoded *androidkeyDescription) *Error
172180
return ErrAttestationFormat.WithDetails("Unable to parse the origin of the teeEnforced authorization list").WithError(err)
173181
}
174182

175-
if originSoftware, presentSoftware, err = authorizationListOrigin(&decoded.SoftwareEnforced); err != nil {
176-
return ErrAttestationFormat.WithDetails("Unable to parse the origin of the softwareEnforced authorization list").WithError(err)
183+
// The softwareEnforced list is only parsed when the scope consults it, so a malformed origin there cannot fail
184+
// an attestation the teeEnforced scope would otherwise accept on the teeEnforced list alone.
185+
if union {
186+
if originSoftware, presentSoftware, err = authorizationListOrigin(&decoded.SoftwareEnforced); err != nil {
187+
return ErrAttestationFormat.WithDetails("Unable to parse the origin of the softwareEnforced authorization list").WithError(err)
188+
}
177189
}
178190

179-
// The union is satisfied when either list carries an origin equal to KM_ORIGIN_GENERATED. An absent origin
180-
// satisfies nothing as there is no value to compare against, which mirrors the purpose check below.
181-
generated := (presentTee && originTee == KM_ORIGIN_GENERATED) || (presentSoftware && originSoftware == KM_ORIGIN_GENERATED)
191+
// An absent origin satisfies nothing as there is no value to compare against, which mirrors the purpose check
192+
// below.
193+
generated := presentTee && originTee == KM_ORIGIN_GENERATED
194+
195+
if union && !generated {
196+
generated = presentSoftware && originSoftware == KM_ORIGIN_GENERATED
197+
}
182198

183199
if !generated {
184-
return ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with origin not equal KM_ORIGIN_GENERATED")
200+
return ErrAttestationFormat.WithDetails(fmt.Sprintf("Attestation certificate extensions contains %s with origin not equal KM_ORIGIN_GENERATED", androidKeyScopeDescription(union)))
185201
}
186202

187203
// The value in the AuthorizationList.purpose field is equal to KM_PURPOSE_SIGN (which == 2).
188-
if !contains(decoded.SoftwareEnforced.Purpose, KM_PURPOSE_SIGN) && !contains(decoded.TeeEnforced.Purpose, KM_PURPOSE_SIGN) {
189-
return ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with purpose not equal KM_PURPOSE_SIGN")
204+
sign := contains(decoded.TeeEnforced.Purpose, KM_PURPOSE_SIGN)
205+
206+
if union && !sign {
207+
sign = contains(decoded.SoftwareEnforced.Purpose, KM_PURPOSE_SIGN)
208+
}
209+
210+
if !sign {
211+
return ErrAttestationFormat.WithDetails(fmt.Sprintf("Attestation certificate extensions contains %s with purpose not equal KM_PURPOSE_SIGN", androidKeyScopeDescription(union)))
190212
}
191213

192214
return nil
193215
}
194216

217+
// androidKeyScopeDescription names the authorization lists the §8.4 origin and purpose requirements were evaluated
218+
// against so that a failure identifies the scope in effect. The union wording is unqualified as it matches the
219+
// specification's own phrasing and preserves the error text of the union scope.
220+
func androidKeyScopeDescription(union bool) string {
221+
if union {
222+
return "authorization list"
223+
}
224+
225+
return "teeEnforced authorization list"
226+
}
227+
195228
// authorizationListOrigin returns the origin of an authorization list and reports whether the field was present. The
196229
// value is decoded from the raw element because encoding/asn1 leaves an absent optional integer at its zero value,
197230
// which is indistinguishable from a present origin of KM_ORIGIN_GENERATED.
@@ -264,8 +297,10 @@ var authorizationListValidatedTags = []int{
264297
// [authorizationList] can't model and which precedes a field the verification procedure depends on.
265298
//
266299
// An unmodelled tag otherwise defeats the §8.4 requirement that allApplications is absent, as the element is dropped
267-
// along with every field declared after it while the union permits the other list to supply the origin and purpose. A
268-
// list which can't be modelled in full is rejected explicitly rather than silently truncated.
300+
// along with every field declared after it. That requirement is checked against both lists in every scope, not only
301+
// when the union scope draws on softwareEnforced for the origin and purpose checks, so an unmodelled tag in either
302+
// list needs the same protection regardless of which scope the Relying Party has selected. A list which can't be
303+
// modelled in full is rejected explicitly rather than silently truncated.
269304
func androidKeyVerifyAuthorizationListTags(raw *androidkeyDescriptionRaw) *Error {
270305
for _, list := range []struct {
271306
name string
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package protocol_test
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/hex"
6+
"encoding/json"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/go-webauthn/webauthn/protocol"
13+
"github.com/go-webauthn/webauthn/protocol/webauthncose"
14+
"github.com/go-webauthn/webauthn/webauthn"
15+
)
16+
17+
func TestCreateCredential_AndroidKeyAuthorizationScope(t *testing.T) {
18+
const (
19+
errTEEEnforced = "Attestation certificate extensions contains teeEnforced authorization list with origin not equal KM_ORIGIN_GENERATED"
20+
errUnion = "Attestation certificate extensions contains authorization list with origin not equal KM_ORIGIN_GENERATED"
21+
)
22+
23+
testCases := []struct {
24+
name string
25+
attestation protocol.AttestationPolicy
26+
err string
27+
}{
28+
{
29+
name: "ShouldRejectSoftwareBackedKeyUnderDefaultScope",
30+
attestation: protocol.AttestationPolicy{},
31+
err: errTEEEnforced,
32+
},
33+
{
34+
name: "ShouldRejectSoftwareBackedKeyUnderTEEEnforcedScope",
35+
attestation: protocol.AttestationPolicy{
36+
AndroidKey: protocol.AndroidKeyPolicy{AuthorizationScope: protocol.AndroidKeyAuthorizationScopeTEEEnforced},
37+
},
38+
err: errTEEEnforced,
39+
},
40+
{
41+
name: "ShouldRejectSoftwareBackedKeyUnderUnionScopeWithUnionWording",
42+
attestation: protocol.AttestationPolicy{
43+
AndroidKey: protocol.AndroidKeyPolicy{AuthorizationScope: protocol.AndroidKeyAuthorizationScopeUnion},
44+
},
45+
err: errUnion,
46+
},
47+
}
48+
49+
for _, tc := range testCases {
50+
t.Run(tc.name, func(t *testing.T) {
51+
body, challenge := androidKeyScopeWiringSpecVector(t)
52+
53+
parsedResponse, err := protocol.ParseCredentialCreationResponseBytes(body)
54+
require.NoError(t, err)
55+
56+
userID := []byte("test-user-id")
57+
58+
w := &webauthn.WebAuthn{
59+
Config: &webauthn.Config{
60+
RPID: "example.org",
61+
RPOrigins: []string{"https://example.org"},
62+
Attestation: tc.attestation,
63+
},
64+
}
65+
66+
session := webauthn.SessionData{
67+
Challenge: challenge,
68+
UserID: userID,
69+
CredParams: []protocol.CredentialParameter{{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}},
70+
}
71+
72+
credential, err := w.CreateCredential(androidKeyScopeWiringTestUser{id: userID}, session, parsedResponse)
73+
74+
assert.Nil(t, credential)
75+
assert.EqualError(t, err, tc.err)
76+
})
77+
}
78+
}
79+
80+
type androidKeyScopeWiringTestUser struct {
81+
id []byte
82+
}
83+
84+
func (u androidKeyScopeWiringTestUser) WebAuthnID() []byte { return u.id }
85+
func (u androidKeyScopeWiringTestUser) WebAuthnName() string { return "test-user" }
86+
func (u androidKeyScopeWiringTestUser) WebAuthnDisplayName() string { return "Test User" }
87+
func (u androidKeyScopeWiringTestUser) WebAuthnCredentials() []webauthn.Credential { return nil }
88+
89+
func androidKeyScopeWiringSpecVector(t *testing.T) (body []byte, challenge string) {
90+
t.Helper()
91+
92+
const (
93+
attestationObjectHex = "a363666d746b616e64726f69642d6b65796761747453746d74a363616c67266373696758483046022100e95512982aa3f216cff2e87c8ec57057b8529f674eaabeccaa27fd03d8779f19022100afb6bf459da4a826f00d01fc6b60712ff31dc4eb331619c8f874bb17e4314e94637835638159026e3082026a30820210a00302010202101ff91f76b63f44812f998b250b0286bf300a06082a8648ce3d0403023062311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331253023060355040b0c1c41757468656e74696361746f72204174746573746174696f6e204341310b30090603550406130241413020170d3234303130313030303030305a180f33303234303130313030303030305a305f311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e310b30090603550406130241413059301306072a8648ce3d020106082a8648ce3d0301070342000499169657036d089a2a9821a7d0063d341f1a4613389359636efab5f3cbf1accfdd91c55543176ea99b644406dd1dd63774b6af65ac759e06ff40b1c8ab02df6ba381a83081a5300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d0e041604141ac81e50641e8d1339ab9f7eb25f0cd5aac054b0301f0603551d2304183016801445aff715b0dd786741fee996ebc16547a3931b1e3045060a2b06010401d679020111043730350202012c0a01000201000a01000420b435028d7b6a8f83bb461d41c19b053a9d3cdb30351a4f374cd4cde8dbefb606040030003000300a06082a8648ce3d040302034800304502202d27f0ca39d2f519fc8f49c6d96dfc793059e211ff80516a50398cf1eac2a322022100d482a88c740f64cf6a98ccc6c8b9f5e1e533fa5e509f0a7b4c3a02f964a8eba768617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000ade9705e1ce7085b899a540d02199bf800200a4729519788b6ed8a2d772b494e186244d8c798c052960dbc8c10c915176795a501020326200121582099169657036d089a2a9821a7d0063d341f1a4613389359636efab5f3cbf1accf225820dd91c55543176ea99b644406dd1dd63774b6af65ac759e06ff40b1c8ab02df6b"
94+
clientDataJSONHex = "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a2250654877747a5a647a4e345f384d76795869625f7037725f682d385162494438686c334541746d57414641222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a205656316351755232714c4d5f616d50666f487a4c3067227d"
95+
credentialIDHex = "0a4729519788b6ed8a2d772b494e186244d8c798c052960dbc8c10c915176795" //nolint:gosec
96+
challengeHex = "3de1f0b7365dccde3ff0cbf25e26ffa7baff87ef106c80fc865dc402d9960050"
97+
)
98+
99+
credentialID, err := hex.DecodeString(credentialIDHex)
100+
require.NoError(t, err)
101+
102+
challengeBytes, err := hex.DecodeString(challengeHex)
103+
require.NoError(t, err)
104+
105+
challenge = base64.RawURLEncoding.EncodeToString(challengeBytes)
106+
107+
attestationObjectBytes, err := hex.DecodeString(attestationObjectHex)
108+
require.NoError(t, err)
109+
110+
clientDataJSONBytes, err := hex.DecodeString(clientDataJSONHex)
111+
require.NoError(t, err)
112+
113+
id := base64.RawURLEncoding.EncodeToString(credentialID)
114+
attObj := base64.RawURLEncoding.EncodeToString(attestationObjectBytes)
115+
cdj := base64.RawURLEncoding.EncodeToString(clientDataJSONBytes)
116+
117+
response := map[string]any{
118+
"id": id,
119+
"rawId": id,
120+
"type": "public-key",
121+
"response": map[string]any{
122+
"attestationObject": attObj,
123+
"clientDataJSON": cdj,
124+
},
125+
}
126+
127+
body, err = json.Marshal(response)
128+
require.NoError(t, err)
129+
130+
return body, challenge
131+
}

0 commit comments

Comments
 (0)