Files
web/third_party/go-webauthn/protocol/assertion_test.go
T
gamertan bfe6cfd29e
verify / verify (push) Successful in 3m40s
auth: publish passkey foundations preview
2026-08-21 17:33:00 -04:00

539 lines
18 KiB
Go
Vendored

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: <nil>",
},
{
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
`,
}