Files
common/webauthn/assertion_test.go

421 lines
14 KiB
Go

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
`,
}