package webauthn import ( "bytes" "crypto/sha256" "encoding/base64" "io" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sonr-io/common/webauthn/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 errString 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, }, }, }, errString: "", }, { name: "ShouldHandleTrailingData", args: args{ "trailingData", }, expected: nil, errString: "Parse error for Assertion", errType: "invalid_request", errDetails: "Parse error for Assertion", errInfo: "The 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.errString != "" { assert.EqualError(t, err, tc.errString) 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 TestParsedCredentialAssertionData_Verify(t *testing.T) { type fields struct { ParsedPublicKeyCredential ParsedPublicKeyCredential Response ParsedAssertionResponse Raw CredentialAssertionResponse } type args struct { storedChallenge URLEncodedBase64 relyingPartyID string relyingPartyOrigin []string verifyUser bool credentialBytes []byte } // Helper function to create test credential makeTestCredential := func() ParsedPublicKeyCredential { return ParsedPublicKeyCredential{ ParsedCredential: ParsedCredential{ ID: "test-credential-id", Type: "public-key", }, } } // Helper function to create test response makeTestResponse := func(challenge, origin string, flags byte) ParsedAssertionResponse { // Generate valid RPID hash for "example.com" rpidHash := sha256.Sum256([]byte("example.com")) return ParsedAssertionResponse{ CollectedClientData: CollectedClientData{ Type: CeremonyType("webauthn.get"), Challenge: challenge, Origin: origin, }, AuthenticatorData: AuthenticatorData{ RPIDHash: rpidHash[:], Counter: 100, Flags: AuthenticatorFlags(flags), }, } } // Create a mock EC2 public key credential for testing // This is a minimal CBOR-encoded EC2 public key mockCredentialBytes := []byte{ 0xa5, // map(5) 0x01, 0x02, // kty: 2 (EC2) 0x03, 0x26, // alg: -7 (ES256) 0x20, 0x01, // crv: 1 (P-256) 0x21, 0x58, 0x20, // x: bytes(32) 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x22, 0x58, 0x20, // y: bytes(32) 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, } tests := []struct { name string fields fields args args wantErr bool }{ // Note: These "valid" test cases will fail at signature verification // since we're using mock data. The purpose is to test the validation // logic up to that point. Real signature verification is tested elsewhere. { name: "Valid assertion with user verification (fails at sig verify)", fields: fields{ ParsedPublicKeyCredential: makeTestCredential(), Response: makeTestResponse( base64.RawURLEncoding.EncodeToString([]byte("test-challenge")), "https://example.com", 0x05, ), Raw: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ID: "test-credential-id", Type: "public-key"}, }, }, }, args: args{ storedChallenge: URLEncodedBase64("test-challenge"), relyingPartyID: "example.com", relyingPartyOrigin: []string{"https://example.com"}, verifyUser: true, credentialBytes: mockCredentialBytes, }, wantErr: true, // Changed to true since sig verification will fail }, { name: "Invalid challenge", fields: fields{ ParsedPublicKeyCredential: makeTestCredential(), Response: makeTestResponse( "wrong-challenge", "https://example.com", 0x05, ), Raw: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ID: "test-credential-id", Type: "public-key"}, }, }, }, args: args{ storedChallenge: URLEncodedBase64("test-challenge"), relyingPartyID: "example.com", relyingPartyOrigin: []string{"https://example.com"}, verifyUser: true, credentialBytes: mockCredentialBytes, }, wantErr: true, }, { name: "Invalid origin", fields: fields{ ParsedPublicKeyCredential: makeTestCredential(), Response: makeTestResponse( "test-challenge", "https://evil.com", 0x05, ), Raw: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ID: "test-credential-id", Type: "public-key"}, }, }, }, args: args{ storedChallenge: URLEncodedBase64("test-challenge"), relyingPartyID: "example.com", relyingPartyOrigin: []string{"https://example.com"}, verifyUser: true, credentialBytes: mockCredentialBytes, }, wantErr: true, }, { name: "User verification required but not performed", fields: fields{ ParsedPublicKeyCredential: makeTestCredential(), Response: makeTestResponse( "test-challenge", "https://example.com", 0x01, ), Raw: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ID: "test-credential-id", Type: "public-key"}, }, }, }, args: args{ storedChallenge: URLEncodedBase64("test-challenge"), relyingPartyID: "example.com", relyingPartyOrigin: []string{"https://example.com"}, verifyUser: true, credentialBytes: mockCredentialBytes, }, wantErr: true, }, { name: "Valid assertion without user verification required (fails at sig verify)", fields: fields{ ParsedPublicKeyCredential: makeTestCredential(), Response: makeTestResponse( base64.RawURLEncoding.EncodeToString([]byte("test-challenge")), "https://example.com", 0x01, ), Raw: CredentialAssertionResponse{ PublicKeyCredential: PublicKeyCredential{ Credential: Credential{ID: "test-credential-id", Type: "public-key"}, }, }, }, args: args{ storedChallenge: URLEncodedBase64("test-challenge"), relyingPartyID: "example.com", relyingPartyOrigin: []string{"https://example.com"}, verifyUser: false, credentialBytes: mockCredentialBytes, }, wantErr: true, // Changed to true since sig verification will fail }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &ParsedCredentialAssertionData{ ParsedPublicKeyCredential: tt.fields.ParsedPublicKeyCredential, Response: tt.fields.Response, Raw: tt.fields.Raw, } if err := p.Verify(tt.args.storedChallenge.String(), tt.args.relyingPartyID, tt.args.relyingPartyOrigin, nil, TopOriginIgnoreVerificationMode, "", tt.args.verifyUser, false, tt.args.credentialBytes); (err != nil) != tt.wantErr { t.Errorf( "ParsedCredentialAssertionData.Verify() error = %v, wantErr %v", err, tt.wantErr, ) } }) } } 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 `, }