package protocol import ( "bytes" "encoding/base64" "encoding/hex" "encoding/json" "io" "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/go-webauthn/webauthn/protocol/webauthncbor" ) func TestParseCredentialRequestResponse(t *testing.T) { byteID, _ := base64.RawURLEncoding.DecodeString("AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng") byteAAGUID, _ := base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw") byteRPIDHash, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA") byteAuthData, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ") byteSignature, _ := base64.RawURLEncoding.DecodeString("MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc") byteUserHandle, _ := base64.RawURLEncoding.DecodeString("0ToAAAAAAAAAAA") byteCredentialPubKey, _ := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI") byteClientDataJSON, _ := base64.RawURLEncoding.DecodeString("eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9") type args struct { responseName string } testCases := []struct { name string args args expected *ParsedCredentialAssertionData err string errType string errDetails string errInfo string }{ { name: "ShouldParseCredentialAssertion", args: args{ "success", }, expected: &ParsedCredentialAssertionData{ ParsedPublicKeyCredential: ParsedPublicKeyCredential{ ParsedCredential: ParsedCredential{ ID: "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", Type: string(PublicKeyCredentialType), }, RawID: byteID, ClientExtensionResults: map[string]any{ "appID": "example.com", }, }, Response: ParsedAssertionResponse{ CollectedClientData: CollectedClientData{ Type: CeremonyType("webauthn.get"), Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k", Origin: "https://webauthn.io", Hint: "do not compare clientDataJSON against a template. See https://goo.gl/yabPex", }, AuthenticatorData: AuthenticatorData{ RPIDHash: byteRPIDHash, Counter: 1553097241, Flags: 0x045, AttData: AttestedCredentialData{ AAGUID: byteAAGUID, CredentialID: byteID, CredentialPublicKey: byteCredentialPubKey, }, }, Signature: byteSignature, UserHandle: byteUserHandle, }, Raw: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ Type: string(PublicKeyCredentialType), ID: "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", }, RawID: byteID, ClientExtensionResults: map[string]any{ "appID": "example.com", }, }, AssertionResponse: AuthenticatorAssertionResponse{ AuthenticatorResponse: AuthenticatorResponse{ ClientDataJSON: byteClientDataJSON, }, AuthenticatorData: byteAuthData, Signature: byteSignature, UserHandle: byteUserHandle, }, }, }, err: "", }, { name: "ShouldHandleTrailingData", args: args{ "trailingData", }, expected: nil, err: "Parse error for Assertion", errType: "invalid_request", errDetails: "Parse error for Assertion", errInfo: "body contains trailing data", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() body := io.NopCloser(bytes.NewReader([]byte(testAssertionResponses[tc.args.responseName]))) actual, err := ParseCredentialRequestResponseBody(body) if tc.err != "" { assert.EqualError(t, err, tc.err) AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo) return } require.NoError(t, err) assert.Equal(t, tc.expected.ClientExtensionResults, actual.ClientExtensionResults) assert.Equal(t, tc.expected.ID, actual.ID) assert.Equal(t, tc.expected.ParsedCredential, actual.ParsedCredential) assert.Equal(t, tc.expected.ParsedPublicKeyCredential, actual.ParsedPublicKeyCredential) assert.Equal(t, tc.expected.Raw, actual.Raw) assert.Equal(t, tc.expected.RawID, actual.RawID) assert.Equal(t, tc.expected.Response.CollectedClientData, actual.Response.CollectedClientData) var ( pkExpected, pkActual any ) assert.NoError(t, webauthncbor.Unmarshal(tc.expected.Response.AuthenticatorData.AttData.CredentialPublicKey, &pkExpected)) assert.NoError(t, webauthncbor.Unmarshal(actual.Response.AuthenticatorData.AttData.CredentialPublicKey, &pkActual)) assert.Equal(t, pkExpected, pkActual) assert.NotEqual(t, nil, pkExpected) assert.NotEqual(t, nil, pkActual) }) } } func TestParseCredentialRequestResponse_NilRequest(t *testing.T) { testCases := []struct { name string request *http.Request err string }{ { name: "ShouldFailNilRequest", request: nil, err: "No response given", }, { name: "ShouldFailNilBody", request: &http.Request{}, err: "No response given", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := ParseCredentialRequestResponse(tc.request) assert.Nil(t, result) assert.EqualError(t, err, tc.err) }) } } func TestParseCredentialRequestResponseBytes(t *testing.T) { testCases := []struct { name string data []byte err string errType string errDetails string errInfo string }{ { name: "ShouldFailInvalidJSON", data: []byte("not json"), err: "Parse error for Assertion", errType: "invalid_request", errDetails: "Parse error for Assertion", errInfo: "invalid character 'o' in literal null (expecting 'u')", }, { name: "ShouldFailTrailingData", data: []byte(testAssertionResponses["trailingData"]), err: "Parse error for Assertion", errType: "invalid_request", errDetails: "Parse error for Assertion", errInfo: "body contains trailing data", }, { name: "ShouldSucceed", data: []byte(testAssertionResponses["success"]), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := ParseCredentialRequestResponseBytes(tc.data) if tc.err != "" { assert.Nil(t, result) assert.EqualError(t, err, tc.err) AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo) } else { require.NoError(t, err) require.NotNil(t, result) } }) } } func TestCredentialAssertionResponse_Parse_Errors(t *testing.T) { testCases := []struct { name string car CredentialAssertionResponse err string }{ { name: "ShouldFailMissingID", car: CredentialAssertionResponse{}, err: "CredentialAssertionResponse with ID missing", }, { name: "ShouldFailIDNotBase64", car: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ ID: "not valid base64 %%%", }, }, }, err: "CredentialAssertionResponse with ID not base64url encoded", }, { name: "ShouldFailBadType", car: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ ID: "dGVzdA", Type: "bad-type", }, }, }, err: "CredentialAssertionResponse with bad type", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := tc.car.Parse() assert.Nil(t, result) assert.EqualError(t, err, tc.err) }) } } func TestParsedCredentialAssertionData_Verify(t *testing.T) { par, credPubKey, challenge := testAssertionSpecVectorNoneES256(t) // Valid but wrong public key (from the packed self ES256 spec test vector). wrongKey, err := hex.DecodeString("a5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2") require.NoError(t, err) testCases := []struct { name string challenge string relyingPartyID string rpOrigins []string appID string credentialBytes []byte err string }{ { name: "ShouldSucceed", challenge: challenge, relyingPartyID: "example.org", rpOrigins: []string{"https://example.org"}, credentialBytes: credPubKey, }, { name: "ShouldFailClientDataVerification", challenge: "wrong-challenge", relyingPartyID: "example.org", rpOrigins: []string{"https://example.org"}, credentialBytes: credPubKey, err: "Error validating challenge", }, { name: "ShouldFailAuthDataVerification", challenge: challenge, relyingPartyID: "wrong-rp-id.example.com", rpOrigins: []string{"https://example.org"}, credentialBytes: credPubKey, err: "Error validating the authenticator response", }, { name: "ShouldFailInvalidPublicKey", challenge: challenge, relyingPartyID: "example.org", rpOrigins: []string{"https://example.org"}, credentialBytes: []byte("invalid-key"), err: "Error parsing the assertion public key: Unsupported Public Key Type", }, { name: "ShouldFailSignatureVerification", challenge: challenge, relyingPartyID: "example.org", rpOrigins: []string{"https://example.org"}, credentialBytes: wrongKey, err: "Error validating the assertion signature: ", }, { name: "ShouldFailWithAppID", challenge: challenge, relyingPartyID: "example.org", rpOrigins: []string{"https://example.org"}, appID: "https://example.org", credentialBytes: credPubKey, err: "Error parsing the assertion public key: failed to parse FIDO public key: crypto/ecdh: invalid public key", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { err := par.Verify(tc.challenge, tc.relyingPartyID, tc.appID, tc.rpOrigins, nil, TopOriginExplicitVerificationMode, false, false, true, tc.credentialBytes) if tc.err == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.err) } }) } } func TestParseCredentialRequestResponse_Success(t *testing.T) { body := io.NopCloser(bytes.NewReader([]byte(testAssertionResponses["success"]))) req := &http.Request{Body: body} result, err := ParseCredentialRequestResponse(req) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", result.ID) } func TestCredentialAssertionResponse_Parse_AuthenticatorAttachment(t *testing.T) { testCases := []struct { name string attachment string expectedAttachment AuthenticatorAttachment }{ { name: "ShouldHandlePlatform", attachment: "platform", expectedAttachment: Platform, }, { name: "ShouldHandleCrossPlatform", attachment: "cross-platform", expectedAttachment: CrossPlatform, }, { name: "ShouldHandleEmpty", attachment: "", expectedAttachment: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { response := testAssertionResponses["success"] var raw map[string]any require.NoError(t, json.Unmarshal([]byte(response), &raw)) if tc.attachment != "" { raw["authenticatorAttachment"] = tc.attachment } data, err := json.Marshal(raw) require.NoError(t, err) result, err := ParseCredentialRequestResponseBytes(data) require.NoError(t, err) assert.Equal(t, tc.expectedAttachment, result.AuthenticatorAttachment) }) } } func TestCredentialAssertionResponse_Parse_ClientDataJSONError(t *testing.T) { car := CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ ID: "dGVzdA", Type: string(PublicKeyCredentialType), }, }, AssertionResponse: AuthenticatorAssertionResponse{ AuthenticatorResponse: AuthenticatorResponse{ ClientDataJSON: []byte("not valid json"), }, AuthenticatorData: []byte{ // Minimal valid auth data: 32 bytes rpIdHash + 1 byte flags + 4 bytes counter = 37 bytes. 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, // Flags Value: UP. 0, 0, 0, 0, 0, 0, 0, 0, // Counter Value. }, }, } result, err := car.Parse() assert.Nil(t, result) require.Error(t, err) } func TestCredentialAssertionResponse_Parse_AuthDataError(t *testing.T) { car := CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ ID: "dGVzdA", Type: string(PublicKeyCredentialType), }, }, AssertionResponse: AuthenticatorAssertionResponse{ AuthenticatorResponse: AuthenticatorResponse{ ClientDataJSON: []byte(`{"type":"webauthn.get","challenge":"dGVzdA","origin":"https://example.org"}`), }, AuthenticatorData: []byte{0x01, 0x02}, // Too short to be valid. }, } result, err := car.Parse() assert.Nil(t, result) assert.EqualError(t, err, "Error unmarshalling auth data") } // testAssertionSpecVectorNoneES256 returns a parsed assertion and credentials for testing. func testAssertionSpecVectorNoneES256(t *testing.T) (par *ParsedCredentialAssertionData, credPubKey []byte, challenge string) { t.Helper() const ( authenticatorDataHex = "bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000" clientDataJSONHex = "7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d" signatureHex = "3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87" credentialIDHex = "f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4" //nolint:gosec challengeHex = "39c0e7521417ba54d43e8dc95174f423dee9bf3cd804ff6d65c857c9abf4d408" credentialPubKeyHex = "a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220" ) credentialID, err := hex.DecodeString(credentialIDHex) require.NoError(t, err) credPubKey, err = hex.DecodeString(credentialPubKeyHex) require.NoError(t, err) challenge = base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, challengeHex)) id := base64.RawURLEncoding.EncodeToString(credentialID) authenticatorData := base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, authenticatorDataHex)) clientDataJSON := base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, clientDataJSONHex)) signature := base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, signatureHex)) body := map[string]any{ "id": id, "rawId": id, "type": "public-key", "response": map[string]any{ "authenticatorData": authenticatorData, "clientDataJSON": clientDataJSON, "signature": signature, }, } data, err := json.Marshal(body) require.NoError(t, err) par, err = ParseCredentialRequestResponseBytes(data) require.NoError(t, err) return par, credPubKey, challenge } func assertionTestDecodeHex(t *testing.T, s string) []byte { t.Helper() data, err := hex.DecodeString(s) require.NoError(t, err) return data } var testAssertionResponses = map[string]string{ // None Attestation - MacOS TouchID. `success`: `{ "id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", "rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", "clientExtensionResults":{"appID":"example.com"}, "type":"public-key", "response":{ "authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9", "signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc", "userHandle":"0ToAAAAAAAAAAA"} } `, `trailingData`: `{ "id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", "rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", "clientExtensionResults":{"appID":"example.com"}, "type":"public-key", "response":{ "authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9", "signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc", "userHandle":"0ToAAAAAAAAAAA"} } trailing `, }