auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
+67
View File
@@ -0,0 +1,67 @@
package webauthn
import (
"github.com/go-webauthn/webauthn/protocol"
)
//go:generate msgp
//msgp:replace protocol.AuthenticatorAttachment with:string
//msgp:clearomitted
// Authenticator represents a specific authenticator in the context of a [Credential].
type Authenticator struct {
// The AAGUID of the authenticator. An AAGUID is defined as an array containing the globally unique
// identifier of the authenticator model being sought.
AAGUID []byte `json:"AAGUID,omitempty" msg:"aaguid,omitempty"`
// SignCount is a representation of the number of times the Authenticator or Credential have been used to login.
// Upon a new login operation, the Relying Party compares the stored signature counter value with the new SignCount
// value returned in the assertions authenticator data. If this new SignCount value is less than or equal to the
// stored value, a cloned authenticator may exist, or the authenticator may be malfunctioning.
SignCount uint32 `json:"signCount,omitempty" msg:"sc,omitempty"`
// CloneWarning is a signal that the authenticator may be cloned, i.e. at least two copies of the
// credential private key may exist and are being used in parallel. Relying Parties should incorporate
// this information into their risk scoring. Whether the Relying Party updates the stored signature
// counter value in this case, or not, or fails the authentication ceremony or not, is Relying Party-specific.
CloneWarning bool `json:"cloneWarning,omitempty" msg:"cw,omitempty"`
// Attachment is the authenticatorAttachment value returned by the request.
Attachment protocol.AuthenticatorAttachment `json:"attachment,omitempty" msg:"aa,omitempty"`
}
// SelectAuthenticator is a convenience function that constructs a [protocol.AuthenticatorSelection] from individual
// string and boolean parameters. Use [protocol.ResidentKeyRequired] or [protocol.ResidentKeyNotRequired] for the rrk
// parameter.
func SelectAuthenticator(att string, rrk *bool, uv string) protocol.AuthenticatorSelection {
return protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.AuthenticatorAttachment(att),
RequireResidentKey: rrk,
UserVerification: protocol.UserVerificationRequirement(uv),
}
}
// UpdateCounter updates the authenticator and either sets the clone warning value or the sign count.
//
// Step 17 of §7.2. about verifying attestation. If the signature counter value authData.signCount
// is nonzero or the value stored in conjunction with credentials id attribute is nonzero, then
// run the following sub-step:
//
// If the signature counter value authData.signCount is
//
// → Greater than the signature counter value stored in conjunction with credentials id attribute.
// Update the stored signature counter value, associated with credentials id attribute, to be the value of
// authData.signCount.
//
// → Less than or equal to the signature counter value stored in conjunction with credentials id attribute.
// This is a signal that the authenticator may be cloned, see CloneWarning above for more information.
func (a *Authenticator) UpdateCounter(authDataCount uint32) {
if authDataCount <= a.SignCount && (authDataCount != 0 || a.SignCount != 0) {
a.CloneWarning = true
return
}
a.SignCount = authDataCount
}
+305
View File
@@ -0,0 +1,305 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *Authenticator) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "aaguid":
z.AAGUID, err = dc.ReadBytes(z.AAGUID)
if err != nil {
err = msgp.WrapError(err, "AAGUID")
return
}
zb0001Mask |= 0x1
case "sc":
z.SignCount, err = dc.ReadUint32()
if err != nil {
err = msgp.WrapError(err, "SignCount")
return
}
zb0001Mask |= 0x2
case "cw":
z.CloneWarning, err = dc.ReadBool()
if err != nil {
err = msgp.WrapError(err, "CloneWarning")
return
}
zb0001Mask |= 0x4
case "aa":
{
var zb0002 string
zb0002, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Attachment")
return
}
z.Attachment = protocol.AuthenticatorAttachment(zb0002)
}
zb0001Mask |= 0x8
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0xf {
if (zb0001Mask & 0x1) == 0 {
z.AAGUID = nil
}
if (zb0001Mask & 0x2) == 0 {
z.SignCount = 0
}
if (zb0001Mask & 0x4) == 0 {
z.CloneWarning = false
}
if (zb0001Mask & 0x8) == 0 {
z.Attachment = ""
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *Authenticator) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(4)
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
if z.AAGUID == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.SignCount == 0 {
zb0001Len--
zb0001Mask |= 0x2
}
if z.CloneWarning == false {
zb0001Len--
zb0001Mask |= 0x4
}
if z.Attachment == "" {
zb0001Len--
zb0001Mask |= 0x8
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// write "aaguid"
err = en.Append(0xa6, 0x61, 0x61, 0x67, 0x75, 0x69, 0x64)
if err != nil {
return
}
err = en.WriteBytes(z.AAGUID)
if err != nil {
err = msgp.WrapError(err, "AAGUID")
return
}
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "sc"
err = en.Append(0xa2, 0x73, 0x63)
if err != nil {
return
}
err = en.WriteUint32(z.SignCount)
if err != nil {
err = msgp.WrapError(err, "SignCount")
return
}
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "cw"
err = en.Append(0xa2, 0x63, 0x77)
if err != nil {
return
}
err = en.WriteBool(z.CloneWarning)
if err != nil {
err = msgp.WrapError(err, "CloneWarning")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "aa"
err = en.Append(0xa2, 0x61, 0x61)
if err != nil {
return
}
err = en.WriteString(string(z.Attachment))
if err != nil {
err = msgp.WrapError(err, "Attachment")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *Authenticator) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(4)
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
if z.AAGUID == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.SignCount == 0 {
zb0001Len--
zb0001Mask |= 0x2
}
if z.CloneWarning == false {
zb0001Len--
zb0001Mask |= 0x4
}
if z.Attachment == "" {
zb0001Len--
zb0001Mask |= 0x8
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// string "aaguid"
o = append(o, 0xa6, 0x61, 0x61, 0x67, 0x75, 0x69, 0x64)
o = msgp.AppendBytes(o, z.AAGUID)
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "sc"
o = append(o, 0xa2, 0x73, 0x63)
o = msgp.AppendUint32(o, z.SignCount)
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "cw"
o = append(o, 0xa2, 0x63, 0x77)
o = msgp.AppendBool(o, z.CloneWarning)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "aa"
o = append(o, 0xa2, 0x61, 0x61)
o = msgp.AppendString(o, string(z.Attachment))
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *Authenticator) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "aaguid":
z.AAGUID, bts, err = msgp.ReadBytesBytes(bts, z.AAGUID)
if err != nil {
err = msgp.WrapError(err, "AAGUID")
return
}
zb0001Mask |= 0x1
case "sc":
z.SignCount, bts, err = msgp.ReadUint32Bytes(bts)
if err != nil {
err = msgp.WrapError(err, "SignCount")
return
}
zb0001Mask |= 0x2
case "cw":
z.CloneWarning, bts, err = msgp.ReadBoolBytes(bts)
if err != nil {
err = msgp.WrapError(err, "CloneWarning")
return
}
zb0001Mask |= 0x4
case "aa":
{
var zb0002 string
zb0002, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Attachment")
return
}
z.Attachment = protocol.AuthenticatorAttachment(zb0002)
}
zb0001Mask |= 0x8
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0xf {
if (zb0001Mask & 0x1) == 0 {
z.AAGUID = nil
}
if (zb0001Mask & 0x2) == 0 {
z.SignCount = 0
}
if (zb0001Mask & 0x4) == 0 {
z.CloneWarning = false
}
if (zb0001Mask & 0x8) == 0 {
z.Attachment = ""
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *Authenticator) Msgsize() (s int) {
s = 1 + 7 + msgp.BytesPrefixSize + len(z.AAGUID) + 3 + msgp.Uint32Size + 3 + msgp.BoolSize + 3 + msgp.StringPrefixSize + len(string(z.Attachment))
return
}
@@ -0,0 +1,123 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"bytes"
"testing"
"github.com/tinylib/msgp/msgp"
)
func TestMarshalUnmarshalAuthenticator(t *testing.T) {
v := Authenticator{}
bts, err := v.MarshalMsg(nil)
if err != nil {
t.Fatal(err)
}
left, err := v.UnmarshalMsg(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
}
left, err = msgp.Skip(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
}
}
func BenchmarkMarshalMsgAuthenticator(b *testing.B) {
v := Authenticator{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.MarshalMsg(nil)
}
}
func BenchmarkAppendMsgAuthenticator(b *testing.B) {
v := Authenticator{}
bts := make([]byte, 0, v.Msgsize())
bts, _ = v.MarshalMsg(bts[0:0])
b.SetBytes(int64(len(bts)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
bts, _ = v.MarshalMsg(bts[0:0])
}
}
func BenchmarkUnmarshalAuthenticator(b *testing.B) {
v := Authenticator{}
bts, _ := v.MarshalMsg(nil)
b.ReportAllocs()
b.SetBytes(int64(len(bts)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := v.UnmarshalMsg(bts)
if err != nil {
b.Fatal(err)
}
}
}
func TestEncodeDecodeAuthenticator(t *testing.T) {
v := Authenticator{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
m := v.Msgsize()
if buf.Len() > m {
t.Log("WARNING: TestEncodeDecodeAuthenticator Msgsize() is inaccurate")
}
vn := Authenticator{}
err := msgp.Decode(&buf, &vn)
if err != nil {
t.Error(err)
}
buf.Reset()
msgp.Encode(&buf, &v)
err = msgp.NewReader(&buf).Skip()
if err != nil {
t.Error(err)
}
}
func BenchmarkEncodeAuthenticator(b *testing.B) {
v := Authenticator{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
en := msgp.NewWriter(msgp.Nowhere)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.EncodeMsg(en)
}
en.Flush()
}
func BenchmarkDecodeAuthenticator(b *testing.B) {
v := Authenticator{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
rd := msgp.NewEndlessReader(buf.Bytes(), b)
dc := msgp.NewReader(rd)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := v.DecodeMsg(dc)
if err != nil {
b.Fatal(err)
}
}
}
+277
View File
@@ -0,0 +1,277 @@
package webauthn
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinylib/msgp/msgp"
"github.com/go-webauthn/webauthn/protocol"
)
func TestAuthenticator_UpdateCounter(t *testing.T) {
type fields struct {
AAGUID []byte
SignCount uint32
CloneWarning bool
}
type args struct {
authDataCount uint32
}
testCases := []struct {
name string
fields fields
args args
expected bool
}{
{
"IncreasedCounter",
fields{
AAGUID: make([]byte, 16),
SignCount: 1,
CloneWarning: false,
},
args{
authDataCount: 2,
},
false,
},
{
"UnchangedCounter",
fields{
AAGUID: make([]byte, 16),
SignCount: 1,
CloneWarning: false,
},
args{
authDataCount: 1,
},
true,
},
{
"DecreasedCounter",
fields{
AAGUID: make([]byte, 16),
SignCount: 2,
CloneWarning: false,
},
args{
authDataCount: 1,
},
true,
},
{
"ZeroCounter",
fields{
AAGUID: make([]byte, 16),
SignCount: 0,
CloneWarning: false,
},
args{
authDataCount: 0,
},
false,
},
{
"CounterReturnedToZero",
fields{
AAGUID: make([]byte, 16),
SignCount: 1,
CloneWarning: false,
},
args{
authDataCount: 0,
},
true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
authenticator := &Authenticator{
AAGUID: tc.fields.AAGUID,
SignCount: tc.fields.SignCount,
CloneWarning: tc.fields.CloneWarning,
}
signCount := authenticator.SignCount
authenticator.UpdateCounter(tc.args.authDataCount)
assert.Equal(t, tc.expected, authenticator.CloneWarning)
if authenticator.CloneWarning {
assert.Equal(t, signCount, authenticator.SignCount)
} else {
assert.Equal(t, tc.args.authDataCount, authenticator.SignCount)
}
})
}
}
func TestSelectAuthenticator(t *testing.T) {
type args struct {
att string
rrk *bool
uv string
}
testCases := []struct {
name string
args args
expected protocol.AuthenticatorSelection
}{
{"GenerateCorrectAuthenticatorSelection",
args{
att: "platform",
rrk: protocol.ResidentKeyNotRequired(),
uv: "preferred",
},
protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.Platform,
RequireResidentKey: protocol.ResidentKeyNotRequired(),
UserVerification: protocol.VerificationPreferred,
},
},
{"GenerateCorrectAuthenticatorSelection",
args{
att: "cross-platform",
rrk: protocol.ResidentKeyRequired(),
uv: "required",
},
protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.CrossPlatform,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationRequired,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, SelectAuthenticator(tc.args.att, tc.args.rrk, tc.args.uv))
})
}
}
func TestAuthenticator_MsgpRoundTrip(t *testing.T) {
testCases := []struct {
name string
original Authenticator
}{
{
"FullyPopulated",
Authenticator{
AAGUID: bytes.Repeat([]byte{0xAB}, 16),
SignCount: 1234,
CloneWarning: true,
Attachment: protocol.Platform,
},
},
{
"CrossPlatformNoCloneWarning",
Authenticator{
AAGUID: bytes.Repeat([]byte{0x01}, 16),
SignCount: 1,
CloneWarning: false,
Attachment: protocol.CrossPlatform,
},
},
{
"Zero",
Authenticator{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.original.MarshalMsg(nil)
require.NoError(t, err)
var decoded Authenticator
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left, "UnmarshalMsg should consume all bytes")
assert.Equal(t, tc.original, decoded)
assert.LessOrEqual(t, len(data), tc.original.Msgsize())
var buf bytes.Buffer
require.NoError(t, msgp.Encode(&buf, &tc.original))
var streamDecoded Authenticator
require.NoError(t, msgp.Decode(&buf, &streamDecoded))
assert.Equal(t, tc.original, streamDecoded)
})
}
t.Run("UnmarshalSkipsUnknownKeys", func(t *testing.T) {
tiny := []byte{0x81, 0xa3, 'x', 'y', 'z', 0xc3}
var decoded Authenticator
left, err := decoded.UnmarshalMsg(tiny)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, Authenticator{}, decoded)
})
}
func TestAuthenticator_MsgpEncodeErrorPaths(t *testing.T) {
v := Authenticator{
AAGUID: bytes.Repeat([]byte{0xAB}, 16),
SignCount: 1234,
CloneWarning: true,
Attachment: protocol.Platform,
}
data, err := v.MarshalMsg(nil)
require.NoError(t, err)
exerciseEncodeMsgErrorPaths(t, &v, data)
}
func TestAuthenticator_DecodeMsgInvalidTypes(t *testing.T) {
t.Run("NotAMap", func(t *testing.T) {
var a Authenticator
_, err := a.UnmarshalMsg(msgpString("not a map"))
require.Error(t, err)
var a2 Authenticator
require.Error(t, msgp.Decode(bytes.NewReader(msgpString("not a map")), &a2))
})
testCases := []struct {
name string
data []byte
wantSub string
}{
{"AAGUIDAsBool", msgpOneFieldMap("aaguid", msgpBool(true)), "AAGUID"},
{"SignCountAsString", msgpOneFieldMap("sc", msgpString("x")), "SignCount"},
{"CloneWarningAsInt", msgpOneFieldMap("cw", msgpInt64(42)), "CloneWarning"},
{"AttachmentAsBool", msgpOneFieldMap("aa", msgpBool(true)), "Attachment"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var a Authenticator
_, err := a.UnmarshalMsg(tc.data)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantSub)
var a2 Authenticator
streamErr := msgp.Decode(bytes.NewReader(tc.data), &a2)
require.Error(t, streamErr)
assert.Contains(t, streamErr.Error(), tc.wantSub)
})
}
}
+15
View File
@@ -0,0 +1,15 @@
package webauthn
import (
"time"
)
const (
errFmtFieldNotValidDomainString = "field '%s' is not a valid domain string: %w"
errFmtConfigValidate = "error occurred validating the configuration: %w"
)
const (
defaultTimeoutUVD = time.Millisecond * 120000
defaultTimeout = time.Millisecond * 300000
)
+330
View File
@@ -0,0 +1,330 @@
package webauthn
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"github.com/go-webauthn/webauthn/metadata"
"github.com/go-webauthn/webauthn/protocol"
)
//go:generate msgp
//msgp:replace protocol.AuthenticatorTransport with:string
//msgp:shim CredentialFlags as:byte using:(CredentialFlags).MsgpByte/CredentialFlagsFromMsgpByte
//msgp:clearomitted
// NewCredential returns a [*Credential] from a successfully validated registration response. The returned Credential
// includes a populated [CredentialAttestation] containing the raw attestation data needed for future verification;
// see the [CredentialAttestation] documentation for why these values must be persisted.
func NewCredential(clientDataHash []byte, c *protocol.ParsedCredentialCreationData) (credential *Credential, err error) {
credential = &Credential{
ID: c.Response.AttestationObject.AuthData.AttData.CredentialID,
PublicKey: c.Response.AttestationObject.AuthData.AttData.CredentialPublicKey,
AttestationType: c.Response.AttestationObject.Type,
AttestationFormat: c.Response.AttestationObject.Format,
Transport: c.Response.Transports,
Flags: NewCredentialFlags(c.Response.AttestationObject.AuthData.Flags),
Authenticator: Authenticator{
AAGUID: c.Response.AttestationObject.AuthData.AttData.AAGUID,
SignCount: c.Response.AttestationObject.AuthData.Counter,
Attachment: c.AuthenticatorAttachment,
},
Attestation: CredentialAttestation{
ClientDataJSON: c.Raw.AttestationResponse.ClientDataJSON,
ClientDataHash: clientDataHash,
AuthenticatorData: c.Raw.AttestationResponse.AuthenticatorData,
PublicKeyAlgorithm: c.Raw.AttestationResponse.PublicKeyAlgorithm,
Object: c.Raw.AttestationResponse.AttestationObject,
},
}
return credential, nil
}
// Credential contains all needed information about a WebAuthn credential for storage. This struct is effectively the
// Credential Record as described in the specification.
//
// Provided this data structure is preserved properly, a Credential can be verified against the FIDO Metadata Service
// at a later date using the [Credential.Verify] method with a [metadata.Provider].
//
// It is strongly recommended for the best security that a [Credential] is encrypted at rest with the exception of the
// ID and the value you use to lookup the user. This prevents a person with access to the database being able to
// compromise privacy by being able to view this data, as well as prevents them being able to compromise security by
// adding or modifying a Credential without them also having access to the encryption key.
//
// For consolidated persistence guidance; recommended schema shape, required lookup columns, and which fields
// must be written back on every successful FinishLogin / ValidateLogin; see the [Storage] section of the
// [github.com/go-webauthn/webauthn/webauthn] package documentation.
//
// See: §4. Terminology: Credential Record (https://www.w3.org/TR/webauthn-3/#credential-record)
//
// [Storage]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#hdr-Storage
type Credential struct {
// The ID is the ID of the public key credential source. Described by the Credential Record 'id' field.
ID []byte `json:"id" msg:"id"`
// The credential public key of the public key credential source. Described by the Credential Record 'publicKey'
// field.
PublicKey []byte `json:"publicKey" msg:"pk"`
// AttestationType is the attestation type as conveyed by the authenticator during the registration ceremonyl
// one of the values defined by [metadata.AuthenticatorAttestationType] ("basic_full", "basic_surrogate",
// "attca", "anonca", "ecdaa", "none"). Prior releases incorrectly stored the attestation FORMAT here; see the
// custom [Credential.UnmarshalJSON] for the backward-compatibility migration applied when decoding such
// records.
AttestationType string `json:"attestationType,omitempty" msg:"atttype,omitempty"`
// AttestationFormat is the attestation statement format identifier ("packed", "tpm", "android-key",
// "android-safetynet", "fido-u2f", "apple", "compound", "none"); see §8 of the WebAuthn specification and
// the AttestationFormat constants in the protocol package.
AttestationFormat string `json:"attestationFormat,omitempty" msg:"attfmt,omitempty"`
// Transport types the authenticator supports. Described by the Credential Record 'transports' field.
Transport []protocol.AuthenticatorTransport `json:"transport,omitempty" msg:"t,omitempty"`
// Flags represent the commonly stored flags.
Flags CredentialFlags `json:"flags" msg:"flg"`
// The Authenticator information for a given Credential.
Authenticator Authenticator `json:"authenticator" msg:"a"`
// The attestation values that can be used to validate this Credential via the MDS3 at a later date.
Attestation CredentialAttestation `json:"attestation" msg:"att"`
}
// UnmarshalJSON decodes a [Credential] from JSON, applying a backward-compatibility migration for records produced
// by earlier versions of this library: if the decoded record has no AttestationFormat and the AttestationType value
// is a recognised attestation FORMAT identifier (i.e. "packed", "tpm", "none"), the value is moved to
// AttestationFormat and AttestationType is cleared so callers can re-derive the true attestation type by calling
// [Credential.Verify]. Records that already carry an AttestationFormat are untouched.
func (c *Credential) UnmarshalJSON(data []byte) error {
type credentialAlias Credential
var tmp credentialAlias
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
*c = Credential(tmp)
if c.AttestationFormat == "" && protocol.IsAttestationFormatString(c.AttestationType) {
c.AttestationFormat = c.AttestationType
c.AttestationType = ""
}
return nil
}
// SignalUnknownCredential creates a struct that can easily be marshaled to JSON which indicates this is an unknown
// Credential.
func (c *Credential) SignalUnknownCredential(rpid string) *protocol.SignalUnknownCredential {
return c.Descriptor().SignalUnknownCredential(rpid)
}
// Descriptor converts a [Credential] into a [protocol.CredentialDescriptor].
func (c *Credential) Descriptor() (descriptor protocol.CredentialDescriptor) {
return protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: c.ID,
Transport: c.Transport,
AttestationType: c.AttestationType,
AttestationFormat: c.AttestationFormat,
}
}
// Verify re-runs the full attestation verification for this credential against the given [metadata.Provider]. The
// stored raw attestation bytes are re-parsed, the attestation signature is re-verified, and the authenticator is
// validated against the MDS via [protocol.AttestationObject.VerifyAttestation] (which internally dispatches
// [protocol.ValidateMetadata]). This is the canonical audit path and is at least as strong as the original
// registration-time verification; call it on a schedule (i.e. on login or periodically) to catch MDS status changes
// such as a newly-revoked authenticator model or a compromise advisory published after registration.
//
// Requirements:
//
// - The mds argument must be a non-nil [metadata.Provider]; a nil provider returns an error.
//
// - [CredentialAttestation.ClientDataJSON] must be preserved byte-for-byte; it is re-parsed for its collected
// client data fields and re-hashed when [CredentialAttestation.ClientDataHash] is absent.
//
// - [CredentialAttestation.Object] must be preserved byte-for-byte; it is the raw CBOR attestation object and
// is decoded to recover the authenticator data, statement format, and statement for full re-verification.
//
// - [Credential.PublicKey] must be populated with the CBOR-encoded COSE key as emitted by the authenticator at
// registration. As an integrity check, Verify compares this value byte-for-byte against the credential public
// key carried inside the attestation object and returns an error on mismatch.
//
// - [CredentialAttestation.ClientDataHash] is optional; if empty it is recomputed as the SHA-256 of
// ClientDataJSON.
//
// - [Credential.Transport], [CredentialAttestation.AuthenticatorData], and [CredentialAttestation.PublicKeyAlgorithm]
// are not read by the current Verify implementation (the authenticator data is re-derived from the attestation
// object, and the top-level AuthenticatorData / PublicKeyAlgorithm convenience fields are informational). They
// are still stored so future versions of this library, or alternative verification paths, can consume them;
// see [CredentialAttestation] for why every field should be persisted.
//
// As a side-effect, a successful Verify call will populate [Credential.AttestationType] from the re-derived value
// when the field is empty (i.e. on a record migrated from a pre-split JSON layout by [Credential.UnmarshalJSON]);
// the next marshal of the Credential will then carry the correct attestation type. For this reason Verify uses a
// pointer receiver.
//
// See [CredentialAttestation] for guidance on persisting these raw values securely.
func (c *Credential) Verify(mds metadata.Provider) (err error) {
if mds == nil {
return fmt.Errorf("error verifying credential: the metadata provider must be provided but it's nil")
}
raw := c.toAuthenticatorAttestationResponse()
var attestation *protocol.ParsedAttestationResponse
if attestation, err = raw.Parse(); err != nil {
return fmt.Errorf("error verifying credential: error parsing attestation: %w", err)
}
if !bytes.Equal(c.PublicKey, attestation.AttestationObject.AuthData.AttData.CredentialPublicKey) {
return fmt.Errorf("error verifying credential: stored public key does not match the credential public key embedded in the attestation object")
}
clientDataHash := c.Attestation.ClientDataHash
if len(clientDataHash) == 0 {
sum := sha256.Sum256(c.Attestation.ClientDataJSON)
clientDataHash = sum[:]
}
if err = attestation.AttestationObject.VerifyAttestation(clientDataHash, mds); err != nil {
return fmt.Errorf("error verifying credential: error verifying attestation: %w", err)
}
if c.AttestationType == "" {
c.AttestationType = attestation.AttestationObject.Type
}
return nil
}
func (c *Credential) toAuthenticatorAttestationResponse() *protocol.AuthenticatorAttestationResponse {
raw := &protocol.AuthenticatorAttestationResponse{
AuthenticatorResponse: protocol.AuthenticatorResponse{
ClientDataJSON: c.Attestation.ClientDataJSON,
},
Transports: make([]string, len(c.Transport)),
AuthenticatorData: c.Attestation.AuthenticatorData,
PublicKey: c.PublicKey,
PublicKeyAlgorithm: c.Attestation.PublicKeyAlgorithm,
AttestationObject: c.Attestation.Object,
}
for i, transport := range c.Transport {
raw.Transports[i] = string(transport)
}
return raw
}
// Credentials is a decorator type which allows easily converting a [Credential] slice into a
// [protocol.CredentialDescriptor] slice by utilizing the [Credentials.CredentialDescriptors] method. This will be the
// type used globally for the library in a future release.
type Credentials []Credential
// CredentialDescriptors returns the [protocol.CredentialDescriptor] slice for this [Credentials] type.
func (c Credentials) CredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
descriptors = make([]protocol.CredentialDescriptor, len(c))
for i, credential := range c {
descriptors[i] = credential.Descriptor()
}
return descriptors
}
// NewCredentialFlags is a utility function that is used to derive the [Credential]'s Flags field given a
// [protocol.AuthenticatorFlags]. This allows implementers to solely save the Raw field of the [CredentialFlags] to
// restore them appropriately for appropriate processing without concern that changes forced upon implementers by the
// W3C will introduce breaking changes.
func NewCredentialFlags(flags protocol.AuthenticatorFlags) CredentialFlags {
return CredentialFlags{
UserPresent: flags.HasUserPresent(),
UserVerified: flags.HasUserVerified(),
BackupEligible: flags.HasBackupEligible(),
BackupState: flags.HasBackupState(),
raw: flags,
}
}
// CredentialFlagsFromMsgpByte reconstructs a [CredentialFlags] from the single-byte representation produced by
// [CredentialFlags.MsgpByte]. It is intended for use by the msgp-generated serialization layer; normal callers
// should prefer [NewCredentialFlags].
func CredentialFlagsFromMsgpByte(b byte) CredentialFlags {
return NewCredentialFlags(protocol.AuthenticatorFlags(b))
}
// CredentialFlags contains the boolean flags derived from the authenticator data during registration or login.
// These flags indicate the state of user presence, user verification, and backup eligibility/state at the time
// the credential was used.
type CredentialFlags struct {
// Flag UP indicates the users presence.
UserPresent bool `json:"userPresent"`
// Flag UV indicates the user performed verification.
UserVerified bool `json:"userVerified"`
// Flag BE indicates the credential is able to be backed up and/or sync'd between devices. This should NEVER change.
BackupEligible bool `json:"backupEligible"`
// Flag BS indicates the credential has been backed up and/or sync'd. This value can change but it's recommended
// that RP's keep track of this value.
BackupState bool `json:"backupState"`
raw protocol.AuthenticatorFlags
}
// ProtocolValue returns the underlying [protocol.AuthenticatorFlags] provided this [CredentialFlags] was created using
// NewCredentialFlags.
func (f CredentialFlags) ProtocolValue() protocol.AuthenticatorFlags {
return f.raw
}
// MsgpByte returns the [CredentialFlags] encoded as a single byte, equivalent to the raw
// [protocol.AuthenticatorFlags] value. It is intended for use by the msgp-generated serialization layer (see the
// //msgp:shim directive in this file); normal callers should prefer [CredentialFlags.ProtocolValue].
func (f CredentialFlags) MsgpByte() byte {
return byte(f.raw)
}
// CredentialAttestation holds the raw attestation data from a registration ceremony. These values are intentionally
// stored in their original unparsed form rather than as parsed structures. This is critical because:
//
// - It enables the [Credential] to be verified against the FIDO Metadata Service at a later date using
// [Credential.Verify], even long after the registration ceremony has completed.
// - The WebAuthn specification evolves over time, introducing new validation procedures. Preserving the raw data
// ensures that credentials created today can be re-validated against future rules without requiring re-registration.
// - Raw data serves as an auditable record of exactly what the authenticator and client provided during registration,
// independent of how the library parsed it at that point in time.
//
// Implementers MUST persist all fields of this struct.
type CredentialAttestation struct {
// ClientDataJSON is the raw JSON-encoded client data from the registration response. This is the verbatim value
// provided by the client and is used to recompute the client data hash during later verification.
ClientDataJSON []byte `json:"clientDataJSON,omitempty" msg:"cdj,omitempty"`
// ClientDataHash is the SHA-256 hash of ClientDataJSON computed during registration verification. If empty,
// [Credential.Verify] will recompute it from ClientDataJSON.
ClientDataHash []byte `json:"clientDataHash,omitempty" msg:"cdh,omitempty"`
// AuthenticatorData is the raw authenticator data from the registration response as provided in the
// RegistrationResponseJSON. This is the unparsed byte representation that can be re-parsed for future validation.
AuthenticatorData []byte `json:"authenticatorData,omitempty" msg:"data,omitempty"`
// PublicKeyAlgorithm is the COSE algorithm identifier for the credential's public key.
PublicKeyAlgorithm int64 `json:"publicKeyAlgorithm,omitempty" msg:"alg,omitempty"`
// Object is the raw CBOR-encoded attestation object from the registration response. This contains the attestation
// statement, format, and authenticator data needed by [Credential.Verify] to re-perform attestation verification.
Object []byte `json:"object,omitempty" msg:"obj,omitempty"`
}
+914
View File
@@ -0,0 +1,914 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *Credential) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 3 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "id":
z.ID, err = dc.ReadBytes(z.ID)
if err != nil {
err = msgp.WrapError(err, "ID")
return
}
case "pk":
z.PublicKey, err = dc.ReadBytes(z.PublicKey)
if err != nil {
err = msgp.WrapError(err, "PublicKey")
return
}
case "atttype":
z.AttestationType, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "AttestationType")
return
}
zb0001Mask |= 0x1
case "attfmt":
z.AttestationFormat, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "AttestationFormat")
return
}
zb0001Mask |= 0x2
case "t":
var zb0002 uint32
zb0002, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err, "Transport")
return
}
if cap(z.Transport) >= int(zb0002) {
z.Transport = (z.Transport)[:zb0002]
} else {
z.Transport = make([]protocol.AuthenticatorTransport, zb0002)
}
for za0001 := range z.Transport {
{
var zb0003 string
zb0003, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Transport", za0001)
return
}
z.Transport[za0001] = protocol.AuthenticatorTransport(zb0003)
}
}
zb0001Mask |= 0x4
case "flg":
{
var zb0004 byte
zb0004, err = dc.ReadByte()
if err != nil {
err = msgp.WrapError(err, "Flags")
return
}
z.Flags = CredentialFlagsFromMsgpByte(zb0004)
}
case "a":
err = z.Authenticator.DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
case "att":
err = z.Attestation.DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7 {
if (zb0001Mask & 0x1) == 0 {
z.AttestationType = ""
}
if (zb0001Mask & 0x2) == 0 {
z.AttestationFormat = ""
}
if (zb0001Mask & 0x4) == 0 {
z.Transport = nil
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *Credential) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(8)
var zb0001Mask uint8 /* 8 bits */
_ = zb0001Mask
if z.AttestationType == "" {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AttestationFormat == "" {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Transport == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
// write "id"
err = en.Append(0xa2, 0x69, 0x64)
if err != nil {
return
}
err = en.WriteBytes(z.ID)
if err != nil {
err = msgp.WrapError(err, "ID")
return
}
// write "pk"
err = en.Append(0xa2, 0x70, 0x6b)
if err != nil {
return
}
err = en.WriteBytes(z.PublicKey)
if err != nil {
err = msgp.WrapError(err, "PublicKey")
return
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "atttype"
err = en.Append(0xa7, 0x61, 0x74, 0x74, 0x74, 0x79, 0x70, 0x65)
if err != nil {
return
}
err = en.WriteString(z.AttestationType)
if err != nil {
err = msgp.WrapError(err, "AttestationType")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "attfmt"
err = en.Append(0xa6, 0x61, 0x74, 0x74, 0x66, 0x6d, 0x74)
if err != nil {
return
}
err = en.WriteString(z.AttestationFormat)
if err != nil {
err = msgp.WrapError(err, "AttestationFormat")
return
}
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// write "t"
err = en.Append(0xa1, 0x74)
if err != nil {
return
}
err = en.WriteArrayHeader(uint32(len(z.Transport)))
if err != nil {
err = msgp.WrapError(err, "Transport")
return
}
for za0001 := range z.Transport {
err = en.WriteString(string(z.Transport[za0001]))
if err != nil {
err = msgp.WrapError(err, "Transport", za0001)
return
}
}
}
// write "flg"
err = en.Append(0xa3, 0x66, 0x6c, 0x67)
if err != nil {
return
}
err = en.WriteByte((CredentialFlags).MsgpByte(z.Flags))
if err != nil {
err = msgp.WrapError(err, "Flags")
return
}
// write "a"
err = en.Append(0xa1, 0x61)
if err != nil {
return
}
err = z.Authenticator.EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
// write "att"
err = en.Append(0xa3, 0x61, 0x74, 0x74)
if err != nil {
return
}
err = z.Attestation.EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *Credential) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(8)
var zb0001Mask uint8 /* 8 bits */
_ = zb0001Mask
if z.AttestationType == "" {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AttestationFormat == "" {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Transport == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
// string "id"
o = append(o, 0xa2, 0x69, 0x64)
o = msgp.AppendBytes(o, z.ID)
// string "pk"
o = append(o, 0xa2, 0x70, 0x6b)
o = msgp.AppendBytes(o, z.PublicKey)
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "atttype"
o = append(o, 0xa7, 0x61, 0x74, 0x74, 0x74, 0x79, 0x70, 0x65)
o = msgp.AppendString(o, z.AttestationType)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "attfmt"
o = append(o, 0xa6, 0x61, 0x74, 0x74, 0x66, 0x6d, 0x74)
o = msgp.AppendString(o, z.AttestationFormat)
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// string "t"
o = append(o, 0xa1, 0x74)
o = msgp.AppendArrayHeader(o, uint32(len(z.Transport)))
for za0001 := range z.Transport {
o = msgp.AppendString(o, string(z.Transport[za0001]))
}
}
// string "flg"
o = append(o, 0xa3, 0x66, 0x6c, 0x67)
o = msgp.AppendByte(o, (CredentialFlags).MsgpByte(z.Flags))
// string "a"
o = append(o, 0xa1, 0x61)
o, err = z.Authenticator.MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
// string "att"
o = append(o, 0xa3, 0x61, 0x74, 0x74)
o, err = z.Attestation.MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *Credential) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 3 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "id":
z.ID, bts, err = msgp.ReadBytesBytes(bts, z.ID)
if err != nil {
err = msgp.WrapError(err, "ID")
return
}
case "pk":
z.PublicKey, bts, err = msgp.ReadBytesBytes(bts, z.PublicKey)
if err != nil {
err = msgp.WrapError(err, "PublicKey")
return
}
case "atttype":
z.AttestationType, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "AttestationType")
return
}
zb0001Mask |= 0x1
case "attfmt":
z.AttestationFormat, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "AttestationFormat")
return
}
zb0001Mask |= 0x2
case "t":
var zb0002 uint32
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Transport")
return
}
if cap(z.Transport) >= int(zb0002) {
z.Transport = (z.Transport)[:zb0002]
} else {
z.Transport = make([]protocol.AuthenticatorTransport, zb0002)
}
for za0001 := range z.Transport {
{
var zb0003 string
zb0003, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Transport", za0001)
return
}
z.Transport[za0001] = protocol.AuthenticatorTransport(zb0003)
}
}
zb0001Mask |= 0x4
case "flg":
{
var zb0004 byte
zb0004, bts, err = msgp.ReadByteBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Flags")
return
}
z.Flags = CredentialFlagsFromMsgpByte(zb0004)
}
case "a":
bts, err = z.Authenticator.UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
case "att":
bts, err = z.Attestation.UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7 {
if (zb0001Mask & 0x1) == 0 {
z.AttestationType = ""
}
if (zb0001Mask & 0x2) == 0 {
z.AttestationFormat = ""
}
if (zb0001Mask & 0x4) == 0 {
z.Transport = nil
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *Credential) Msgsize() (s int) {
s = 1 + 3 + msgp.BytesPrefixSize + len(z.ID) + 3 + msgp.BytesPrefixSize + len(z.PublicKey) + 8 + msgp.StringPrefixSize + len(z.AttestationType) + 7 + msgp.StringPrefixSize + len(z.AttestationFormat) + 2 + msgp.ArrayHeaderSize
for za0001 := range z.Transport {
s += msgp.StringPrefixSize + len(string(z.Transport[za0001]))
}
s += 4 + msgp.ByteSize + 2 + z.Authenticator.Msgsize() + 4 + z.Attestation.Msgsize()
return
}
// DecodeMsg implements msgp.Decodable
func (z *CredentialAttestation) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "cdj":
z.ClientDataJSON, err = dc.ReadBytes(z.ClientDataJSON)
if err != nil {
err = msgp.WrapError(err, "ClientDataJSON")
return
}
zb0001Mask |= 0x1
case "cdh":
z.ClientDataHash, err = dc.ReadBytes(z.ClientDataHash)
if err != nil {
err = msgp.WrapError(err, "ClientDataHash")
return
}
zb0001Mask |= 0x2
case "data":
z.AuthenticatorData, err = dc.ReadBytes(z.AuthenticatorData)
if err != nil {
err = msgp.WrapError(err, "AuthenticatorData")
return
}
zb0001Mask |= 0x4
case "alg":
z.PublicKeyAlgorithm, err = dc.ReadInt64()
if err != nil {
err = msgp.WrapError(err, "PublicKeyAlgorithm")
return
}
zb0001Mask |= 0x8
case "obj":
z.Object, err = dc.ReadBytes(z.Object)
if err != nil {
err = msgp.WrapError(err, "Object")
return
}
zb0001Mask |= 0x10
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x1f {
if (zb0001Mask & 0x1) == 0 {
z.ClientDataJSON = nil
}
if (zb0001Mask & 0x2) == 0 {
z.ClientDataHash = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AuthenticatorData = nil
}
if (zb0001Mask & 0x8) == 0 {
z.PublicKeyAlgorithm = 0
}
if (zb0001Mask & 0x10) == 0 {
z.Object = nil
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *CredentialAttestation) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(5)
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
if z.ClientDataJSON == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.ClientDataHash == nil {
zb0001Len--
zb0001Mask |= 0x2
}
if z.AuthenticatorData == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.PublicKeyAlgorithm == 0 {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Object == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// write "cdj"
err = en.Append(0xa3, 0x63, 0x64, 0x6a)
if err != nil {
return
}
err = en.WriteBytes(z.ClientDataJSON)
if err != nil {
err = msgp.WrapError(err, "ClientDataJSON")
return
}
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "cdh"
err = en.Append(0xa3, 0x63, 0x64, 0x68)
if err != nil {
return
}
err = en.WriteBytes(z.ClientDataHash)
if err != nil {
err = msgp.WrapError(err, "ClientDataHash")
return
}
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "data"
err = en.Append(0xa4, 0x64, 0x61, 0x74, 0x61)
if err != nil {
return
}
err = en.WriteBytes(z.AuthenticatorData)
if err != nil {
err = msgp.WrapError(err, "AuthenticatorData")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "alg"
err = en.Append(0xa3, 0x61, 0x6c, 0x67)
if err != nil {
return
}
err = en.WriteInt64(z.PublicKeyAlgorithm)
if err != nil {
err = msgp.WrapError(err, "PublicKeyAlgorithm")
return
}
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// write "obj"
err = en.Append(0xa3, 0x6f, 0x62, 0x6a)
if err != nil {
return
}
err = en.WriteBytes(z.Object)
if err != nil {
err = msgp.WrapError(err, "Object")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *CredentialAttestation) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(5)
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
if z.ClientDataJSON == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.ClientDataHash == nil {
zb0001Len--
zb0001Mask |= 0x2
}
if z.AuthenticatorData == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.PublicKeyAlgorithm == 0 {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Object == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// string "cdj"
o = append(o, 0xa3, 0x63, 0x64, 0x6a)
o = msgp.AppendBytes(o, z.ClientDataJSON)
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "cdh"
o = append(o, 0xa3, 0x63, 0x64, 0x68)
o = msgp.AppendBytes(o, z.ClientDataHash)
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "data"
o = append(o, 0xa4, 0x64, 0x61, 0x74, 0x61)
o = msgp.AppendBytes(o, z.AuthenticatorData)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "alg"
o = append(o, 0xa3, 0x61, 0x6c, 0x67)
o = msgp.AppendInt64(o, z.PublicKeyAlgorithm)
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// string "obj"
o = append(o, 0xa3, 0x6f, 0x62, 0x6a)
o = msgp.AppendBytes(o, z.Object)
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *CredentialAttestation) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "cdj":
z.ClientDataJSON, bts, err = msgp.ReadBytesBytes(bts, z.ClientDataJSON)
if err != nil {
err = msgp.WrapError(err, "ClientDataJSON")
return
}
zb0001Mask |= 0x1
case "cdh":
z.ClientDataHash, bts, err = msgp.ReadBytesBytes(bts, z.ClientDataHash)
if err != nil {
err = msgp.WrapError(err, "ClientDataHash")
return
}
zb0001Mask |= 0x2
case "data":
z.AuthenticatorData, bts, err = msgp.ReadBytesBytes(bts, z.AuthenticatorData)
if err != nil {
err = msgp.WrapError(err, "AuthenticatorData")
return
}
zb0001Mask |= 0x4
case "alg":
z.PublicKeyAlgorithm, bts, err = msgp.ReadInt64Bytes(bts)
if err != nil {
err = msgp.WrapError(err, "PublicKeyAlgorithm")
return
}
zb0001Mask |= 0x8
case "obj":
z.Object, bts, err = msgp.ReadBytesBytes(bts, z.Object)
if err != nil {
err = msgp.WrapError(err, "Object")
return
}
zb0001Mask |= 0x10
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x1f {
if (zb0001Mask & 0x1) == 0 {
z.ClientDataJSON = nil
}
if (zb0001Mask & 0x2) == 0 {
z.ClientDataHash = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AuthenticatorData = nil
}
if (zb0001Mask & 0x8) == 0 {
z.PublicKeyAlgorithm = 0
}
if (zb0001Mask & 0x10) == 0 {
z.Object = nil
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *CredentialAttestation) Msgsize() (s int) {
s = 1 + 4 + msgp.BytesPrefixSize + len(z.ClientDataJSON) + 4 + msgp.BytesPrefixSize + len(z.ClientDataHash) + 5 + msgp.BytesPrefixSize + len(z.AuthenticatorData) + 4 + msgp.Int64Size + 4 + msgp.BytesPrefixSize + len(z.Object)
return
}
// DecodeMsg implements msgp.Decodable
func (z *CredentialFlags) DecodeMsg(dc *msgp.Reader) (err error) {
{
var zb0001 byte
zb0001, err = dc.ReadByte()
if err != nil {
err = msgp.WrapError(err)
return
}
(*z) = CredentialFlagsFromMsgpByte(zb0001)
}
return
}
// EncodeMsg implements msgp.Encodable
func (z CredentialFlags) EncodeMsg(en *msgp.Writer) (err error) {
err = en.WriteByte((CredentialFlags).MsgpByte(z))
if err != nil {
err = msgp.WrapError(err)
return
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z CredentialFlags) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
o = msgp.AppendByte(o, (CredentialFlags).MsgpByte(z))
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *CredentialFlags) UnmarshalMsg(bts []byte) (o []byte, err error) {
{
var zb0001 byte
zb0001, bts, err = msgp.ReadByteBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
(*z) = CredentialFlagsFromMsgpByte(zb0001)
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z CredentialFlags) Msgsize() (s int) {
s = msgp.ByteSize
return
}
// DecodeMsg implements msgp.Decodable
func (z *Credentials) DecodeMsg(dc *msgp.Reader) (err error) {
var zb0002 uint32
zb0002, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
if cap((*z)) >= int(zb0002) {
(*z) = (*z)[:zb0002]
} else {
(*z) = make(Credentials, zb0002)
}
for zb0001 := range *z {
err = (*z)[zb0001].DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, zb0001)
return
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z Credentials) EncodeMsg(en *msgp.Writer) (err error) {
err = en.WriteArrayHeader(uint32(len(z)))
if err != nil {
err = msgp.WrapError(err)
return
}
for zb0003 := range z {
err = z[zb0003].EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, zb0003)
return
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z Credentials) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
o = msgp.AppendArrayHeader(o, uint32(len(z)))
for zb0003 := range z {
o, err = z[zb0003].MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, zb0003)
return
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *Credentials) UnmarshalMsg(bts []byte) (o []byte, err error) {
var zb0002 uint32
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
if cap((*z)) >= int(zb0002) {
(*z) = (*z)[:zb0002]
} else {
(*z) = make(Credentials, zb0002)
}
for zb0001 := range *z {
bts, err = (*z)[zb0001].UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, zb0001)
return
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z Credentials) Msgsize() (s int) {
s = msgp.ArrayHeaderSize
for zb0003 := range z {
s += z[zb0003].Msgsize()
}
return
}
+349
View File
@@ -0,0 +1,349 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"bytes"
"testing"
"github.com/tinylib/msgp/msgp"
)
func TestMarshalUnmarshalCredential(t *testing.T) {
v := Credential{}
bts, err := v.MarshalMsg(nil)
if err != nil {
t.Fatal(err)
}
left, err := v.UnmarshalMsg(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
}
left, err = msgp.Skip(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
}
}
func BenchmarkMarshalMsgCredential(b *testing.B) {
v := Credential{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.MarshalMsg(nil)
}
}
func BenchmarkAppendMsgCredential(b *testing.B) {
v := Credential{}
bts := make([]byte, 0, v.Msgsize())
bts, _ = v.MarshalMsg(bts[0:0])
b.SetBytes(int64(len(bts)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
bts, _ = v.MarshalMsg(bts[0:0])
}
}
func BenchmarkUnmarshalCredential(b *testing.B) {
v := Credential{}
bts, _ := v.MarshalMsg(nil)
b.ReportAllocs()
b.SetBytes(int64(len(bts)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := v.UnmarshalMsg(bts)
if err != nil {
b.Fatal(err)
}
}
}
func TestEncodeDecodeCredential(t *testing.T) {
v := Credential{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
m := v.Msgsize()
if buf.Len() > m {
t.Log("WARNING: TestEncodeDecodeCredential Msgsize() is inaccurate")
}
vn := Credential{}
err := msgp.Decode(&buf, &vn)
if err != nil {
t.Error(err)
}
buf.Reset()
msgp.Encode(&buf, &v)
err = msgp.NewReader(&buf).Skip()
if err != nil {
t.Error(err)
}
}
func BenchmarkEncodeCredential(b *testing.B) {
v := Credential{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
en := msgp.NewWriter(msgp.Nowhere)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.EncodeMsg(en)
}
en.Flush()
}
func BenchmarkDecodeCredential(b *testing.B) {
v := Credential{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
rd := msgp.NewEndlessReader(buf.Bytes(), b)
dc := msgp.NewReader(rd)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := v.DecodeMsg(dc)
if err != nil {
b.Fatal(err)
}
}
}
func TestMarshalUnmarshalCredentialAttestation(t *testing.T) {
v := CredentialAttestation{}
bts, err := v.MarshalMsg(nil)
if err != nil {
t.Fatal(err)
}
left, err := v.UnmarshalMsg(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
}
left, err = msgp.Skip(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
}
}
func BenchmarkMarshalMsgCredentialAttestation(b *testing.B) {
v := CredentialAttestation{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.MarshalMsg(nil)
}
}
func BenchmarkAppendMsgCredentialAttestation(b *testing.B) {
v := CredentialAttestation{}
bts := make([]byte, 0, v.Msgsize())
bts, _ = v.MarshalMsg(bts[0:0])
b.SetBytes(int64(len(bts)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
bts, _ = v.MarshalMsg(bts[0:0])
}
}
func BenchmarkUnmarshalCredentialAttestation(b *testing.B) {
v := CredentialAttestation{}
bts, _ := v.MarshalMsg(nil)
b.ReportAllocs()
b.SetBytes(int64(len(bts)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := v.UnmarshalMsg(bts)
if err != nil {
b.Fatal(err)
}
}
}
func TestEncodeDecodeCredentialAttestation(t *testing.T) {
v := CredentialAttestation{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
m := v.Msgsize()
if buf.Len() > m {
t.Log("WARNING: TestEncodeDecodeCredentialAttestation Msgsize() is inaccurate")
}
vn := CredentialAttestation{}
err := msgp.Decode(&buf, &vn)
if err != nil {
t.Error(err)
}
buf.Reset()
msgp.Encode(&buf, &v)
err = msgp.NewReader(&buf).Skip()
if err != nil {
t.Error(err)
}
}
func BenchmarkEncodeCredentialAttestation(b *testing.B) {
v := CredentialAttestation{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
en := msgp.NewWriter(msgp.Nowhere)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.EncodeMsg(en)
}
en.Flush()
}
func BenchmarkDecodeCredentialAttestation(b *testing.B) {
v := CredentialAttestation{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
rd := msgp.NewEndlessReader(buf.Bytes(), b)
dc := msgp.NewReader(rd)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := v.DecodeMsg(dc)
if err != nil {
b.Fatal(err)
}
}
}
func TestMarshalUnmarshalCredentials(t *testing.T) {
v := Credentials{}
bts, err := v.MarshalMsg(nil)
if err != nil {
t.Fatal(err)
}
left, err := v.UnmarshalMsg(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
}
left, err = msgp.Skip(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
}
}
func BenchmarkMarshalMsgCredentials(b *testing.B) {
v := Credentials{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.MarshalMsg(nil)
}
}
func BenchmarkAppendMsgCredentials(b *testing.B) {
v := Credentials{}
bts := make([]byte, 0, v.Msgsize())
bts, _ = v.MarshalMsg(bts[0:0])
b.SetBytes(int64(len(bts)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
bts, _ = v.MarshalMsg(bts[0:0])
}
}
func BenchmarkUnmarshalCredentials(b *testing.B) {
v := Credentials{}
bts, _ := v.MarshalMsg(nil)
b.ReportAllocs()
b.SetBytes(int64(len(bts)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := v.UnmarshalMsg(bts)
if err != nil {
b.Fatal(err)
}
}
}
func TestEncodeDecodeCredentials(t *testing.T) {
v := Credentials{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
m := v.Msgsize()
if buf.Len() > m {
t.Log("WARNING: TestEncodeDecodeCredentials Msgsize() is inaccurate")
}
vn := Credentials{}
err := msgp.Decode(&buf, &vn)
if err != nil {
t.Error(err)
}
buf.Reset()
msgp.Encode(&buf, &v)
err = msgp.NewReader(&buf).Skip()
if err != nil {
t.Error(err)
}
}
func BenchmarkEncodeCredentials(b *testing.B) {
v := Credentials{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
en := msgp.NewWriter(msgp.Nowhere)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.EncodeMsg(en)
}
en.Flush()
}
func BenchmarkDecodeCredentials(b *testing.B) {
v := Credentials{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
rd := msgp.NewEndlessReader(buf.Bytes(), b)
dc := msgp.NewReader(rd)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := v.DecodeMsg(dc)
if err != nil {
b.Fatal(err)
}
}
}
+1157
View File
@@ -0,0 +1,1157 @@
package webauthn
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinylib/msgp/msgp"
"go.uber.org/mock/gomock"
"github.com/go-webauthn/webauthn/metadata"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/testing/mocks"
)
func TestNewCredentialFlags(t *testing.T) {
testCases := []struct {
name string
flags protocol.AuthenticatorFlags
expectedUserPresent bool
expectedUserVerified bool
expectedBackupEligible bool
expectedBackupState bool
}{
{
name: "ShouldHandleNoFlags",
flags: 0,
expectedUserPresent: false,
expectedUserVerified: false,
expectedBackupEligible: false,
expectedBackupState: false,
},
{
name: "ShouldHandleAllFlags",
flags: protocol.FlagUserPresent | protocol.FlagUserVerified | protocol.FlagBackupEligible | protocol.FlagBackupState,
expectedUserPresent: true,
expectedUserVerified: true,
expectedBackupEligible: true,
expectedBackupState: true,
},
{
name: "ShouldHandleUserPresentOnly",
flags: protocol.FlagUserPresent,
expectedUserPresent: true,
expectedUserVerified: false,
expectedBackupEligible: false,
expectedBackupState: false,
},
{
name: "ShouldHandleBackupFlags",
flags: protocol.FlagBackupEligible | protocol.FlagBackupState,
expectedUserPresent: false,
expectedUserVerified: false,
expectedBackupEligible: true,
expectedBackupState: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := NewCredentialFlags(tc.flags)
assert.Equal(t, tc.expectedUserPresent, result.UserPresent)
assert.Equal(t, tc.expectedUserVerified, result.UserVerified)
assert.Equal(t, tc.expectedBackupEligible, result.BackupEligible)
assert.Equal(t, tc.expectedBackupState, result.BackupState)
assert.Equal(t, tc.flags, result.ProtocolValue())
})
}
}
func TestCredential_Verify(t *testing.T) {
assert.EqualError(t, (&Credential{}).Verify(nil), "error verifying credential: the metadata provider must be provided but it's nil")
testCases := []struct {
name string
credential func(t *testing.T) Credential
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
err string
}{
{
name: "ShouldFailParseError",
credential: func(t *testing.T) Credential {
t.Helper()
return Credential{
Attestation: CredentialAttestation{
ClientDataJSON: []byte(`{}`),
Object: []byte("not-valid-cbor"),
},
}
},
err: "error verifying credential: error parsing attestation: Error parsing the authenticator response",
},
{
name: "ShouldVerifyNoneFormatWithClientDataHash",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromNoneAttestation(t)
},
},
{
name: "ShouldVerifyNoneFormatWithEmptyClientDataHash",
credential: func(t *testing.T) Credential {
t.Helper()
credential := testCredentialFromNoneAttestation(t)
credential.Attestation.ClientDataHash = nil
return credential
},
},
{
name: "ShouldVerifyNoneFormatWithTransports",
credential: func(t *testing.T) Credential {
t.Helper()
credential := testCredentialFromNoneAttestation(t)
credential.Transport = []protocol.AuthenticatorTransport{protocol.USB, protocol.NFC}
return credential
},
},
{
name: "ShouldVerifyPackedFormatWithMetadataValidation",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{
MetadataStatement: metadata.Statement{
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
},
}, nil)
provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(false)
provider.EXPECT().GetValidateStatus(gomock.Any()).Return(false)
provider.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(false)
},
},
{
name: "ShouldFailPackedFormatMetadataGetEntryError",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("entry lookup failed"))
},
err: "error verifying credential: error verifying attestation: Failed to validate authenticator metadata for Authenticator Attestation GUID '2369d4d0-13ce-48cb-9f26-f7ed8c9a6068'. Error occurred retrieving the metadata entry: entry lookup failed",
},
{
name: "ShouldFailPackedFormatMetadataValidateStatus",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{
MetadataStatement: metadata.Statement{
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
},
}, nil)
provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(false)
provider.EXPECT().GetValidateStatus(gomock.Any()).Return(true)
provider.EXPECT().ValidateStatusReports(gomock.Any(), gomock.Any()).Return(fmt.Errorf("status report invalid"))
},
err: "error verifying credential: error verifying attestation: Failed to validate authenticator metadata for Authenticator Attestation GUID '2369d4d0-13ce-48cb-9f26-f7ed8c9a6068'. Error occurred validating the authenticator status: status report invalid",
},
{
name: "ShouldVerifyPackedFormatMetadataEntryNilNoValidation",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
provider.EXPECT().GetValidateEntry(gomock.Any()).Return(false)
},
},
{
name: "ShouldFailPackedFormatMetadataEntryNilValidateEntryRequired",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
provider.EXPECT().GetValidateEntry(gomock.Any()).Return(true)
},
err: "error verifying credential: error verifying attestation: Failed to validate authenticator metadata for Authenticator Attestation GUID '2369d4d0-13ce-48cb-9f26-f7ed8c9a6068'. The authenticator has no registered metadata.",
},
{
name: "ShouldVerifyPackedFormatWithAttestationTypeValidation",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{
MetadataStatement: metadata.Statement{
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
},
}, nil)
provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(true)
provider.EXPECT().GetValidateStatus(gomock.Any()).Return(false)
provider.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(false)
},
},
{
name: "ShouldFailPackedFormatAttestationTypeMismatch",
credential: func(t *testing.T) Credential {
t.Helper()
return testCredentialFromPackedAttestation(t)
},
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{
MetadataStatement: metadata.Statement{
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicSurrogate},
},
}, nil)
provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(true)
},
err: "error verifying credential: error verifying attestation: Failed to validate authenticator metadata for Authenticator Attestation GUID '2369d4d0-13ce-48cb-9f26-f7ed8c9a6068'. The attestation type 'basic_full' is not known to be used by this authenticator.",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
if tc.setup != nil {
tc.setup(t, provider)
}
credential := tc.credential(t)
err := credential.Verify(provider)
if tc.err == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.err)
}
})
}
}
func TestCredential_Verify_RestoresAttestationType(t *testing.T) {
credential := testCredentialFromNoneAttestation(t)
credential.AttestationType = ""
credential.AttestationFormat = "none"
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
require.NoError(t, credential.Verify(provider))
assert.Equal(t, "none", credential.AttestationType)
assert.Equal(t, "none", credential.AttestationFormat)
}
func TestCredential_Verify_LeavesExistingAttestationTypeAlone(t *testing.T) {
// The self-heal must not overwrite a caller-supplied AttestationType. If the field is already populated,
// Verify leaves it alone.
credential := testCredentialFromNoneAttestation(t)
credential.AttestationType = "caller-set-value"
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
require.NoError(t, credential.Verify(provider))
assert.Equal(t, "caller-set-value", credential.AttestationType)
}
func TestCredential_Verify_RejectsTamperedPublicKey(t *testing.T) {
t.Run("MismatchReturnsError", func(t *testing.T) {
credential := testCredentialFromNoneAttestation(t)
tampered := make([]byte, len(credential.PublicKey))
copy(tampered, credential.PublicKey)
tampered[0] ^= 0xFF
credential.PublicKey = tampered
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
err := credential.Verify(provider)
assert.EqualError(t, err, "error verifying credential: stored public key does not match the credential public key embedded in the attestation object")
})
t.Run("EmptyPublicKeyReturnsError", func(t *testing.T) {
credential := testCredentialFromNoneAttestation(t)
credential.PublicKey = nil
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
err := credential.Verify(provider)
assert.EqualError(t, err, "error verifying credential: stored public key does not match the credential public key embedded in the attestation object")
})
}
// testCredentialFromNoneAttestation constructs a Credential with valid "none" format attestation data for testing.
func testCredentialFromNoneAttestation(t *testing.T) Credential {
t.Helper()
attObject, err := base64.RawURLEncoding.DecodeString("o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw")
require.NoError(t, err)
clientDataJSON, err := base64.RawURLEncoding.DecodeString("eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ")
require.NoError(t, err)
clientDataHash := sha256.Sum256(clientDataJSON)
parsed := (&protocol.AuthenticatorAttestationResponse{
AuthenticatorResponse: protocol.AuthenticatorResponse{ClientDataJSON: clientDataJSON},
AttestationObject: attObject,
})
parsedResponse, err := parsed.Parse()
require.NoError(t, err)
return Credential{
ID: []byte("credential-id"),
PublicKey: parsedResponse.AttestationObject.AuthData.AttData.CredentialPublicKey,
AttestationType: "none",
Attestation: CredentialAttestation{
ClientDataJSON: clientDataJSON,
ClientDataHash: clientDataHash[:],
Object: attObject,
},
}
}
func TestNewCredential(t *testing.T) {
testCases := []struct {
name string
clientDataHash []byte
parsed *protocol.ParsedCredentialCreationData
expected *Credential
}{
{
name: "ShouldCreateCredential",
clientDataHash: []byte("client-data-hash"),
parsed: &protocol.ParsedCredentialCreationData{
ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{
AuthenticatorAttachment: protocol.Platform,
},
Response: protocol.ParsedAttestationResponse{
AttestationObject: protocol.AttestationObject{
Format: "packed",
Type: string(metadata.BasicFull),
AuthData: protocol.AuthenticatorData{
Counter: 1,
Flags: protocol.FlagUserPresent | protocol.FlagUserVerified,
AttData: protocol.AttestedCredentialData{
AAGUID: []byte("aaguid-value-here"),
CredentialID: []byte("credential-id"),
CredentialPublicKey: []byte("public-key"),
},
},
},
Transports: []protocol.AuthenticatorTransport{protocol.USB},
},
Raw: protocol.CredentialCreationResponse{
AttestationResponse: protocol.AuthenticatorAttestationResponse{
AuthenticatorResponse: protocol.AuthenticatorResponse{
ClientDataJSON: []byte("client-data-json"),
},
AuthenticatorData: []byte("auth-data"),
PublicKeyAlgorithm: -7,
AttestationObject: []byte("attestation-object"),
},
},
},
expected: &Credential{
ID: []byte("credential-id"),
PublicKey: []byte("public-key"),
AttestationType: string(metadata.BasicFull),
AttestationFormat: "packed",
Transport: []protocol.AuthenticatorTransport{protocol.USB},
Flags: NewCredentialFlags(protocol.FlagUserPresent | protocol.FlagUserVerified),
Authenticator: Authenticator{
AAGUID: []byte("aaguid-value-here"),
SignCount: 1,
Attachment: protocol.Platform,
},
Attestation: CredentialAttestation{
ClientDataJSON: []byte("client-data-json"),
ClientDataHash: []byte("client-data-hash"),
AuthenticatorData: []byte("auth-data"),
PublicKeyAlgorithm: -7,
Object: []byte("attestation-object"),
},
},
},
{
name: "ShouldCreateCredentialWithNilHash",
clientDataHash: nil,
parsed: &protocol.ParsedCredentialCreationData{
Response: protocol.ParsedAttestationResponse{
AttestationObject: protocol.AttestationObject{
Format: "none",
Type: string(metadata.None),
AuthData: protocol.AuthenticatorData{
AttData: protocol.AttestedCredentialData{
CredentialID: []byte("cred-2"),
CredentialPublicKey: []byte("pub-2"),
},
},
},
},
Raw: protocol.CredentialCreationResponse{},
},
expected: &Credential{
ID: []byte("cred-2"),
PublicKey: []byte("pub-2"),
AttestationType: string(metadata.None),
AttestationFormat: "none",
Flags: CredentialFlags{},
Authenticator: Authenticator{},
Attestation: CredentialAttestation{},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual, err := NewCredential(tc.clientDataHash, tc.parsed)
require.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}
}
func TestCredential_SignalUnknownCredential(t *testing.T) {
testCases := []struct {
name string
rpid string
have *Credential
expected *protocol.SignalUnknownCredential
expectedJSON string
}{
{
"ShouldHandleStandard",
"example.com",
&Credential{
ID: []byte("1234"),
},
&protocol.SignalUnknownCredential{
CredentialID: protocol.URLEncodedBase64("1234"),
RPID: "example.com",
},
`{"credentialId":"MTIzNA","rpId":"example.com"}`,
},
{
"ShouldHandleNoID",
"example.com",
&Credential{},
&protocol.SignalUnknownCredential{
RPID: "example.com",
},
`{"credentialId":null,"rpId":"example.com"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := tc.have.SignalUnknownCredential(tc.rpid)
assert.Equal(t, tc.expected, actual)
data, err := json.Marshal(actual)
require.NoError(t, err)
assert.Equal(t, tc.expectedJSON, string(data))
})
}
}
func TestCredentials_CredentialDescriptors(t *testing.T) {
testCases := []struct {
name string
have Credentials
expected []protocol.CredentialDescriptor
expectedJSON string
}{
{
"ShouldHandleStandard",
Credentials{
Credential{
ID: []byte("1234"),
},
},
[]protocol.CredentialDescriptor{
{
Type: protocol.PublicKeyCredentialType,
CredentialID: protocol.URLEncodedBase64("1234"),
},
},
`[{"type":"public-key","id":"MTIzNA"}]`,
},
{
"ShouldHandleEmpty",
Credentials{},
[]protocol.CredentialDescriptor{},
`[]`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := tc.have.CredentialDescriptors()
assert.Equal(t, tc.expected, actual)
data, err := json.Marshal(actual)
require.NoError(t, err)
assert.Equal(t, tc.expectedJSON, string(data))
})
}
}
func TestCredential_UnmarshalJSON(t *testing.T) {
testCases := []struct {
name string
input string
attestationType string
attestationFormat string
}{
{
name: "ShouldMigrateLegacyRecordWithPackedFormat",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"packed"}`,
attestationType: "",
attestationFormat: "packed",
},
{
name: "ShouldMigrateLegacyRecordWithNoneFormat",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"none"}`,
attestationType: "",
attestationFormat: "none",
},
{
name: "ShouldMigrateLegacyRecordWithFIDOU2FFormat",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"fido-u2f"}`,
attestationType: "",
attestationFormat: "fido-u2f",
},
{
name: "ShouldMigrateLegacyRecordWithAndroidKeyFormat",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"android-key"}`,
attestationType: "",
attestationFormat: "android-key",
},
{
name: "ShouldPreserveNewRecordWithBothFields",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"basic_full","attestationFormat":"packed"}`,
attestationType: "basic_full",
attestationFormat: "packed",
},
{
name: "ShouldPreserveNewRecordWithSurrogate",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"basic_surrogate","attestationFormat":"packed"}`,
attestationType: "basic_surrogate",
attestationFormat: "packed",
},
{
name: "ShouldPreserveTypeValueThatIsNotAFormat",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"basic_full"}`,
attestationType: "basic_full",
attestationFormat: "",
},
{
name: "ShouldHandleEmptyBothFields",
input: `{"id":"MTIz","publicKey":"YWJj"}`,
attestationType: "",
attestationFormat: "",
},
{
name: "ShouldHandleUnknownTypeString",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"something-unrecognised"}`,
attestationType: "something-unrecognised",
attestationFormat: "",
},
{
name: "ShouldNotMigrateWhenFormatAlreadyPresent",
input: `{"id":"MTIz","publicKey":"YWJj","attestationType":"packed","attestationFormat":"tpm"}`,
attestationType: "packed",
attestationFormat: "tpm",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var c Credential
require.NoError(t, json.Unmarshal([]byte(tc.input), &c))
assert.Equal(t, tc.attestationType, c.AttestationType)
assert.Equal(t, tc.attestationFormat, c.AttestationFormat)
})
}
t.Run("ShouldRejectMalformedJSON", func(t *testing.T) {
var c Credential
assert.Error(t, json.Unmarshal([]byte(`{not-json`), &c))
})
}
func TestCredential_RoundTripJSON(t *testing.T) {
// Marshal -> Unmarshal produces an equivalent record (no lossy migration applied to a record already carrying
// both fields).
original := Credential{
ID: []byte("cred-id"),
AttestationType: "basic_surrogate",
AttestationFormat: "packed",
}
data, err := json.Marshal(original)
require.NoError(t, err)
var round Credential
require.NoError(t, json.Unmarshal(data, &round))
assert.Equal(t, original.AttestationType, round.AttestationType)
assert.Equal(t, original.AttestationFormat, round.AttestationFormat)
}
func TestCredential_Descriptor_PopulatesBothFields(t *testing.T) {
// Descriptor() mirrors Credential's attestation type / format split into the descriptor. The format field is
// what GetAppID and the option helpers key on (against the "fido-u2f" format string); the type field carries
// the real attestation type for completeness.
c := Credential{
ID: []byte("cred-id"),
AttestationType: "basic_full",
AttestationFormat: "fido-u2f",
}
descriptor := c.Descriptor()
assert.Equal(t, protocol.PublicKeyCredentialType, descriptor.Type)
assert.Equal(t, "basic_full", descriptor.AttestationType)
assert.Equal(t, "fido-u2f", descriptor.AttestationFormat)
}
// testCredentialFromPackedAttestation constructs a Credential with valid "packed" format attestation data for testing.
func testCredentialFromPackedAttestation(t *testing.T) Credential {
t.Helper()
response := `{
"id":"owBY6F5857tda9Pg5iFNCg6ksHpGOYhrNqIn46pkvhEMKIgNGcKS-vDGAUEroq0-VHnl1LhzQkPRQmYBTHjGcpLKZKSLa2m2ANI-91HjXzoJd_zFOiEnu7CDwQTff9KZ6uPlx7kUK-JJOHar-IyRKcNhc_kOJ2ezglmj1JYuIJLoDEyXlKkkviFdwk1vbWLnO3p_oWROUeIgH_S4CLVLPIJXkPe0YvMgp3ESs9CsrN6kvMTysVRIt_h5KUqpZo0TKCL96zwFk1X_2PwCLKWmOxVL35lJfUKOHG9rc3bmKlqZR6aOgZjerY6BpU8BTJkAqfOvdVlqFeEcywJQgveR7FOvnVtoqzd5oaEwjA",
"rawId":"owBY6F5857tda9Pg5iFNCg6ksHpGOYhrNqIn46pkvhEMKIgNGcKS-vDGAUEroq0-VHnl1LhzQkPRQmYBTHjGcpLKZKSLa2m2ANI-91HjXzoJd_zFOiEnu7CDwQTff9KZ6uPlx7kUK-JJOHar-IyRKcNhc_kOJ2ezglmj1JYuIJLoDEyXlKkkviFdwk1vbWLnO3p_oWROUeIgH_S4CLVLPIJXkPe0YvMgp3ESs9CsrN6kvMTysVRIt_h5KUqpZo0TKCL96zwFk1X_2PwCLKWmOxVL35lJfUKOHG9rc3bmKlqZR6aOgZjerY6BpU8BTJkAqfOvdVlqFeEcywJQgveR7FOvnVtoqzd5oaEwjA",
"response":{
"attestationObject":"o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEgwRgIhAIXRMqmC2_bHTkKUwOvLvmAikuQPCk__9clILwjhOz3VAiEApJXTrN4WMiPwFXqTIh0oI8AZBm3vs-y_UotbQFSnX99jeDVjgVkCqzCCAqcwggJMoAMCAQICFGqj6W3EVhRWQJPun0qqCMyTlnqKMAoGCCqGSM49BAMCMC0xETAPBgNVBAoMCFNvbG9LZXlzMQswCQYDVQQGEwJDSDELMAkGA1UEAwwCRjEwIBcNMjEwNTIzMDA1MjA2WhgPMjA3MTA1MTEwMDUyMDZaMIGDMQswCQYDVQQGEwJVUzERMA8GA1UECgwIU29sb0tleXMxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xPTA7BgNVBAMMNFNvbG8gMiBORkMrVVNCLUMgMjM2OUQ0RDAxM0NFNDhDQjlGMjZGN0VEOEM5QTYwNjggQjIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS6N5V2fT-agh34bRiW--Wl6CQPSsnLqqSEID0t5RRKjjl1NDI__mzuyYuOrWyb5yzGZRHgnHq65cm2ROpxo6AOo4HwMIHtMB0GA1UdDgQWBBQ6CEDC5W8_zAMOhVgV8wHJI8n3bzAfBgNVHSMEGDAWgBRBa7ZL76IZDeRiX_0pBJa5gim0-DAJBgNVHRMEAjAAMAsGA1UdDwQEAwIE8DAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9pLnMycGtpLm5ldC9mMS8wJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL2MuczJwa2kubmV0L3IxLzAhBgsrBgEEAYLlHAEBBAQSBBAjadTQE85Iy58m9-2MmmBoMBMGCysGAQQBguUcAgEBBAQDAgQwMAoGCCqGSM49BAMCA0kAMEYCIQCP82Rolr0U2FvOJq53AZYcA6xfC4-cNDczvf0FtU1SQAIhAIvb21Z3D8RCvwk2-Ryn4wpsGnn2vma6Bw3E1f48hyVwaGF1dGhEYXRhWQFtarm78N-aFvkduzO7sTL6-dF8eCxIJsbscOzuWNl-9SpBAAAAJyNp1NATzkjLnyb37YyaYGgBDKMAWOhefOe7XWvT4OYhTQoOpLB6RjmIazaiJ-OqZL4RDCiIDRnCkvrwxgFBK6KtPlR55dS4c0JD0UJmAUx4xnKSymSki2tptgDSPvdR4186CXf8xTohJ7uwg8EE33_Smerj5ce5FCviSTh2q_iMkSnDYXP5Didns4JZo9SWLiCS6AxMl5SpJL4hXcJNb21i5zt6f6FkTlHiIB_0uAi1SzyCV5D3tGLzIKdxErPQrKzepLzE8rFUSLf4eSlKqWaNEygi_es8BZNV_9j8AiylpjsVS9-ZSX1Cjhxva3N25ipamUemjoGY3q2OgaVPAUyZAKnzr3VZahXhHMsCUIL3kexTr51baKs3eaGhMIykAQEDJyAGIVggjz9UkJ7cKooE3blSuzlqxkdLppMuFl3CIiST8odWS6k",
"clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ1dieENUMEc0TDJ5T1JwQkw2U1dWaWd3ZTJrUUVYQmhvNUw2d0U0Ny1FcyIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG4uZmlyc3R5ZWFyLmlkLmF1IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ"
},
"type":"public-key"
}`
var ccr protocol.CredentialCreationResponse
require.NoError(t, json.Unmarshal([]byte(response), &ccr))
parsed, err := ccr.AttestationResponse.Parse()
require.NoError(t, err)
clientDataHash := sha256.Sum256(ccr.AttestationResponse.ClientDataJSON)
return Credential{
ID: ccr.RawID,
PublicKey: parsed.AttestationObject.AuthData.AttData.CredentialPublicKey,
AttestationType: "packed",
Authenticator: Authenticator{
AAGUID: parsed.AttestationObject.AuthData.AttData.AAGUID,
},
Attestation: CredentialAttestation{
ClientDataJSON: ccr.AttestationResponse.ClientDataJSON,
ClientDataHash: clientDataHash[:],
Object: ccr.AttestationResponse.AttestationObject,
},
}
}
func TestCredential_MsgpRoundTrip(t *testing.T) {
// Exercises the Marshal/Unmarshal and Encode/Decode paths of every top-level field branch on Credential,
// including the Transport slice element, the CredentialFlags shim, and the nested Authenticator /
// CredentialAttestation structs. The generated TestMarshalUnmarshalCredential only covers the zero value.
original := newPopulatedCredential()
t.Run("MarshalUnmarshalPreservesFields", func(t *testing.T) {
data, err := original.MarshalMsg(nil)
require.NoError(t, err)
var decoded Credential
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left, "UnmarshalMsg should consume all bytes")
assert.Equal(t, original, decoded)
// Msgsize returns an upper-bound estimate; marshalled output must fit within it.
assert.LessOrEqual(t, len(data), original.Msgsize())
})
t.Run("EncodeDecodePreservesFields", func(t *testing.T) {
var buf bytes.Buffer
err := msgp.Encode(&buf, &original)
require.NoError(t, err)
var decoded Credential
require.NoError(t, msgp.Decode(&buf, &decoded))
assert.Equal(t, original, decoded)
})
t.Run("UnmarshalSkipsUnknownKeysAlongsideKnown", func(t *testing.T) {
data, err := original.MarshalMsg(nil)
require.NoError(t, err)
size, rest, err := msgp.ReadMapHeaderBytes(data)
require.NoError(t, err)
spliced := msgp.AppendMapHeader(nil, size+1)
spliced = msgp.AppendString(spliced, "xyz")
spliced = msgp.AppendBool(spliced, true)
spliced = append(spliced, rest...)
var decoded Credential
left, err := decoded.UnmarshalMsg(spliced)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, original, decoded)
})
t.Run("UnmarshalSkipsUnknownKeysOnly", func(t *testing.T) {
tiny := []byte{0x81, 0xa3, 'x', 'y', 'z', 0xc3}
var decoded Credential
left, err := decoded.UnmarshalMsg(tiny)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, Credential{}, decoded)
})
}
func TestCredentialAttestation_MsgpRoundTrip(t *testing.T) {
original := CredentialAttestation{
ClientDataJSON: []byte(`{"type":"webauthn.create"}`),
ClientDataHash: bytes.Repeat([]byte{0x01}, 32),
AuthenticatorData: []byte{0xff, 0xee, 0xdd},
PublicKeyAlgorithm: -257,
Object: []byte{0xca, 0xfe, 0xba, 0xbe},
}
data, err := original.MarshalMsg(nil)
require.NoError(t, err)
var decoded CredentialAttestation
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, original, decoded)
assert.LessOrEqual(t, len(data), original.Msgsize())
// EncodeMsg / DecodeMsg via msgp.Writer and msgp.Reader path.
var buf bytes.Buffer
require.NoError(t, msgp.Encode(&buf, &original))
var streamDecoded CredentialAttestation
require.NoError(t, msgp.Decode(&buf, &streamDecoded))
assert.Equal(t, original, streamDecoded)
}
func TestCredentialFlags_MsgpRoundTrip(t *testing.T) {
// The //msgp:shim directive makes CredentialFlags encode as a single msgpack byte via MsgpByte and decode via
// CredentialFlagsFromMsgpByte. Generated code for CredentialFlags has the smallest surface of any msgp method
// pair in this package and was 0%-covered before this test.
testCases := []struct {
name string
flags protocol.AuthenticatorFlags
}{
{"Zero", 0},
{"UserPresent", protocol.FlagUserPresent},
{"UserVerified", protocol.FlagUserVerified},
{"AllKnownFlags", protocol.FlagUserPresent | protocol.FlagUserVerified | protocol.FlagBackupEligible | protocol.FlagBackupState},
{"AllBitsSet", protocol.AuthenticatorFlags(0xFF)},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
original := NewCredentialFlags(tc.flags)
data, err := original.MarshalMsg(nil)
require.NoError(t, err)
// msgpack encodes a byte as either a 1-byte positive fixint (values 0x00-0x7f) or a 2-byte uint 8
// (0xcc + payload) for values ≥ 0x80; either is acceptable.
assert.LessOrEqual(t, len(data), 2, "CredentialFlags should encode in at most 2 msgpack bytes")
assert.GreaterOrEqual(t, len(data), 1)
var decoded CredentialFlags
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, original, decoded)
assert.Equal(t, original.ProtocolValue(), decoded.ProtocolValue())
// Stream path via msgp.Writer / msgp.Reader.
var buf bytes.Buffer
require.NoError(t, msgp.Encode(&buf, original))
var streamDecoded CredentialFlags
require.NoError(t, msgp.Decode(&buf, &streamDecoded))
assert.Equal(t, original, streamDecoded)
})
}
// Truncated-input error path.
var decoded CredentialFlags
_, err := decoded.UnmarshalMsg(nil)
require.Error(t, err)
}
func TestCredentials_MsgpRoundTrip(t *testing.T) {
testCases := []struct {
name string
original Credentials
}{
{"Empty", Credentials{}},
{"Single", Credentials{newPopulatedCredential()}},
{"Multiple", Credentials{newPopulatedCredential(), newPopulatedCredential(), newPopulatedCredential()}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.original.MarshalMsg(nil)
require.NoError(t, err)
var decoded Credentials
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left)
if len(tc.original) == 0 {
assert.Empty(t, decoded)
} else {
assert.Equal(t, tc.original, decoded)
}
assert.LessOrEqual(t, len(data), tc.original.Msgsize())
})
}
}
func newPopulatedCredential() Credential {
return Credential{
ID: []byte{0x01, 0x02, 0x03, 0x04},
PublicKey: []byte{0xa5, 0x01, 0x02, 0x03, 0x26},
AttestationType: "basic_full",
AttestationFormat: "packed",
Transport: []protocol.AuthenticatorTransport{protocol.USB, protocol.NFC, protocol.Hybrid},
Flags: NewCredentialFlags(protocol.FlagUserPresent | protocol.FlagUserVerified | protocol.FlagBackupEligible),
Authenticator: Authenticator{
AAGUID: bytes.Repeat([]byte{0x11}, 16),
SignCount: 42,
CloneWarning: true,
Attachment: protocol.Platform,
},
Attestation: CredentialAttestation{
ClientDataJSON: []byte(`{"type":"webauthn.create","challenge":"abc","origin":"https://example.com"}`),
ClientDataHash: bytes.Repeat([]byte{0xde}, 32),
AuthenticatorData: bytes.Repeat([]byte{0xaa}, 64),
PublicKeyAlgorithm: -7,
Object: bytes.Repeat([]byte{0xbb}, 128),
},
}
}
func TestCredential_MsgpEncodeErrorPaths(t *testing.T) {
v := newPopulatedCredential()
data, err := v.MarshalMsg(nil)
require.NoError(t, err)
exerciseEncodeMsgErrorPaths(t, &v, data)
}
func TestCredentialAttestation_MsgpEncodeErrorPaths(t *testing.T) {
v := CredentialAttestation{
ClientDataJSON: []byte(`{"type":"webauthn.create"}`),
ClientDataHash: bytes.Repeat([]byte{0x01}, 32),
AuthenticatorData: []byte{0xff, 0xee, 0xdd},
PublicKeyAlgorithm: -257,
Object: []byte{0xca, 0xfe, 0xba, 0xbe},
}
data, err := v.MarshalMsg(nil)
require.NoError(t, err)
exerciseEncodeMsgErrorPaths(t, &v, data)
}
func TestCredentialFlags_MsgpEncodeErrorPaths(t *testing.T) {
v := NewCredentialFlags(protocol.FlagUserPresent | protocol.FlagUserVerified | protocol.FlagBackupEligible | protocol.FlagBackupState)
data, err := v.MarshalMsg(nil)
require.NoError(t, err)
exerciseEncodeMsgErrorPaths(t, v, data)
}
func TestCredentials_MsgpEncodeErrorPaths(t *testing.T) {
v := Credentials{newPopulatedCredential(), newPopulatedCredential()}
data, err := v.MarshalMsg(nil)
require.NoError(t, err)
exerciseEncodeMsgErrorPaths(t, v, data)
}
func TestCredential_DecodeMsgInvalidTypes(t *testing.T) {
t.Run("NotAMap", func(t *testing.T) {
var c Credential
_, err := c.UnmarshalMsg(msgpString("not a map"))
require.Error(t, err)
var c2 Credential
require.Error(t, msgp.Decode(bytes.NewReader(msgpString("not a map")), &c2))
})
testCases := []struct {
name string
data []byte
wantSub string
}{
{"IDAsBool", msgpOneFieldMap("id", msgpBool(true)), "ID"},
{"PublicKeyAsInt", msgpOneFieldMap("pk", msgpInt64(42)), "PublicKey"},
{"AttestationTypeAsInt", msgpOneFieldMap("atttype", msgpInt64(42)), "AttestationType"},
{"AttestationFormatAsBool", msgpOneFieldMap("attfmt", msgpBool(true)), "AttestationFormat"},
{"TransportNotArray", msgpOneFieldMap("t", msgpBool(true)), "Transport"},
{"TransportElementNotString", msgpOneFieldMap("t", func() []byte {
b := msgp.AppendArrayHeader(nil, 1)
return append(b, msgpBool(true)...)
}()), "Transport"},
{"FlagsAsString", msgpOneFieldMap("flg", msgpString("x")), "Flags"},
{"AuthenticatorAsBool", msgpOneFieldMap("a", msgpBool(true)), "Authenticator"},
{"AttestationAsBool", msgpOneFieldMap("att", msgpBool(true)), "Attestation"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var c Credential
_, err := c.UnmarshalMsg(tc.data)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantSub)
var c2 Credential
streamErr := msgp.Decode(bytes.NewReader(tc.data), &c2)
require.Error(t, streamErr)
assert.Contains(t, streamErr.Error(), tc.wantSub)
})
}
}
func TestCredentialAttestation_DecodeMsgInvalidTypes(t *testing.T) {
t.Run("NotAMap", func(t *testing.T) {
var c CredentialAttestation
_, err := c.UnmarshalMsg(msgpString("not a map"))
require.Error(t, err)
var c2 CredentialAttestation
require.Error(t, msgp.Decode(bytes.NewReader(msgpString("not a map")), &c2))
})
testCases := []struct {
name string
data []byte
wantSub string
}{
{"ClientDataJSONAsInt", msgpOneFieldMap("cdj", msgpInt64(42)), "ClientDataJSON"},
{"ClientDataHashAsBool", msgpOneFieldMap("cdh", msgpBool(true)), "ClientDataHash"},
{"AuthenticatorDataAsInt", msgpOneFieldMap("data", msgpInt64(42)), "AuthenticatorData"},
{"PublicKeyAlgorithmAsString", msgpOneFieldMap("alg", msgpString("x")), "PublicKeyAlgorithm"},
{"ObjectAsBool", msgpOneFieldMap("obj", msgpBool(true)), "Object"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var c CredentialAttestation
_, err := c.UnmarshalMsg(tc.data)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantSub)
var c2 CredentialAttestation
streamErr := msgp.Decode(bytes.NewReader(tc.data), &c2)
require.Error(t, streamErr)
assert.Contains(t, streamErr.Error(), tc.wantSub)
})
}
}
func TestCredentialFlags_DecodeMsgInvalidTypes(t *testing.T) {
testCases := []struct {
name string
data []byte
}{
{"AsString", msgpString("x")},
{"AsBool", msgpBool(true)},
{"AsNil", msgp.AppendNil(nil)},
{"Truncated", nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var f CredentialFlags
_, err := f.UnmarshalMsg(tc.data)
require.Error(t, err)
if len(tc.data) > 0 {
var f2 CredentialFlags
require.Error(t, msgp.Decode(bytes.NewReader(tc.data), &f2))
}
})
}
}
func TestCredentials_DecodeMsgInvalidTypes(t *testing.T) {
t.Run("NotAnArray", func(t *testing.T) {
var c Credentials
_, err := c.UnmarshalMsg(msgpString("not an array"))
require.Error(t, err)
var c2 Credentials
require.Error(t, msgp.Decode(bytes.NewReader(msgpString("not an array")), &c2))
})
t.Run("ElementNotAMap", func(t *testing.T) {
b := msgp.AppendArrayHeader(nil, 1)
b = append(b, msgpBool(true)...)
var c Credentials
_, err := c.UnmarshalMsg(b)
require.Error(t, err)
var c2 Credentials
require.Error(t, msgp.Decode(bytes.NewReader(b), &c2))
})
}
type failingWriter struct {
limit int
count int
}
func (w *failingWriter) Write(p []byte) (int, error) {
remaining := w.limit - w.count
if remaining <= 0 {
return 0, errors.New("failingWriter: exhausted")
}
if len(p) > remaining {
w.count = w.limit
return remaining, errors.New("failingWriter: exhausted")
}
w.count += len(p)
return len(p), nil
}
func exerciseEncodeMsgErrorPaths(t *testing.T, enc msgp.Encodable, marshalled []byte) {
t.Helper()
for limit := 0; limit <= len(marshalled); limit++ {
fw := &failingWriter{limit: limit}
wr := msgp.NewWriterSize(fw, 18)
err := enc.EncodeMsg(wr)
if err == nil {
err = wr.Flush()
}
if limit < len(marshalled) {
require.Errorf(t, err, "EncodeMsg should fail when underlying writer errors after %d bytes", limit)
} else {
require.NoError(t, err)
}
}
}
func msgpOneFieldMap(key string, value []byte) []byte {
b := msgp.AppendMapHeader(nil, 1)
b = msgp.AppendString(b, key)
return append(b, value...)
}
func msgpBool(v bool) []byte { return msgp.AppendBool(nil, v) } //nolint:unparam
func msgpInt64(v int64) []byte { return msgp.AppendInt64(nil, v) } //nolint:unparam
func msgpString(v string) []byte { return msgp.AppendString(nil, v) }
+185
View File
@@ -0,0 +1,185 @@
// Package webauthn contains the API functionality of the library. After creating and configuring a webauthn object,
// users can call the object to create and validate web authentication credentials.
//
// This documentation section highlights key functions within the library which are recommended and often have
// examples attached. Functions which are discouraged due to their lack of functionality are expressly not documented
// here, and you're on your own with these functions. Generally speaking, if the function is not documented here, it is
// either used by another function documented here, and it hides one of the arguments or return values, or it is lower
// level logic only intended for advanced use cases.
//
// The [New] function is a key function in creating a new instance of a WebAuthn Relying Party which is required to
// perform most actions.
//
// To start the credential creation ceremony, the [WebAuthn.BeginMediatedRegistration] or [WebAuthn.BeginRegistration]
// functions are used which returns [*SessionData] and a [*protocol.CredentialCreation] struct which can be easily
// serialized as JSON for the frontend library/logic. The [*SessionData] must be saved in a way which allows the
// implementer to restore it later. This [*SessionData] should be safely anchored to a user agent without allowing the
// user agent to modify the contents (i.e. opaque session cookie).
//
// To finish the credential creation ceremony, the [WebAuthn.FinishRegistration] function can be used. This function
// requires a [*http.Request] and performs all the necessary and requested validations. If you have other requirements,
// you can use [protocol.ParseCredentialCreationResponseBody] or [protocol.ParseCredentialCreationResponseBytes] which
// require an [io.Reader] or byte array respectively, then use [WebAuthn.CreateCredential] to
// perform validations against the [*protocol.ParsedCredentialCreationData] and saved [*SessionData] and finalize the
// process. For complete customizability, just produce the [*protocol.ParsedCredentialCreationData] with a custom parser
// and provide it to [WebAuthn.CreateCredential].
//
// To start a Passkey login ceremony, the [WebAuthn.BeginDiscoverableMediatedLogin] or [WebAuthn.BeginDiscoverableLogin]
// functions are used which returns [*SessionData] and a [*protocol.CredentialAssertion] struct which can easily be
// serialized as JSON for the frontend library/logic. The [*SessionData] should be safely handled as previously described.
//
// To finish a Passkey login ceremony, the [WebAuthn.FinishPasskeyLogin] function can be used. This function requires a
// [*http.Request] and performs all the necessary validations. If you have other requirements, you can use the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, then use [WebAuthn.ValidatePasskeyLogin] to perform validations against the
// [*protocol.ParsedCredentialAssertionData] and saved [*SessionData] and finalize the process. For complete customizabilty,
// just produce the [protocol.ParsedCredentialAssertionData] with a custom parser and provide it to
// [WebAuthn.ValidatePasskeyLogin].
//
// To start a Multi-Factor login ceremony, the [WebAuthn.BeginMediatedLogin] or [WebAuthn.BeginLogin]
// functions are used which returns [SessionData] and a [*protocol.CredentialAssertion] struct which can easily be
// serialized as JSON for the frontend library/logic. The [*SessionData] should be safely handled as previously described.
//
// To finish a Multi-Factor login ceremony, the [WebAuthn.FinishLogin] function can be used. This function requires a
// [*http.Request] and performs all the necessary validations. If you have other requirements, you can use the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, then use [WebAuthn.ValidateLogin] to perform validations against the
// [*protocol.ParsedCredentialAssertionData] and saved [*SessionData] and finalize the process. For complete
// customizabilty, just produce the [protocol.ParsedCredentialAssertionData] with a custom parser and provide it to
// [WebAuthn.ValidateLogin].
//
// # Relying Party Usage
//
// This library hadnles the relying party server-side concerns. The browser or other user agent is responsible for
// handling the JSON responses from this library and translating them for the WebAUthn API appropriately. There are two
// primary ways to handle this other than doing so manually:
//
// 1. Using a client side library like [@simplewebauthn/browser].
// 2. Some browsers support the [parseCreationOptionsFromJSON] static method on the WebAuthn object.
//
// [parseCreationOptionsFromJSON]: https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseCreationOptionsFromJSON_static
// [@simplewebauthn/browser]: https://simplewebauthn.dev/docs/packages/browser
//
// # Storage
//
// This section describes how a Relying Party should persist the state produced by the library: the [Credential]
// records returned from registration (which must survive for the lifetime of the credential) and the
// [SessionData] records exchanged between the Begin and Finish/Validate calls of each ceremony (which need only
// live long enough to span the ceremony).
//
// Guidance here assumes PostgreSQL as the backing store; the same shape translates to other SQL engines but the
// column types given below are written against PostgreSQL.
//
// Two persistence shapes are supported for the [Credential] struct and the first is strongly recommended:
//
// 1. Explicit fields (recommended). Map each field of the struct (and for [Credential] the nested
// [Authenticator] and [CredentialAttestation] fields) to its own column, using native types (BYTEA for raw
// bytes, BOOLEAN for each flag, TIMESTAMPTZ for time values, etc.). This gives the database a typed,
// queryable view of each record, allows per-field constraints and indexes, and lets an operator audit or
// migrate individual values without having to decode an opaque blob.
//
// 2. Opaque serialized value. Serialize the whole struct into a single BYTEA (or JSONB) column. Both
// encoding/json and MessagePack are supported via the struct tags on every field (the `msg:` tags drive the
// msgp-generated code in *_gen.go, and the `json:` tags drive encoding/json); either encoding will
// round-trip a [Credential]. Prefer this only when the explicit-field approach genuinely
// does not fit; you lose the ability to query, index, or update individual fields in the database.
//
// One persistence shape is supported for the [SessionData] struct which is to store it as bytes via encoding/json or
// using MessagePack as bytes in whatever storage system you're using for user sessions. This data MUST be definitively
// anchored to a user's active session, and it must be restored between the ceremony steps.
//
// Regardless of which shape is chosen, the following values MUST be persisted as their own columns so records
// can be located and scoped correctly without first decoding attestation or key material. The User Handle in
// particular is per-user state (one value shared by every credential that user owns) and MUST NOT be stored on
// the credential row; store it once per user on a separate table (`webauthn_users` in the example below) and
// link credentials to that row via the application user identifier.
//
// On each [Credential] row:
//
// - Credential ID; [Credential.ID], the identifier returned by the authenticator and echoed in every
// assertion. This is the primary lookup key at login.
// - Relying Party ID; the RP ID the credential was registered against. Credentials must be partitioned by
// RP ID and the stored value must match the RP ID in effect at authentication time.
// - Application user identifier; your application's own unique user id (the primary key used elsewhere in
// your schema to reference the user). This is what ties a credential back to the user record and,
// transitively via `webauthn_users`, to the User Handle.
//
// On a separate per-user row (`webauthn_users` or equivalent), keyed uniquely by (RP ID, application user id)
// and also uniquely by (RP ID, User Handle):
//
// - Relying Party ID; same scoping rules as above; a user may have distinct User Handles under different
// RP IDs, so the RP ID must be part of both unique keys on this table.
// - Application user identifier; the same value stored on each of that user's credential rows; this is
// the join column between `webauthn_users` and `webauthn_credentials`.
// - User Handle; the opaque per-user byte sequence returned by [User.WebAuthnID], equivalently
// [SessionData.UserID]. This is the value exchanged with the authenticator and is what
// discoverable-credential flows return at login. It MUST be stable for the lifetime of the account and
// MUST be the same across every credential that user owns; storing it once, per user, is what enforces
// that. It is NOT the same as the application user identifier: the User Handle is an opaque WebAuthn
// value emitted to authenticators, whereas the application user identifier is your schema's primary
// key for the user. Keeping the two as separate columns lets you resolve from either direction.
//
// A minimal PostgreSQL schema covering the above plus the remaining [Credential], [Authenticator], and
// [CredentialAttestation] fields is shown below.
//
// Example users table:
//
// CREATE TABLE webauthn_users (
// id UUID PRIMARY KEY DEFAULT uuidv7(),
// created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
// rpid VARCHAR(512) NOT NULL, -- Relying Party ID
// user_id UUID NOT NULL, -- Application-side unique user id (FK to your users table)
// handle BYTEA NOT NULL -- User.WebAuthnID (WebAuthn User Handle); stable per (rpid, user_id)
// );
//
// CREATE UNIQUE INDEX webauthn_users_user_id_key ON webauthn_users (rpid, user_id);
// CREATE UNIQUE INDEX webauthn_users_handle_key ON webauthn_users (rpid, handle);
//
// Example credentials table:
//
// CREATE TABLE webauthn_credentials (
// id UUID PRIMARY KEY DEFAULT uuidv7(),
// created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
// last_used_at TIMESTAMPTZ NULL,
// rpid VARCHAR(512) NOT NULL, -- Relying Party ID
// user_id UUID NOT NULL, -- Application-side unique user id
// kid BYTEA NOT NULL, -- Credential.ID
// aaguid BYTEA NULL, -- Authenticator.AAGUID
// public_key BYTEA NOT NULL, -- Credential.PublicKey (encrypt at rest)
// attestation_type VARCHAR(32) NOT NULL, -- CredentialAttestation.AttestationType
// attestation_format VARCHAR(32) NOT NULL, -- CredentialAttestation.AttestationFormat
// attestation BYTEA NULL DEFAULT NULL, -- CredentialAttestation serialized as Message Pack or JSON (encrypt at rest)
// transport VARCHAR(64) NOT NULL DEFAULT '', -- Credential.Transport serialized as a comma-separated value
// sign_count BIGINT NOT NULL DEFAULT 0, -- Authenticator.SignCount
// clone_warning BOOLEAN NOT NULL DEFAULT FALSE, -- Authenticator.CloneWarning
// attachment VARCHAR(64) NOT NULL DEFAULT '', -- Authenticator.Attachment
// flags BYTEA NOT NULL, -- Value of Flags.ProtocolValue (a single octet), restored with NewCredentialFlags, could also be SMALLINT
// present BOOLEAN NOT NULL DEFAULT FALSE, -- Flags.UserPresent, optionally stored so you can either display it to the user or for filtering credentials
// verified BOOLEAN NOT NULL DEFAULT FALSE, -- Flags.UserVerified, optionally stored so you can either display it to the user or for filtering credentials
// backup_eligible BOOLEAN NOT NULL DEFAULT FALSE, -- Flags.BackupEligible, optionally stored so you can either display it to the user or for filtering credentials
// backup_state BOOLEAN NOT NULL DEFAULT FALSE -- Flags.BackupState, optionally stored so you can either display it to the user or for filtering credentials
// );
//
// CREATE UNIQUE INDEX webauthn_credentials_kid_key ON webauthn_credentials (rpid, kid);
// CREATE INDEX webauthn_credentials_user_id ON webauthn_credentials (rpid, user_id);
//
// With that shape, the two login lookup paths resolve as:
//
// - Credential-ID-first (allowCredentials flows): match `webauthn_credentials.kid` to the credential ID
// returned by the authenticator, then optionally join `webauthn_users` on (rpid, user_id) to compare the
// authenticator-supplied User Handle against the stored one.
// - User-Handle-first (discoverable / passkey flows): match `webauthn_users.handle` under the current
// RP ID to resolve the application user id, then load that user's credentials from
// `webauthn_credentials`.
//
// Fields that change across assertions; [Authenticator.SignCount], [Authenticator.CloneWarning], and
// [CredentialFlags.BackupState] when [CredentialFlags.BackupEligible] is true MUST be written back to storage
// on every successful FinishLogin / ValidateLogin so the next ceremony observes the current values.
//
// For [SessionData] stored in a database (rather than a server-side session store), use the same persistence
// shapes described above. The User Handle on a [SessionData] row is per-session ceremony state rather than
// per-user state, so it is fine to keep [SessionData.UserID] on the session row itself; the per-user-table
// rule applies to [Credential] storage, not to [SessionData]. Additionally index the challenge (unique) and
// the expiry timestamp so sessions can be looked up by challenge at Finish time and expired rows reaped
// cheaply. Stored sessions must only be consumed by a Finish call operating under the same RP ID.
package webauthn
@@ -0,0 +1,258 @@
package webauthn_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// Example_multiFactorRegisterAndLogin demonstrates handling Multi Factor registration and Logins. This uses the higher level APIs to
// perform all of the various requirements. The Crude and Abstract examples are purely domain logic and will often
// describe aspects that should be considered during their implementation if they are important; these aspects
// are not strictly concerns related to the library as there are too many logical implementations to count.
func Example_multiFactorRegisterAndLogin() {
config := &webauthn.Config{
RPDisplayName: "Go WebAuthn",
RPID: "app.awesome-go-webauthn.com",
RPOrigins: []string{"https://app.awesome-go-webauthn.com"},
}
w, err := webauthn.New(config)
if err != nil {
// Crude example of error handling.
panic(err)
}
mux := http.NewServeMux()
// Register the handlers. The second component describes the action (i.e. register/login), the final component
// describes the step (i.e. start/finish).
mux.HandleFunc("/webauthn/register/start", handlerExampleMultiFactorCreateChallenge(w))
mux.HandleFunc("/webauthn/register/finish", handlerExampleMultiFactorValidateCreateChallengeResponse(w))
mux.HandleFunc("/webauthn/login/start", handlerExampleMultiFactorLoginChallenge(w))
mux.HandleFunc("/webauthn/login/finish", handlerExampleMultiFactorLoginChallengeResponse(w))
// Crude example that assumes the app is handled exclusively by a proxy which handles TLS termination. You will
// have to adjust this depending on the context to ensure TLS is used on port 443 or the relevant config options
// are adjusted.
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
if err = server.ListenAndServe(); err != nil {
panic(err)
}
}
var sessionExampleMultiFactor *webauthn.SessionData
func saveSessionExampleMultiFactor(s *webauthn.SessionData) {
sessionExampleMultiFactor = s
}
func loadSessionExampleMultiFactor() (*webauthn.SessionData, error) {
if sessionExampleMultiFactor == nil {
return nil, fmt.Errorf("no session found")
}
return sessionExampleMultiFactor, nil
}
func handlerExampleMultiFactorCreateChallenge(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude / Abstract example of retrieving the user this registration will belong to. The user must be logged in
// for this step unless you plan to register the user and the credential at the same time i.e. usernameless.
// The user should have a unique and stable value returned from WebAuthnID that can be used to retrieve the
// account details for the user.
user, err := LoadUser()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
var (
creation *protocol.CredentialCreation
s *webauthn.SessionData
)
opts := []webauthn.RegistrationOption{
webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}),
}
if creation, s, err = w.BeginMediatedRegistration(user, protocol.MediationDefault, opts...); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example saving the session data securely to be loaded in the finish step of the register action. This
// should be stored in such a way that the user and user agent has no access to it. For example using an opaque
// session cookie.
saveSessionExampleMultiFactor(s)
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(rw)
if err = encoder.Encode(creation); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func handlerExampleMultiFactorValidateCreateChallengeResponse(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude / Abstract example of retrieving the user performing the multi-factor authentication. The user must be
// logged in for this step.
user, err := LoadUser()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example loading the session data securely from the start step for the register action. This should be
// loaded from a place the user and user agent has no access to it. For example using an opaque session cookie.
s, err := loadSessionExampleMultiFactor()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
credential, err := w.FinishRegistration(user, *s, r)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude / Abstract example of adding the credential to the list of credentials for the user. This is critical
// for performing future logins.
user.credentials = append(user.credentials, *credential)
// Crude / Abstract example of saving the updated user. This is critical for performing future logins.
if err = SaveUser(user); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
}
}
func handlerExampleMultiFactorLoginChallenge(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude / Abstract example of retrieving the user for this multi-factor authentication. Because this is a
// multi-factor authentication the user MUST be logged in at this stage and the returned struct/interface must
// be deterministically matched to their account.
user, err := LoadUser()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
assertion, s, err := w.BeginMediatedLogin(user, protocol.MediationDefault)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example saving the session data securely to be loaded in the finish step of the login action. This
// should be stored in such a way that the user and user agent has no access to it. For example using an opaque
// session cookie.
saveSessionExampleMultiFactor(s)
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(rw)
if err = encoder.Encode(assertion); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func handlerExampleMultiFactorLoginChallengeResponse(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude / Abstract example of retrieving the user performing the multi-factor authentication. The user must be
// logged in for this step.
user, err := LoadUser()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example loading the session data securely from the start step for the login action. This should be
// loaded from a place the user and user agent has no access to it. For example using an opaque session cookie.
s, err := loadSessionExampleMultiFactor()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
validatedCredential, err := w.FinishLogin(user, *s, r)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
var found bool
// Modify the matching credential in the user struct which is critical for proper future validations as the
// metadata for this credential has been updated. No type assertion is required here since the LoadUser function
// returns the concrete implementation, you may have to adjust this if you return the abstract implementation
// instead.
for i, credential := range user.credentials {
if bytes.Equal(validatedCredential.ID, credential.ID) {
user.credentials[i] = *validatedCredential
// Crude / Abstract example of saving the user with their updated credentials. This is critical for
// proper future validations.
if err = SaveUser(user); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
found = true
break
}
}
// Should error if we can't update the credentials for the user.
if !found {
rw.WriteHeader(http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
}
}
+19
View File
@@ -0,0 +1,19 @@
package webauthn_test
import "github.com/go-webauthn/webauthn/webauthn"
// Example_newRelyingParty demonstrates initializing a relying party.
func Example_newRelyingParty() {
config := &webauthn.Config{
RPDisplayName: "Go WebAuthn",
RPID: "app.awesome-go-webauthn.com",
RPOrigins: []string{"https://app.awesome-go-webauthn.com"},
}
handler, err := webauthn.New(config)
if err != nil {
panic(err)
}
_ = handler
}
+255
View File
@@ -0,0 +1,255 @@
package webauthn_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// Example_passkeysRegisterAndLogin demonstrates handling Passkey registration and Logins. This uses the higher level APIs to
// perform all of the various requirements. The Crude and Abstract examples are purely domain logic and will often
// describe aspects that should be considered during their implementation if they are important; these aspects
// are not strictly concerns related to the library as there are too many logical implementations to count.
func Example_passkeysRegisterAndLogin() {
config := &webauthn.Config{
RPDisplayName: "Go WebAuthn",
RPID: "app.awesome-go-webauthn.com",
RPOrigins: []string{"https://app.awesome-go-webauthn.com"},
}
w, err := webauthn.New(config)
if err != nil {
// Crude example of error handling.
panic(err)
}
mux := http.NewServeMux()
// Register the handlers. The second component describes the action (i.e. register/login), the final component
// describes the step (i.e. start/finish).
mux.HandleFunc("/webauthn/register/start", handlerExamplePasskeyCreateChallenge(w))
mux.HandleFunc("/webauthn/register/finish", handlerExamplePasskeyValidateCreateChallengeResponse(w))
mux.HandleFunc("/webauthn/login/start", handlerExamplePasskeyLoginChallenge(w))
mux.HandleFunc("/webauthn/login/finish", handlerExamplePasskeyLoginChallengeResponse(w))
// Crude example that assumes the app is handled exclusively by a proxy which handles TLS termination. You will
// have to adjust this depending on the context to ensure TLS is used on port 443 or the relevant config options
// are adjusted.
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
if err = server.ListenAndServe(); err != nil {
panic(err)
}
}
var sessionExamplePasskey *webauthn.SessionData
func saveSessionExamplePasskey(s *webauthn.SessionData) {
sessionExamplePasskey = s
}
func loadSessionExamplePasskey() (*webauthn.SessionData, error) {
if sessionExamplePasskey == nil {
return nil, fmt.Errorf("no session found")
}
return sessionExamplePasskey, nil
}
func loadUserExamplePasskey(rawID []byte, userHandle []byte) (user webauthn.User, err error) {
// Crude / Abstract example of retrieving the user for the rawID/userHandle value.
return LoadUserByHandle(userHandle)
}
func handlerExamplePasskeyCreateChallenge(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude / Abstract example of retrieving the user this registration will belong to. The user must be logged in
// for this step unless you plan to register the user and the credential at the same time i.e. usernameless.
// The user should have a unique and stable value returned from WebAuthnID that can be used to retrieve the
// account details for the user.
user, err := LoadUser()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
var (
creation *protocol.CredentialCreation
s *webauthn.SessionData
)
opts := []webauthn.RegistrationOption{
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}),
}
if creation, s, err = w.BeginMediatedRegistration(user, protocol.MediationDefault, opts...); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example saving the session data securely to be loaded in the finish step of the register action. This
// should be stored in such a way that the user and user agent has no access to it. For example using an opaque
// session cookie.
saveSessionExamplePasskey(s)
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(rw)
if err = encoder.Encode(creation); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func handlerExamplePasskeyValidateCreateChallengeResponse(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude / Abstract example of retrieving the user this registration will belong to. The user must be logged in
// for this step unless you plan to register the user and the credential at the same time i.e. usernameless.
// The user should have a unique and stable value returned from WebAuthnID that can be used to retrieve the
// account details for the user.
user, err := LoadUser()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example loading the session data securely from the start step for the register action. This should be
// loaded from a place the user and user agent has no access to it. For example using an opaque session cookie.
s, err := loadSessionExamplePasskey()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
credential, err := w.FinishRegistration(user, *s, r)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude / Abstract example of adding the credential to the list of credentials for the user. This is critical
// for performing future logins.
user.credentials = append(user.credentials, *credential)
// Crude / Abstract example of saving the updated user. This is critical for performing future logins.
if err = SaveUser(user); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
}
}
func handlerExamplePasskeyLoginChallenge(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
assertion, s, err := w.BeginDiscoverableMediatedLogin(protocol.MediationDefault)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// Crude example saving the session data securely to be loaded in the finish step of the login action. This
// should be stored in such a way that the user and user agent has no access to it. For example using an opaque
// session cookie.
saveSessionExamplePasskey(s)
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(rw)
if err = encoder.Encode(assertion); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func handlerExamplePasskeyLoginChallengeResponse(w *webauthn.WebAuthn) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
// Crude example loading the session data securely from the start step for the login action. This should be
// loaded from a place the user and user agent has no access to it. For example using an opaque session cookie.
s, err := loadSessionExamplePasskey()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
validatedUser, validatedCredential, err := w.FinishPasskeyLogin(loadUserExamplePasskey, *s, r)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
// This type assertion is necessary to perform the necessary updates.
user, ok := validatedUser.(*defaultUser)
if !ok {
rw.WriteHeader(http.StatusInternalServerError)
return
}
var found bool
// Modify the matching credential in the user struct which is critical for proper future validations as the
// metadata for this credential has been updated. No type assertion is required here since the LoadUser function
// returns the concrete implementation, you may have to adjust this if you return the abstract implementation
// instead.
for i, credential := range user.credentials {
if bytes.Equal(validatedCredential.ID, credential.ID) {
user.credentials[i] = *validatedCredential
// Crude / Abstract example of saving the user with their updated credentials. This is critical for
// proper future validations.
if err = SaveUser(user); err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
found = true
break
}
}
// Should error if we can't update the credentials for the user.
if !found {
rw.WriteHeader(http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
}
}
+387
View File
@@ -0,0 +1,387 @@
package webauthn
import (
"bytes"
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/go-webauthn/webauthn/protocol"
)
// LoginOption is a functional option that modifies the [protocol.PublicKeyCredentialRequestOptions] sent to the
// client during a login ceremony. Use the With* functions in this package (i.e. [WithUserVerification],
// [WithAllowedCredentials]) to create login options.
type LoginOption func(*protocol.PublicKeyCredentialRequestOptions)
// DiscoverableUserHandler is a callback function that the Relying Party must provide when performing a discoverable
// (passkey) login. It is called with the rawID of the credential and the userHandle from the authenticator response,
// and must return the [User] who owns the credential. This is necessary because in discoverable login flows, the
// Relying Party does not know which user is authenticating until the authenticator response is received.
type DiscoverableUserHandler func(rawID, userHandle []byte) (user User, err error)
// BeginLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent for beginning
// the login/assertion process. This function is used to perform a login when the identity of the user is known such as
// multifactor authentications, to specify a conditional mediation requirement use [WebAuthn.BeginMediatedLogin], to
// perform a login when the identity of the user is not known see [WebAuthn.BeginDiscoverableLogin] and
// [WebAuthn.BeginDiscoverableMediatedLogin] instead. The format of this data can be seen in §5.5 of the WebAuthn
// specification. These default values can be amended by providing additional [LoginOption] parameters. This function
// also returns [SessionData], that must be stored by the RP in a secure manner and then provided to the
// [WebAuthn.FinishLogin] function. This data helps us verify the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginLogin(user User, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.BeginMediatedLogin(user, protocol.MediationDefault, opts...)
}
// BeginDiscoverableLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent
// for beginning the login/assertion process. This function is used to perform a client-side discoverable login when the
// identity of the user is not known such as passwordless or usernameless authentication, to specify a conditional
// mediation requirement use [WebAuthn.BeginDiscoverableMediatedLogin], to perform logins where the identity of the user
// is known such as multifactor authentication see [WebAuthn.BeginLogin] and [WebAuthn.BeginMediatedLogin] instead.
// The format of this data can be seen in §5.5 of the WebAuthn specification. These default values can be amended by
// providing additional [LoginOption] parameters. This function also returns [SessionData], that
// must be stored by the RP in a secure manner and then provided to the [WebAuthn.FinishLogin] function. This data helps
// us verify the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginDiscoverableLogin(opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.beginLogin(nil, nil, protocol.MediationDefault, opts...)
}
// BeginMediatedLogin is similar to [WebAuthn.BeginLogin] however it also allows specifying a credential mediation
// requirement.
func (webauthn *WebAuthn) BeginMediatedLogin(user User, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
credentials := user.WebAuthnCredentials()
if len(credentials) == 0 { // If the user does not have any credentials, we cannot perform an assertion.
return nil, nil, protocol.ErrBadRequest.WithDetails("Found no credentials for user")
}
var allowedCredentials = make([]protocol.CredentialDescriptor, len(credentials))
for i, credential := range credentials {
allowedCredentials[i] = credential.Descriptor()
}
return webauthn.beginLogin(user.WebAuthnID(), allowedCredentials, mediation, opts...)
}
// BeginDiscoverableMediatedLogin is similar to [WebAuthn.BeginDiscoverableLogin] however it also allows specifying a
// credential mediation requirement.
func (webauthn *WebAuthn) BeginDiscoverableMediatedLogin(mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.beginLogin(nil, nil, mediation, opts...)
}
func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protocol.CredentialDescriptor, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (assertion *protocol.CredentialAssertion, session *SessionData, err error) {
if err = webauthn.Config.validate(); err != nil {
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
}
assertion = &protocol.CredentialAssertion{
Response: protocol.PublicKeyCredentialRequestOptions{
RelyingPartyID: webauthn.Config.RPID,
UserVerification: webauthn.Config.AuthenticatorSelection.UserVerification,
AllowedCredentials: allowedCredentials,
},
Mediation: mediation,
}
for _, opt := range opts {
opt(&assertion.Response)
}
if len(assertion.Response.Challenge) == 0 {
var challenge protocol.URLEncodedBase64
if challenge, err = protocol.CreateChallenge(); err != nil {
return nil, nil, err
}
assertion.Response.Challenge = challenge
}
if len(assertion.Response.Challenge) < protocol.MinimumChallengeLength {
return nil, nil, fmt.Errorf("error generating assertion: the challenge must be at least 16 bytes")
}
if len(assertion.Response.RelyingPartyID) == 0 {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id must be provided via the configuration or a functional option for a login")
} else if err = protocol.ValidateRPID(assertion.Response.RelyingPartyID); err != nil {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id failed to validate as it's not a valid domain string with error: %w", err)
}
if assertion.Response.Timeout == 0 {
switch assertion.Response.UserVerification {
case protocol.VerificationDiscouraged:
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.TimeoutUVD.Milliseconds())
default:
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.Timeout.Milliseconds())
}
}
session = &SessionData{
Challenge: assertion.Response.Challenge.String(),
RelyingPartyID: assertion.Response.RelyingPartyID,
UserID: userID,
AllowedCredentialIDs: assertion.Response.GetAllowedCredentialIDs(),
UserVerification: assertion.Response.UserVerification,
Extensions: assertion.Response.Extensions,
}
if webauthn.Config.Timeouts.Login.Enforce {
session.Expires = time.Now().Add(time.Millisecond * time.Duration(assertion.Response.Timeout))
}
return assertion, session, nil
}
// FinishLogin takes the response from the client and validates it against the user credentials and stored session data.
//
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidateLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in the [User]'s
// [Credential] list.
func (webauthn *WebAuthn) FinishLogin(user User, session SessionData, response *http.Request) (credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, err
}
return webauthn.ValidateLogin(user, session, parsedResponse)
}
// FinishDiscoverableLogin takes the response from the client and validates it against the handler and stored session data.
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
// business code that will retrieve the user from your persistent data.
//
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) FinishDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, err
}
return webauthn.ValidateDiscoverableLogin(handler, session, parsedResponse)
}
// FinishPasskeyLogin takes the response from the client and validate it against the handler and stored session data.
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
// business code that will retrieve the user from your persistent data.
//
// As with all Finish functions this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// io.Reader or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) FinishPasskeyLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (user User, credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, nil, err
}
return webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
}
// ValidateLogin takes a parsed response and validates it against the user credentials and session data.
//
// If you wish to skip performing the step required to parse the *protocol.ParsedCredentialAssertionData and
// you're using net/http then you can use [WebAuthn.FinishLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in
// the [User]'s [Credential] list.
func (webauthn *WebAuthn) ValidateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
if !bytes.Equal(user.WebAuthnID(), session.UserID) {
return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
return webauthn.validateLogin(user, session, parsedResponse)
}
// ValidateDiscoverableLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials. It's
// recommended that [WebAuthn.ValidatePasskeyLogin] is used instead.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
// you're using net/http then you can use [WebAuthn.FinishDiscoverableLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
//
// Note: this is just a backwards compatibility layer over [WebAuthn.ValidatePasskeyLogin] which returns more information.
func (webauthn *WebAuthn) ValidateDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
_, credential, err = webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
return credential, err
}
// ValidatePasskeyLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
// you're using net/http then you can use [WebAuthn.FinishPasskeyLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) ValidatePasskeyLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (user User, credential *Credential, err error) {
if len(session.UserID) != 0 {
return nil, nil, protocol.ErrBadRequest.WithDetails("Session was not initiated as a client-side discoverable login")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
if len(parsedResponse.Response.UserHandle) == 0 {
return nil, nil, protocol.ErrBadRequest.WithDetails("Client-side Discoverable Assertion was attempted with a blank User Handle")
}
if user, err = handler(parsedResponse.RawID, parsedResponse.Response.UserHandle); err != nil {
return nil, nil, protocol.ErrBadRequest.WithDetails(fmt.Sprintf("Failed to lookup Client-side Discoverable Credential: %s", err)).WithError(err)
}
if user == nil {
return nil, nil, protocol.ErrBadRequest.WithDetails("Failed to lookup Client-side Discoverable Credential: handler returned a nil user")
}
if credential, err = webauthn.validateLogin(user, session, parsedResponse); err != nil {
return nil, nil, err
}
return user, credential, nil
}
// validateLogin takes a parsed response and validates it against the user credentials and session data.
//
//nolint:gocyclo
func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (*Credential, error) {
// Step 1. If the allowCredentials option was given when this authentication ceremony was initiated,
// verify that credential.id identifies one of the public key credentials that were listed in
// allowCredentials.
// NON-NORMATIVE Prior Step: Verify that the allowCredentials for the session are owned by the user provided.
credentials := user.WebAuthnCredentials()
if len(session.AllowedCredentialIDs) > 0 {
if !isCredentialsAllowedMatchingOwned(session.AllowedCredentialIDs, credentials) {
return nil, protocol.ErrBadRequest.WithDetails("User does not own all credentials from the allowed credential list")
}
if !isCredentialIDInCredentials(parsedResponse.RawID, credentials) {
return nil, &protocol.ErrorUnknownCredential{Err: protocol.ErrBadRequest.WithDetails("The credential ID provided is not owned by the user")}
}
if !isByteArrayInSlice(parsedResponse.RawID, session.AllowedCredentialIDs...) {
return nil, protocol.ErrBadRequest.WithDetails("The credential ID provided is not in the sessions allowed credential list")
}
}
// Step 2. If credential.response.userHandle is present, verify that the user identified by this value is
// the owner of the public key credential identified by credential.id. This is in part handled by our Step 1.
userHandle := parsedResponse.Response.UserHandle
if len(userHandle) > 0 {
if !bytes.Equal(userHandle, user.WebAuthnID()) {
return nil, protocol.ErrBadRequest.WithDetails("User handle and User ID do not match")
}
}
var (
found bool
credential Credential
)
// Step 3. Using credentials id attribute (or the corresponding rawId, if base64url encoding is inappropriate
// for your use case), look up the corresponding credential public key.
for _, credential = range credentials {
if bytes.Equal(credential.ID, parsedResponse.RawID) {
found = true
break
}
}
if !found {
return nil, protocol.ErrBadRequest.WithDetails("Unable to find the credential for the returned credential ID")
}
var (
appID string
err error
)
// Ensure authenticators with a bad status are not used.
if webauthn.Config.MDS != nil {
var aaguid uuid.UUID
if len(credential.Authenticator.AAGUID) == 0 {
aaguid = uuid.Nil
} else if aaguid, err = uuid.FromBytes(credential.Authenticator.AAGUID); err != nil {
return nil, protocol.ErrBadRequest.WithDetails("Failed to decode AAGUID").WithInfo(fmt.Sprintf("Error occurred decoding AAGUID from the credential record: %s", err)).WithError(err)
}
if e := protocol.ValidateMetadata(context.Background(), webauthn.Config.MDS, aaguid, credential.AttestationType, credential.AttestationFormat, nil); e != nil {
return nil, protocol.ErrBadRequest.WithDetails("Failed to validate credential record metadata").WithInfo(e.DevInfo).WithError(e)
}
}
shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
shouldVerifyUserPresence := true
rpID := webauthn.Config.RPID
rpOrigins := webauthn.Config.RPOrigins
rpTopOrigins := webauthn.Config.RPTopOrigins
if appID, err = parsedResponse.GetAppID(session.Extensions, credential.AttestationFormat); err != nil {
return nil, err
}
// Handle steps 4 through 16.
if err = parsedResponse.Verify(session.Challenge, rpID, appID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.RPAllowCrossOrigin, shouldVerifyUser, shouldVerifyUserPresence, credential.PublicKey); err != nil {
return nil, err
}
// Check if the BackupEligible flag has changed.
if credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() {
return nil, protocol.ErrBadRequest.WithDetails("Backup Eligible flag inconsistency detected during login validation")
}
// Check for the invalid combination BE=0 and BS=1.
if !parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() && parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() {
return nil, protocol.ErrBadRequest.WithDetails("Backup State Flag is true but Backup Eligible flag is false which is invalid")
}
// Handle step 17.
credential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter)
// Update flags from response data.
credential.Flags = NewCredentialFlags(parsedResponse.Response.AuthenticatorData.Flags)
return &credential, nil
}
+101
View File
@@ -0,0 +1,101 @@
package webauthn
import "github.com/go-webauthn/webauthn/protocol"
// WithChallenge overrides the random challenge that [WebAuthn.BeginLogin] would otherwise generate for this
// ceremony. The supplied value is used verbatim.
//
// The only safe reason to call this is when the relying party needs to record the challenge in a server-side store
// before the ceremony is initiated; for example to maintain a set of previously-issued challenges so it can
// reject a replay that reuses one. Generating the challenge inside a separate step lets the RP persist it
// atomically before it is ever handed to the client.
//
// If you have that need, the supplied challenge MUST be produced by [protocol.CreateChallenge] (32 bytes from
// crypto/rand). Do not use timestamps, counters, UUIDs, hashed user inputs, or any other deterministic or
// partially-predictable source; these defeat the cryptographic guarantees the challenge provides and open the
// ceremony to replay and guessing attacks. [WebAuthn.BeginLogin] enforces a minimum length of
// [protocol.MinimumChallengeLength] bytes, but that check is a backstop only and is not a substitute for using a
// CSPRNG.
//
// If you do not have a specific persistence requirement, do not use this function; let the library generate the
// challenge automatically.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-challenge)
//
// Specification: §13.4.3. Cryptographic Challenges (https://www.w3.org/TR/webauthn/#sctn-cryptographic-challenges)
func WithChallenge(challenge []byte) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Challenge = challenge
}
}
// WithLoginRelyingPartyID sets the Relying Party ID for this particular login.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-rpid)
func WithLoginRelyingPartyID(id string) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.RelyingPartyID = id
}
}
// WithAllowedCredentials adjusts the allowed credentials via a slice of [protocol.CredentialDescriptor] values,
// discussed in the included specification sections with user-supplied values.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-allowcredentials)
//
// Specification: §5.10.3. Credential Descriptor (https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialdescriptor)
func WithAllowedCredentials(allowList []protocol.CredentialDescriptor) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.AllowedCredentials = allowList
}
}
// WithUserVerification adjusts the user verification preference by providing a [protocol.UserVerificationRequirement].
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-userverification)
func WithUserVerification(userVerification protocol.UserVerificationRequirement) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.UserVerification = userVerification
}
}
// WithAssertionPublicKeyCredentialHints adjusts the non-default hints for credential types to select during login by
// providing a slice of [protocol.PublicKeyCredentialHints].
//
// WebAuthn Level 3.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-hints)
func WithAssertionPublicKeyCredentialHints(hints []protocol.PublicKeyCredentialHints) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Hints = hints
}
}
// WithAssertionExtensions adjusts the requested extensions by providing a [protocol.AuthenticationExtensions].
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-extensions)
func WithAssertionExtensions(extensions protocol.AuthenticationExtensions) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Extensions = extensions
}
}
// WithAppIdExtension automatically includes the specified appid if the AllowedCredentials contains a credential
// with the type `fido-u2f`.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-extensions)
func WithAppIdExtension(appid string) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
for _, credential := range cco.AllowedCredentials {
if credential.AttestationFormat == string(protocol.AttestationFormatFIDOUniversalSecondFactor) {
if cco.Extensions == nil {
cco.Extensions = map[string]any{}
}
cco.Extensions[protocol.ExtensionAppID] = appid
break
}
}
}
}
+1393
View File
@@ -0,0 +1,1393 @@
package webauthn
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/go-webauthn/webauthn/metadata"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/testing/mocks"
)
func TestLogin_FinishLoginFailure(t *testing.T) {
user := &defaultUser{
id: []byte("123"),
}
session := SessionData{
UserID: []byte("ABC"),
}
webauthn := &WebAuthn{}
credential, err := webauthn.FinishLogin(user, session, nil)
if err == nil {
t.Errorf("FinishLogin() error = nil, want %v", protocol.ErrBadRequest.Type)
}
if credential != nil {
t.Errorf("FinishLogin() credential = %v, want nil", credential)
}
}
func TestWithLoginRelyingPartyID(t *testing.T) {
testCases := []struct {
name string
have *Config
opts []LoginOption
expectedID string
expectedChallenge []byte
err string
}{
{
name: "OptionDefinedInConfig",
have: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: nil,
expectedID: "example.com",
},
{
name: "OptionDefinedInConfigAndOpts",
have: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: []LoginOption{WithLoginRelyingPartyID("a.example.com")},
expectedID: "a.example.com",
},
{
name: "OptionDefinedInConfigWithNoErrAndInOptsWithError",
have: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: []LoginOption{WithLoginRelyingPartyID("---::~!!~@#M!@OIK#N!@IOK@@@@@@@@@@")},
err: "error generating assertion: the relying party id failed to validate as it's not a valid domain string with error: parse \"---::~!!~@\": first path segment in URL cannot contain colon",
},
{
name: "OptionDefinedInOpts",
have: &Config{
RPOrigins: []string{"https://example.com"},
},
opts: []LoginOption{WithLoginRelyingPartyID("example.com")},
expectedID: "example.com",
},
{
name: "OptionIDNotDefined",
have: &Config{
RPOrigins: []string{"https://example.com"},
},
opts: nil,
err: "error generating assertion: the relying party id must be provided via the configuration or a functional option for a login",
},
{
name: "TooShortWithChallengeOption",
have: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
RPDisplayName: "Test Display Name",
},
opts: []LoginOption{WithChallenge([]byte("1234567890"))},
err: "error generating assertion: the challenge must be at least 16 bytes",
},
{
name: "WithChallengeOption",
have: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
RPDisplayName: "Test Display Name",
},
opts: []LoginOption{WithChallenge([]byte("00000000000000000000000000000000"))},
expectedID: "example.com",
expectedChallenge: []byte("00000000000000000000000000000000"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.have)
assert.NoError(t, err)
user := &defaultUser{
credentials: []Credential{
{},
},
}
creation, _, err := w.BeginLogin(user, tc.opts...)
if tc.err != "" {
assert.EqualError(t, err, tc.err)
} else {
assert.NoError(t, err)
require.NotNil(t, creation)
assert.Equal(t, tc.expectedID, creation.Response.RelyingPartyID)
if len(tc.expectedChallenge) > 0 {
assert.Equal(t, protocol.URLEncodedBase64(tc.expectedChallenge).String(), creation.Response.Challenge.String())
}
}
})
}
}
func TestFinishLoginFailure(t *testing.T) {
const (
credentialID = "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec
userHandle = "0ToAAAAAAAAAAA"
)
var (
byteUserHandle, _ = base64.RawURLEncoding.DecodeString(userHandle)
byteID, _ = base64.RawURLEncoding.DecodeString(credentialID)
byteCredentialPubKey, _ = base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI")
byteAAGUID, _ = base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw")
)
credentials := []Credential{
{
ID: byteID,
PublicKey: byteCredentialPubKey,
Authenticator: Authenticator{
AAGUID: byteAAGUID,
},
},
}
user := &defaultUser{
id: byteUserHandle,
credentials: credentials,
}
session := SessionData{
UserID: byteUserHandle,
Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k",
AllowedCredentialIDs: [][]byte{[]byte("test"), byteID},
}
webauthn := &WebAuthn{
Config: &Config{
RPDisplayName: "test_rp",
RPOrigins: []string{"https://webauthn.io"},
RPID: "webauthn.io",
},
}
reqBody := io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{
"id":"%[1]s",
"rawId":"%[1]s",
"type":"public-key",
"response":{
"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
"userHandle":"%[2]s"
}
}`, credentialID, userHandle,
))))
httpReq := &http.Request{Body: reqBody}
_, err := webauthn.FinishLogin(user, session, httpReq)
require.Equal(t, protocol.ErrBadRequest.WithDetails("User does not own all credentials from the allowed credential list"), err)
}
func TestFinishLoginFailureCredentialOwnedButNotAllowedInSession(t *testing.T) {
const (
credentialIDOne = "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec
credentialIDTwo = "AI6D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec
userHandle = "0ToAAAAAAAAAAA"
)
byteIDOne, err := base64.RawURLEncoding.DecodeString(credentialIDOne)
require.NoError(t, err)
byteIDTwo, err := base64.RawURLEncoding.DecodeString(credentialIDTwo)
require.NoError(t, err)
byteUserHandle, err := base64.RawURLEncoding.DecodeString(userHandle)
require.NoError(t, err)
byteCredentialPubKey, err := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI")
require.NoError(t, err)
byteAAGUID, err := base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw")
require.NoError(t, err)
credentials := []Credential{
{
ID: byteIDOne,
PublicKey: byteCredentialPubKey,
Authenticator: Authenticator{
AAGUID: byteAAGUID,
},
},
{
ID: byteIDTwo,
PublicKey: byteCredentialPubKey,
Authenticator: Authenticator{
AAGUID: byteAAGUID,
},
},
}
user := &defaultUser{
id: byteUserHandle,
credentials: credentials,
}
session := SessionData{
UserID: byteUserHandle,
Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k",
AllowedCredentialIDs: [][]byte{byteIDOne},
}
webauthn := &WebAuthn{
Config: &Config{
RPDisplayName: "test_rp",
RPOrigins: []string{"https://webauthn.io"},
RPID: "webauthn.io",
},
}
reqBody := io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{
"id":"%[1]s",
"rawId":"%[1]s",
"type":"public-key",
"response":{
"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
"userHandle":"%[2]s"
}
}`, credentialIDTwo, userHandle,
))))
httpReq := &http.Request{Body: reqBody}
_, err = webauthn.FinishLogin(user, session, httpReq)
require.Equal(t, protocol.ErrBadRequest.WithDetails("The credential ID provided is not in the sessions allowed credential list"), err)
}
func TestFinishLoginFailureCredentialNotOwned(t *testing.T) {
const (
credentialIDOne = "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec
credentialIDTwo = "AI6D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec
userHandle = "0ToAAAAAAAAAAA"
)
byteIDOne, err := base64.RawURLEncoding.DecodeString(credentialIDOne)
require.NoError(t, err)
byteUserHandle, err := base64.RawURLEncoding.DecodeString(userHandle)
require.NoError(t, err)
byteCredentialPubKey, err := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI")
require.NoError(t, err)
byteAAGUID, err := base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw")
require.NoError(t, err)
credentials := []Credential{
{
ID: byteIDOne,
PublicKey: byteCredentialPubKey,
Authenticator: Authenticator{
AAGUID: byteAAGUID,
},
},
}
user := &defaultUser{
id: byteUserHandle,
credentials: credentials,
}
session := SessionData{
UserID: byteUserHandle,
Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k",
AllowedCredentialIDs: [][]byte{byteIDOne},
}
webauthn := &WebAuthn{
Config: &Config{
RPDisplayName: "test_rp",
RPOrigins: []string{"https://webauthn.io"},
RPID: "webauthn.io",
},
}
reqBody := io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{
"id":"%[1]s",
"rawId":"%[1]s",
"type":"public-key",
"response":{
"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
"userHandle":"%[2]s"
}
}`, credentialIDTwo, userHandle,
))))
httpReq := &http.Request{Body: reqBody}
_, err = webauthn.FinishLogin(user, session, httpReq)
require.Equal(t, &protocol.ErrorUnknownCredential{Err: protocol.ErrBadRequest.WithDetails("The credential ID provided is not owned by the user")}, err)
}
func TestFinishDiscoverableLogin_Failure(t *testing.T) {
session := SessionData{}
webauthn := &WebAuthn{}
credential, err := webauthn.FinishDiscoverableLogin(nil, session, nil)
assert.Nil(t, credential)
assert.Error(t, err)
}
func TestFinishPasskeyLogin_Failure(t *testing.T) {
session := SessionData{}
webauthn := &WebAuthn{}
user, credential, err := webauthn.FinishPasskeyLogin(nil, session, nil)
assert.Nil(t, user)
assert.Nil(t, credential)
assert.Error(t, err)
}
func TestBeginLogin_EnforceTimeout(t *testing.T) {
config := &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
Timeouts: TimeoutsConfig{
Login: TimeoutConfig{
Enforce: true,
Timeout: time.Second * 60,
},
},
}
w, err := New(config)
require.NoError(t, err)
user := &defaultUser{
credentials: []Credential{{}},
}
_, session, err := w.BeginLogin(user)
require.NoError(t, err)
assert.False(t, session.Expires.IsZero())
}
func TestBeginDiscoverableLogin(t *testing.T) {
testCases := []struct {
name string
config *Config
opts []LoginOption
expectedID string
err string
}{
{
name: "ShouldSucceed",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
expectedID: "example.com",
},
{
name: "ShouldSucceedWithOpts",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: []LoginOption{WithUserVerification(protocol.VerificationRequired)},
expectedID: "example.com",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.config)
require.NoError(t, err)
assertion, session, err := w.BeginDiscoverableLogin(tc.opts...)
if tc.err != "" {
assert.EqualError(t, err, tc.err)
} else {
require.NoError(t, err)
require.NotNil(t, assertion)
require.NotNil(t, session)
assert.Equal(t, tc.expectedID, assertion.Response.RelyingPartyID)
assert.Empty(t, session.UserID)
assert.Empty(t, session.AllowedCredentialIDs)
}
})
}
}
func TestBeginDiscoverableMediatedLogin(t *testing.T) {
testCases := []struct {
name string
config *Config
mediation protocol.CredentialMediationRequirement
expectedID string
expectedMediation protocol.CredentialMediationRequirement
}{
{
name: "ShouldSucceedConditional",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
mediation: protocol.MediationConditional,
expectedID: "example.com",
expectedMediation: protocol.MediationConditional,
},
{
name: "ShouldSucceedRequired",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
mediation: protocol.MediationRequired,
expectedID: "example.com",
expectedMediation: protocol.MediationRequired,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.config)
require.NoError(t, err)
assertion, session, err := w.BeginDiscoverableMediatedLogin(tc.mediation)
require.NoError(t, err)
require.NotNil(t, assertion)
require.NotNil(t, session)
assert.Equal(t, tc.expectedID, assertion.Response.RelyingPartyID)
assert.Equal(t, tc.expectedMediation, assertion.Mediation)
assert.Empty(t, session.UserID)
})
}
}
func TestBeginMediatedLogin_NoCredentials(t *testing.T) {
config := &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
}
w, err := New(config)
require.NoError(t, err)
user := &defaultUser{
id: []byte("123"),
credentials: nil,
}
assertion, session, err := w.BeginMediatedLogin(user, protocol.MediationDefault)
assert.Nil(t, assertion)
assert.Nil(t, session)
assert.EqualError(t, err, "Found no credentials for user")
}
func TestBeginLogin_Timeouts(t *testing.T) {
testCases := []struct {
name string
config *Config
opts []LoginOption
expectedTimeout int
}{
{
name: "ShouldUseDefaultTimeout",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
expectedTimeout: 300000,
},
{
name: "ShouldUseUVDTimeout",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationDiscouraged,
},
},
expectedTimeout: 120000,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.config)
require.NoError(t, err)
user := &defaultUser{
credentials: []Credential{{}},
}
assertion, _, err := w.BeginLogin(user, tc.opts...)
require.NoError(t, err)
assert.Equal(t, tc.expectedTimeout, assertion.Response.Timeout)
})
}
}
func TestValidateLogin_Errors(t *testing.T) {
testCases := []struct {
name string
user User
session SessionData
err string
}{
{
name: "ShouldFailUserIDMismatch",
user: &defaultUser{
id: []byte("123"),
},
session: SessionData{
UserID: []byte("456"),
},
err: "ID mismatch for User and Session",
},
{
name: "ShouldFailSessionExpired",
user: &defaultUser{
id: []byte("123"),
},
session: SessionData{
UserID: []byte("123"),
Expires: time.Now().Add(-time.Hour),
},
err: "Session has Expired",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w := &WebAuthn{Config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
}}
credential, err := w.ValidateLogin(tc.user, tc.session, nil)
assert.Nil(t, credential)
assert.EqualError(t, err, tc.err)
})
}
}
func TestValidatePasskeyLogin_Errors(t *testing.T) {
testCases := []struct {
name string
handler DiscoverableUserHandler
session SessionData
parsed *protocol.ParsedCredentialAssertionData
err string
}{
{
name: "ShouldFailSessionNotDiscoverable",
session: SessionData{
UserID: []byte("123"),
},
err: "Session was not initiated as a client-side discoverable login",
},
{
name: "ShouldFailSessionExpired",
session: SessionData{
Expires: time.Now().Add(-time.Hour),
},
err: "Session has Expired",
},
{
name: "ShouldFailBlankUserHandle",
session: SessionData{},
parsed: &protocol.ParsedCredentialAssertionData{
ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{
RawID: []byte("cred-id"),
},
Response: protocol.ParsedAssertionResponse{},
},
err: "Client-side Discoverable Assertion was attempted with a blank User Handle",
},
{
name: "ShouldFailHandlerError",
handler: func(rawID, userHandle []byte) (User, error) {
return nil, fmt.Errorf("user not found")
},
session: SessionData{},
parsed: &protocol.ParsedCredentialAssertionData{
ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{
RawID: []byte("cred-id"),
},
Response: protocol.ParsedAssertionResponse{
UserHandle: []byte("user-handle"),
},
},
err: "Failed to lookup Client-side Discoverable Credential: user not found",
},
{
name: "ShouldFailHandlerReturnsNilUser",
handler: func(rawID, userHandle []byte) (User, error) {
return nil, nil
},
session: SessionData{},
parsed: &protocol.ParsedCredentialAssertionData{
ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{
RawID: []byte("cred-id"),
},
Response: protocol.ParsedAssertionResponse{
UserHandle: []byte("user-handle"),
},
},
err: "Failed to lookup Client-side Discoverable Credential: handler returned a nil user",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w := &WebAuthn{Config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
}}
user, credential, err := w.ValidatePasskeyLogin(tc.handler, tc.session, tc.parsed)
assert.Nil(t, user)
assert.Nil(t, credential)
require.EqualError(t, err, tc.err)
})
}
}
func TestValidateDiscoverableLogin_Errors(t *testing.T) {
w := &WebAuthn{Config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
}}
credential, err := w.ValidateDiscoverableLogin(nil, SessionData{UserID: []byte("123")}, nil)
assert.Nil(t, credential)
require.EqualError(t, err, "Session was not initiated as a client-side discoverable login")
}
func TestLoginOptions(t *testing.T) {
testCases := []struct {
name string
opts []LoginOption
have protocol.PublicKeyCredentialRequestOptions
expected protocol.PublicKeyCredentialRequestOptions
}{
{
name: "Empty",
opts: nil,
},
{
name: "AllowedCredentials",
opts: []LoginOption{WithAllowedCredentials([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}})},
expected: protocol.PublicKeyCredentialRequestOptions{
AllowedCredentials: []protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}},
},
},
{
name: "UserVerification",
opts: []LoginOption{WithUserVerification(protocol.VerificationRequired)},
expected: protocol.PublicKeyCredentialRequestOptions{
UserVerification: protocol.VerificationRequired,
},
},
{
name: "PublicKeyCredentialHints",
opts: []LoginOption{WithAssertionPublicKeyCredentialHints([]protocol.PublicKeyCredentialHints{protocol.PublicKeyCredentialHintSecurityKey})},
expected: protocol.PublicKeyCredentialRequestOptions{
Hints: []protocol.PublicKeyCredentialHints{protocol.PublicKeyCredentialHintSecurityKey},
},
},
{
name: "Extensions",
opts: []LoginOption{WithAssertionExtensions(protocol.AuthenticationExtensions{"example": "extension"})},
expected: protocol.PublicKeyCredentialRequestOptions{
Extensions: protocol.AuthenticationExtensions{"example": "extension"},
},
},
{
name: "AppIDExtensionWithoutU2F",
opts: []LoginOption{WithAllowedCredentials([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}}), WithAppIdExtension("example")},
expected: protocol.PublicKeyCredentialRequestOptions{
AllowedCredentials: []protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}},
},
},
{
name: "AppIDExtensionWithU2F",
opts: []LoginOption{WithAllowedCredentials([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, AttestationFormat: string(protocol.AttestationFormatFIDOUniversalSecondFactor), CredentialID: []byte("123")}}), WithAppIdExtension("example")},
expected: protocol.PublicKeyCredentialRequestOptions{
AllowedCredentials: []protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, AttestationFormat: string(protocol.AttestationFormatFIDOUniversalSecondFactor), CredentialID: []byte("123")}},
Extensions: protocol.AuthenticationExtensions{protocol.ExtensionAppID: "example"},
},
},
{
name: "RelyingPartyID",
opts: []LoginOption{WithLoginRelyingPartyID("example.com")},
expected: protocol.PublicKeyCredentialRequestOptions{
RelyingPartyID: "example.com",
},
},
{
name: "Challenge",
opts: []LoginOption{WithChallenge([]byte("00000000000000000000000000000000"))},
expected: protocol.PublicKeyCredentialRequestOptions{
Challenge: []byte("00000000000000000000000000000000"),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := &tc.have
for _, opt := range tc.opts {
opt(opts)
}
assert.Equal(t, tc.expected, *opts)
})
}
}
func TestValidateLogin_Full(t *testing.T) {
parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t)
webauthn := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
},
}
userID := []byte("test-user-id")
t.Run("ShouldSucceedNoAllowedCredentials", func(t *testing.T) {
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
credential, err := webauthn.ValidateLogin(user, session, parsedResponse)
require.NoError(t, err)
require.NotNil(t, credential)
assert.Equal(t, credentialID, credential.ID)
assert.True(t, credential.Flags.UserPresent)
})
t.Run("ShouldSucceedWithAllowedCredentials", func(t *testing.T) {
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
AllowedCredentialIDs: [][]byte{credentialID},
}
credential, err := webauthn.ValidateLogin(user, session, parsedResponse)
require.NoError(t, err)
require.NotNil(t, credential)
})
t.Run("ShouldFailUserHandleMismatch", func(t *testing.T) {
parsedWithUserHandle, _, challengeUH, credIDUH := testLoginSpecVectorNoneES256(t)
parsedWithUserHandle.Response.UserHandle = []byte("wrong-user-handle")
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credIDUH,
PublicKey: credPubKey,
},
},
}
session := SessionData{
UserID: userID,
Challenge: challengeUH,
}
credential, err := webauthn.ValidateLogin(user, session, parsedWithUserHandle)
assert.Nil(t, credential)
assert.EqualError(t, err, "User handle and User ID do not match")
})
t.Run("ShouldFailCredentialNotFound", func(t *testing.T) {
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: []byte("different-credential-id"),
PublicKey: credPubKey,
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
credential, err := webauthn.ValidateLogin(user, session, parsedResponse)
assert.Nil(t, credential)
assert.EqualError(t, err, "Unable to find the credential for the returned credential ID")
})
t.Run("ShouldFailBackupEligibleFlagMismatch", func(t *testing.T) {
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: false,
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
credential, err := webauthn.ValidateLogin(user, session, parsedResponse)
assert.Nil(t, credential)
assert.EqualError(t, err, "Backup Eligible flag inconsistency detected during login validation")
})
t.Run("ShouldFailVerifyError", func(t *testing.T) {
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: []byte("invalid-public-key"),
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
credential, err := webauthn.ValidateLogin(user, session, parsedResponse)
assert.Nil(t, credential)
require.Error(t, err)
})
t.Run("ShouldSucceedWithMDSNilAAGUID", func(t *testing.T) {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
MDS: provider,
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
provider.EXPECT().GetValidateEntryPermitZeroAAGUID(gomock.Any()).Return(true)
credential, err := w.ValidateLogin(user, session, parsedResponse)
require.NoError(t, err)
require.NotNil(t, credential)
})
t.Run("ShouldFailWithMDSGetEntryError", func(t *testing.T) {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
MDS: provider,
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("entry not found"))
credential, err := w.ValidateLogin(user, session, parsedResponse)
assert.Nil(t, credential)
assert.EqualError(t, err, "Failed to validate credential record metadata")
})
t.Run("ShouldSucceedWithMDSAndAAGUID", func(t *testing.T) {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
MDS: provider,
},
}
aaguid := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
AttestationType: "packed",
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
Authenticator: Authenticator{
AAGUID: aaguid,
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
provider.EXPECT().GetValidateEntry(gomock.Any()).Return(false)
credential, err := w.ValidateLogin(user, session, parsedResponse)
require.NoError(t, err)
require.NotNil(t, credential)
})
t.Run("ShouldFailWithMDSInvalidAAGUID", func(t *testing.T) {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
MDS: provider,
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Authenticator: Authenticator{
AAGUID: []byte{0x01, 0x02, 0x03},
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
credential, err := w.ValidateLogin(user, session, parsedResponse)
assert.Nil(t, credential)
assert.EqualError(t, err, "Failed to decode AAGUID")
})
t.Run("ShouldSucceedWithMDSValidateStatusReports", func(t *testing.T) {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
MDS: provider,
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
AttestationType: "basic_full",
AttestationFormat: "packed",
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
UserID: userID,
Challenge: challenge,
}
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{
MetadataStatement: metadata.Statement{
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
},
}, nil)
provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(true)
provider.EXPECT().GetValidateStatus(gomock.Any()).Return(true)
provider.EXPECT().ValidateStatusReports(gomock.Any(), gomock.Any()).Return(nil)
provider.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(false)
credential, err := w.ValidateLogin(user, session, parsedResponse)
require.NoError(t, err)
require.NotNil(t, credential)
})
}
func TestValidatePasskeyLogin_Full(t *testing.T) {
parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t)
userID := []byte("test-user-id")
parsedResponse.Response.UserHandle = userID
t.Run("ShouldSucceed", func(t *testing.T) {
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
Challenge: challenge,
}
handler := func(rawID, userHandle []byte) (User, error) {
return user, nil
}
returnedUser, credential, err := w.ValidatePasskeyLogin(handler, session, parsedResponse)
require.NoError(t, err)
require.NotNil(t, returnedUser)
require.NotNil(t, credential)
assert.Equal(t, credentialID, credential.ID)
})
t.Run("ShouldFailValidateLoginError", func(t *testing.T) {
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: []byte("different-id"),
PublicKey: credPubKey,
},
},
}
session := SessionData{
Challenge: challenge,
}
handler := func(rawID, userHandle []byte) (User, error) {
return user, nil
}
returnedUser, credential, err := w.ValidatePasskeyLogin(handler, session, parsedResponse)
assert.Nil(t, returnedUser)
assert.Nil(t, credential)
require.Error(t, err)
})
}
func TestFinishDiscoverableLogin_Success(t *testing.T) {
parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t)
userID := []byte("test-user-id")
body := map[string]any{
"id": base64.RawURLEncoding.EncodeToString(credentialID),
"rawId": base64.RawURLEncoding.EncodeToString(credentialID),
"type": "public-key",
"response": map[string]any{
"authenticatorData": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.AuthenticatorData),
"clientDataJSON": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.ClientDataJSON),
"signature": base64.RawURLEncoding.EncodeToString(parsedResponse.Response.Signature),
"userHandle": base64.RawURLEncoding.EncodeToString(userID),
},
}
data, err := json.Marshal(body)
require.NoError(t, err)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
Challenge: challenge,
}
handler := func(rawID, userHandle []byte) (User, error) {
return user, nil
}
reqBody := io.NopCloser(bytes.NewReader(data))
httpReq := &http.Request{Body: reqBody}
credential, err := w.FinishDiscoverableLogin(handler, session, httpReq)
require.NoError(t, err)
require.NotNil(t, credential)
assert.Equal(t, credentialID, credential.ID)
}
func TestFinishPasskeyLogin_Success(t *testing.T) {
parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t)
userID := []byte("test-user-id")
body := map[string]any{
"id": base64.RawURLEncoding.EncodeToString(credentialID),
"rawId": base64.RawURLEncoding.EncodeToString(credentialID),
"type": "public-key",
"response": map[string]any{
"authenticatorData": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.AuthenticatorData),
"clientDataJSON": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.ClientDataJSON),
"signature": base64.RawURLEncoding.EncodeToString(parsedResponse.Response.Signature),
"userHandle": base64.RawURLEncoding.EncodeToString(userID),
},
}
data, err := json.Marshal(body)
require.NoError(t, err)
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
},
}
user := &defaultUser{
id: userID,
credentials: []Credential{
{
ID: credentialID,
PublicKey: credPubKey,
Flags: CredentialFlags{
UserPresent: true,
BackupEligible: true,
},
},
},
}
session := SessionData{
Challenge: challenge,
}
handler := func(rawID, userHandle []byte) (User, error) {
return user, nil
}
reqBody := io.NopCloser(bytes.NewReader(data))
httpReq := &http.Request{Body: reqBody}
returnedUser, credential, err := w.FinishPasskeyLogin(handler, session, httpReq)
require.NoError(t, err)
require.NotNil(t, returnedUser)
require.NotNil(t, credential)
assert.Equal(t, credentialID, credential.ID)
}
// testLoginSpecVectorNoneES256 returns the spec test vector data for NoneES256 authentication.
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-none-es256
func testLoginSpecVectorNoneES256(t *testing.T) (parsedResponse *protocol.ParsedCredentialAssertionData, credPubKey []byte, challenge string, credentialID []byte) {
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(testDecodeHex(t, challengeHex))
id := base64.RawURLEncoding.EncodeToString(credentialID)
authenticatorData := base64.RawURLEncoding.EncodeToString(testDecodeHex(t, authenticatorDataHex))
clientDataJSON := base64.RawURLEncoding.EncodeToString(testDecodeHex(t, clientDataJSONHex))
signature := base64.RawURLEncoding.EncodeToString(testDecodeHex(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)
parsedResponse, err = protocol.ParseCredentialRequestResponseBytes(data)
require.NoError(t, err)
return parsedResponse, credPubKey, challenge, credentialID
}
func testDecodeHex(t *testing.T, s string) []byte {
t.Helper()
data, err := hex.DecodeString(s)
require.NoError(t, err)
return data
}
+230
View File
@@ -0,0 +1,230 @@
package webauthn
import (
"bytes"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/go-webauthn/webauthn/protocol"
)
// RegistrationOption is a functional option that modifies the [protocol.PublicKeyCredentialCreationOptions] sent
// to the client during a registration ceremony. Use the With* functions in this package (i.e.
// [WithConveyancePreference], [WithExclusions], [WithAuthenticatorSelection]) to create registration options.
type RegistrationOption func(*protocol.PublicKeyCredentialCreationOptions)
// BeginRegistration generates a new set of registration data to be sent to the client and authenticator. To set a
// conditional mediation requirement for the registration see [WebAuthn.BeginMediatedRegistration].
func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOption) (creation *protocol.CredentialCreation, session *SessionData, err error) {
return webauthn.BeginMediatedRegistration(user, protocol.MediationDefault, opts...)
}
// BeginMediatedRegistration is similar to [WebAuthn.BeginRegistration] however it also allows specifying a credential
// mediation requirement.
func (webauthn *WebAuthn) BeginMediatedRegistration(user User, mediation protocol.CredentialMediationRequirement, opts ...RegistrationOption) (creation *protocol.CredentialCreation, session *SessionData, err error) {
if err = webauthn.Config.validate(); err != nil {
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
}
var (
challenge protocol.URLEncodedBase64
entityUserID any
)
if challenge, err = protocol.CreateChallenge(); err != nil {
return nil, nil, err
}
if webauthn.Config.EncodeUserIDAsString {
entityUserID = string(user.WebAuthnID())
} else {
entityUserID = protocol.URLEncodedBase64(user.WebAuthnID())
}
entityUser := protocol.UserEntity{
ID: entityUserID,
DisplayName: user.WebAuthnDisplayName(),
CredentialEntity: protocol.CredentialEntity{
Name: user.WebAuthnName(),
},
}
entityRelyingParty := protocol.RelyingPartyEntity{
ID: webauthn.Config.RPID,
CredentialEntity: protocol.CredentialEntity{
Name: webauthn.Config.RPDisplayName,
},
}
credentialParams := CredentialParametersDefault()
creation = &protocol.CredentialCreation{
Response: protocol.PublicKeyCredentialCreationOptions{
RelyingParty: entityRelyingParty,
User: entityUser,
Challenge: challenge,
Parameters: credentialParams,
AuthenticatorSelection: webauthn.Config.AuthenticatorSelection,
Attestation: webauthn.Config.AttestationPreference,
},
Mediation: mediation,
}
for _, opt := range opts {
opt(&creation.Response)
}
if len(creation.Response.RelyingParty.ID) == 0 {
return nil, nil, fmt.Errorf("error generating credential creation: the relying party id must be provided via the configuration or a functional option for a creation")
} else if err = protocol.ValidateRPID(creation.Response.RelyingParty.ID); err != nil {
return nil, nil, fmt.Errorf("error generating credential creation: the relying party id failed to validate as it's not a valid domain string with error: %w", err)
}
if len(creation.Response.RelyingParty.Name) == 0 {
return nil, nil, fmt.Errorf("error generating credential creation: the relying party display name must be provided via the configuration or a functional option for a creation")
}
if len(creation.Response.Challenge) < protocol.MinimumChallengeLength {
return nil, nil, fmt.Errorf("error generating credential creation: the challenge must be at least 16 bytes")
}
if creation.Response.Timeout == 0 {
switch creation.Response.AuthenticatorSelection.UserVerification {
case protocol.VerificationDiscouraged:
creation.Response.Timeout = int(webauthn.Config.Timeouts.Registration.TimeoutUVD.Milliseconds())
default:
creation.Response.Timeout = int(webauthn.Config.Timeouts.Registration.Timeout.Milliseconds())
}
}
session = &SessionData{
Challenge: creation.Response.Challenge.String(),
RelyingPartyID: creation.Response.RelyingParty.ID,
UserID: user.WebAuthnID(),
UserVerification: creation.Response.AuthenticatorSelection.UserVerification,
CredParams: creation.Response.Parameters,
Mediation: creation.Mediation,
}
if webauthn.Config.Timeouts.Registration.Enforce {
session.Expires = time.Now().Add(time.Millisecond * time.Duration(creation.Response.Timeout))
}
return creation, session, nil
}
// FinishRegistration takes the response from the authenticator and client and verify the credential against the user's
// credentials and session data.
//
// As with all Finish functions this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialCreationResponseBody] or [protocol.ParseCredentialCreationResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialCreationData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [User], [*SessionData], and
// [*protocol.ParsedCredentialCreationData] can then be used with the [WebAuthn.CreateCredential] function.
func (webauthn *WebAuthn) FinishRegistration(user User, session SessionData, request *http.Request) (credential *Credential, err error) {
parsedResponse, err := protocol.ParseCredentialCreationResponse(request)
if err != nil {
return nil, err
}
return webauthn.CreateCredential(user, session, parsedResponse)
}
// CreateCredential verifies a parsed response against the user's credentials and session data.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialCreationData] and
// you're using net/http then you can use [WebAuthn.FinishRegistration] instead.
func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parsedResponse *protocol.ParsedCredentialCreationData) (credential *Credential, err error) {
if !bytes.Equal(user.WebAuthnID(), session.UserID) {
return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
shouldVerifyUserPresence := session.Mediation != protocol.MediationConditional
var clientDataHash []byte
if clientDataHash, err = parsedResponse.Verify(session.Challenge, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.RPAllowCrossOrigin, shouldVerifyUser, shouldVerifyUserPresence, webauthn.Config.MDS, session.CredParams); err != nil {
return nil, err
}
if credential, err = NewCredential(clientDataHash, parsedResponse); err != nil {
return nil, err
}
if err = ValidateFilteredCredential(credential, webauthn.Config.Filtering); err != nil {
return nil, err
}
return credential, nil
}
// ValidateFilteredCredential applies the supplied [FilteringConfig] to a freshly-created [Credential]
// and returns a non-nil error when the credential violates any configured filtering rule (backup-eligibility
// prohibition, permitted-AAGUID allow-list, prohibited-AAGUID deny-list). A nil filtering argument is treated
// as "no filtering" and the function returns nil.
//
// The zero AAGUID ([uuid.Nil]) is never excluded by the permitted list, preserving the documented
// [FilteringConfig] contract for authenticators that report no AAGUID.
//
// This function is invoked automatically by [WebAuthn.CreateCredential] using the [Config.Filtering] value;
// relying parties may also call it directly (e.g. to pre-validate a credential before persistence) with any
// FilteringConfig value of their choosing.
//
// The credential argument must be non-nil.
func ValidateFilteredCredential(credential *Credential, filtering *FilteringConfig) (err error) {
if filtering == nil {
return nil
}
if credential == nil {
return protocol.ErrBadRequest.WithInfo("Credential is nil")
}
if filtering.ProhibitBackupEligibility && credential.Flags.BackupEligible {
return protocol.ErrPolicyRestriction.WithInfo("Credential is Backup Eligible")
}
var aaguid uuid.UUID
if err = aaguid.UnmarshalBinary(credential.Authenticator.AAGUID); err != nil {
return protocol.ErrBadRequest.WithInfo("The AAGUID of the credential is not a valid UUID")
}
if len(filtering.PermittedAAGUIDs) != 0 {
var success = false
if aaguid == uuid.Nil {
success = true
} else {
for _, permitted := range filtering.PermittedAAGUIDs {
if permitted == aaguid {
success = true
break
}
}
}
if !success {
return protocol.ErrPolicyRestriction.WithInfo("Credential has an AAGUID which is not permitted")
}
}
if len(filtering.ProhibitedAAGUIDs) != 0 {
for _, prohibited := range filtering.ProhibitedAAGUIDs {
if prohibited == aaguid {
return protocol.ErrPolicyRestriction.WithInfo("Credential has an AAGUID which is prohibited")
}
}
}
return nil
}
@@ -0,0 +1,121 @@
package webauthn
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/protocol/webauthncose"
)
// CredentialParametersDefault returns the default list of acceptable credential algorithms. This includes ES256,
// ES384, ES512, RS256, RS384, RS512, PS256, PS384, PS512, and EdDSA. The order indicates preference.
func CredentialParametersDefault() []protocol.CredentialParameter {
return []protocol.CredentialParameter{
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgEdDSA,
},
}
}
// CredentialParametersRecommendedL3 returns the WebAuthn Level 3 recommended credential algorithm list: EdDSA,
// ES256, and RS256 (in that order). This is the minimal set recommended by the specification for broad
// authenticator compatibility.
func CredentialParametersRecommendedL3() []protocol.CredentialParameter {
return []protocol.CredentialParameter{
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgEdDSA,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS256,
},
}
}
// CredentialParametersExtendedL3 returns the WebAuthn Level 3 recommended credential algorithm list (EdDSA, ES256,
// RS256) extended with all other algorithms supported by this library (ES384, ES512, RS384, RS512, PS256, PS384,
// PS512). The Level 3 recommended algorithms appear first to indicate preference.
func CredentialParametersExtendedL3() []protocol.CredentialParameter {
return []protocol.CredentialParameter{
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgEdDSA,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS512,
},
}
}
+132
View File
@@ -0,0 +1,132 @@
package webauthn
import "github.com/go-webauthn/webauthn/protocol"
// WithCredentialParameters adjusts the credential parameters in the registration options.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-pubkeycredparams)
func WithCredentialParameters(credentialParams []protocol.CredentialParameter) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Parameters = credentialParams
}
}
// WithExclusions adjusts the non-default parameters regarding credentials to exclude from registration.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-excludecredentials)
func WithExclusions(excludeList []protocol.CredentialDescriptor) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.CredentialExcludeList = excludeList
}
}
// WithAuthenticatorSelection adjusts the non-default parameters regarding the authenticator to select during
// registration.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-authenticatorselection)
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dictdef-authenticatorselectioncriteria)
func WithAuthenticatorSelection(authenticatorSelection protocol.AuthenticatorSelection) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.AuthenticatorSelection = authenticatorSelection
}
}
// WithResidentKeyRequirement sets both the resident key and require resident key protocol options.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-authenticatorselection)
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dictdef-authenticatorselectioncriteria)
func WithResidentKeyRequirement(requirement protocol.ResidentKeyRequirement) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.AuthenticatorSelection.ResidentKey = requirement
switch requirement {
case protocol.ResidentKeyRequirementRequired:
cco.AuthenticatorSelection.RequireResidentKey = protocol.ResidentKeyRequired()
default:
cco.AuthenticatorSelection.RequireResidentKey = protocol.ResidentKeyNotRequired()
}
}
}
// WithPublicKeyCredentialHints adjusts the non-default hints for credential types to select during registration.
//
// WebAuthn Level 3.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-hints)
func WithPublicKeyCredentialHints(hints []protocol.PublicKeyCredentialHints) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Hints = hints
}
}
// WithConveyancePreference adjusts the non-default parameters regarding whether the authenticator should attest to the
// credential.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-attestation)
func WithConveyancePreference(preference protocol.ConveyancePreference) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Attestation = preference
}
}
// WithAttestationFormats adjusts the non-default formats for credential types to select during registration.
//
// WebAuthn Level 3.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-attestationformats)
func WithAttestationFormats(formats []protocol.AttestationFormat) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.AttestationFormats = formats
}
}
// WithExtensions adjusts the extension parameter in the registration options.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions)
//
// Specification: §9. Extensions (https://www.w3.org/TR/webauthn/#webauthn-extensions)
func WithExtensions(extension protocol.AuthenticationExtensions) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Extensions = extension
}
}
// WithAppIdExcludeExtension automatically includes the specified appid if the CredentialExcludeList contains a credential
// with the type `fido-u2f`.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions)
//
// Specification: §9. Extensions (https://www.w3.org/TR/webauthn/#webauthn-extensions)
//
// Specification: §10.1.2. FIDO AppID Exclusion Extension (https://www.w3.org/TR/webauthn/#sctn-appid-exclude-extension)
func WithAppIdExcludeExtension(appid string) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
for _, credential := range cco.CredentialExcludeList {
if credential.AttestationFormat == string(protocol.AttestationFormatFIDOUniversalSecondFactor) {
if cco.Extensions == nil {
cco.Extensions = map[string]any{}
}
cco.Extensions[protocol.ExtensionAppIDExclude] = appid
break
}
}
}
}
// WithRegistrationRelyingPartyID sets the relying party id for the registration.
func WithRegistrationRelyingPartyID(id string) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.RelyingParty.ID = id
}
}
// WithRegistrationRelyingPartyName sets the relying party name for the registration.
func WithRegistrationRelyingPartyName(name string) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.RelyingParty.Name = name
}
}
+1048
View File
@@ -0,0 +1,1048 @@
package webauthn
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/go-webauthn/webauthn/metadata"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/protocol/webauthncose"
"github.com/go-webauthn/webauthn/testing/mocks"
)
func TestWithRegistrationRelyingPartyID(t *testing.T) {
testCases := []struct {
name string
have *Config
opts []RegistrationOption
expectedID string
expectedName string
err string
}{
{
name: "OptionDefinedInConfig",
have: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: nil,
expectedID: "example.com",
expectedName: "Test Display Name",
},
{
name: "OptionDefinedInConfigAndOpts",
have: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: []RegistrationOption{WithRegistrationRelyingPartyID("a.example.com"), WithRegistrationRelyingPartyName("Test Display Name2")},
expectedID: "a.example.com",
expectedName: "Test Display Name2",
},
{
name: "OptionDefinedInConfigWithNoErrAndInOptsWithError",
have: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
opts: []RegistrationOption{WithRegistrationRelyingPartyID("---::~!!~@#M!@OIK#N!@IOK@@@@@@@@@@"), WithRegistrationRelyingPartyName("Test Display Name2")},
err: "error generating credential creation: the relying party id failed to validate as it's not a valid domain string with error: parse \"---::~!!~@\": first path segment in URL cannot contain colon",
},
{
name: "OptionDefinedInOpts",
have: &Config{
RPOrigins: []string{"example.com"},
},
opts: []RegistrationOption{WithRegistrationRelyingPartyID("example.com"), WithRegistrationRelyingPartyName("Test Display Name")},
expectedID: "example.com",
expectedName: "Test Display Name",
},
{
name: "OptionDisplayNameNotDefined",
have: &Config{
RPOrigins: []string{"https://example.com"},
},
opts: []RegistrationOption{WithRegistrationRelyingPartyID("example.com")},
err: "error generating credential creation: the relying party display name must be provided via the configuration or a functional option for a creation",
},
{
name: "OptionIDNotDefined",
have: &Config{
RPOrigins: []string{"https://example.com"},
},
opts: []RegistrationOption{WithRegistrationRelyingPartyName("Test Display Name")},
err: "error generating credential creation: the relying party id must be provided via the configuration or a functional option for a creation",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.have)
assert.NoError(t, err)
user := &defaultUser{}
creation, _, err := w.BeginRegistration(user, tc.opts...)
if tc.err != "" {
assert.EqualError(t, err, tc.err)
} else {
assert.NoError(t, err)
require.NotNil(t, creation)
assert.Equal(t, tc.expectedID, creation.Response.RelyingParty.ID)
assert.Equal(t, tc.expectedName, creation.Response.RelyingParty.Name)
}
})
}
}
func TestRegistration_FinishRegistrationFailure(t *testing.T) {
user := &defaultUser{
id: []byte("123"),
}
session := SessionData{
UserID: []byte("ABC"),
}
webauthn := &WebAuthn{}
credential, err := webauthn.FinishRegistration(user, session, nil)
if err == nil {
t.Errorf("FinishRegistration() error = nil, want %v", protocol.ErrBadRequest.Type)
}
if credential != nil {
t.Errorf("FinishRegistration() credential = %v, want nil", credential)
}
}
func TestEntityEncoding(t *testing.T) {
testCases := []struct {
name string
b64 bool
have, expected string
}{
{"ShouldEncodeBase64", true, "abc", `{"name":"","displayName":"","id":"YWJj"}`},
{"ShouldEncodeString", false, "abc", `{"name":"","displayName":"","id":"abc"}`},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
entityUser := protocol.UserEntity{}
if tc.b64 {
entityUser.ID = protocol.URLEncodedBase64(tc.have)
} else {
entityUser.ID = tc.have
}
data, err := json.Marshal(entityUser)
assert.NoError(t, err)
assert.Equal(t, tc.expected, string(data))
})
}
}
func TestCreateCredential_Errors(t *testing.T) {
testCases := []struct {
name string
user User
session SessionData
err string
}{
{
name: "ShouldFailUserIDMismatch",
user: &defaultUser{
id: []byte("123"),
},
session: SessionData{
UserID: []byte("456"),
},
err: "ID mismatch for User and Session",
},
{
name: "ShouldFailSessionExpired",
user: &defaultUser{
id: []byte("123"),
},
session: SessionData{
UserID: []byte("123"),
Expires: time.Now().Add(-time.Hour),
},
err: "Session has Expired",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w := &WebAuthn{Config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
}}
credential, err := w.CreateCredential(tc.user, tc.session, nil)
assert.Nil(t, credential)
assert.EqualError(t, err, tc.err)
})
}
}
func TestBeginRegistration_Timeouts(t *testing.T) {
testCases := []struct {
name string
config *Config
opts []RegistrationOption
expectedTimeout int
}{
{
name: "ShouldUseDefaultTimeout",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
},
expectedTimeout: 300000,
},
{
name: "ShouldUseUVDTimeout",
config: &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationDiscouraged,
},
},
expectedTimeout: 120000,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.config)
require.NoError(t, err)
user := &defaultUser{id: []byte("123")}
creation, _, err := w.BeginRegistration(user, tc.opts...)
require.NoError(t, err)
assert.Equal(t, tc.expectedTimeout, creation.Response.Timeout)
})
}
}
func TestBeginRegistration_EncodeUserIDAsString(t *testing.T) {
testCases := []struct {
name string
encodeAsString bool
userID string
expectedIDType string
}{
{
name: "ShouldEncodeAsBase64",
encodeAsString: false,
userID: "testuser",
expectedIDType: "protocol.URLEncodedBase64",
},
{
name: "ShouldEncodeAsString",
encodeAsString: true,
userID: "testuser",
expectedIDType: "string",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config := &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
EncodeUserIDAsString: tc.encodeAsString,
}
w, err := New(config)
require.NoError(t, err)
user := &defaultUser{id: []byte(tc.userID)}
creation, _, err := w.BeginRegistration(user)
require.NoError(t, err)
if tc.encodeAsString {
_, ok := creation.Response.User.ID.(string)
assert.True(t, ok)
} else {
_, ok := creation.Response.User.ID.(protocol.URLEncodedBase64)
assert.True(t, ok)
}
})
}
}
func TestBeginMediatedRegistration_ChallengeLength(t *testing.T) {
// withTestChallenge is a test-only RegistrationOption that overrides the generated challenge. It mirrors the
// WithChallenge login option; no equivalent is exported for registration, so we synthesise one here to exercise
// the minimum-length guard.
withTestChallenge := func(challenge []byte) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Challenge = challenge
}
}
config := &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
}
testCases := []struct {
name string
opts []RegistrationOption
expLen int
err string
}{
{
name: "ShouldSucceedWithDefaultChallenge",
opts: nil,
expLen: 32,
},
{
name: "ShouldFailNilChallenge",
opts: []RegistrationOption{withTestChallenge(nil)},
err: "error generating credential creation: the challenge must be at least 16 bytes",
},
{
name: "ShouldFailEmptyChallenge",
opts: []RegistrationOption{withTestChallenge([]byte{})},
err: "error generating credential creation: the challenge must be at least 16 bytes",
},
{
name: "ShouldFailEightByteChallenge",
opts: []RegistrationOption{withTestChallenge(bytes.Repeat([]byte{0xab}, 8))},
err: "error generating credential creation: the challenge must be at least 16 bytes",
},
{
name: "ShouldFailFifteenByteChallenge",
opts: []RegistrationOption{withTestChallenge(bytes.Repeat([]byte{0xab}, 15))},
err: "error generating credential creation: the challenge must be at least 16 bytes",
},
{
name: "ShouldSucceedSixteenByteChallenge",
opts: []RegistrationOption{withTestChallenge(bytes.Repeat([]byte{0xab}, 16))},
expLen: 16,
},
{
name: "ShouldSucceedThirtyTwoByteChallenge",
opts: []RegistrationOption{withTestChallenge(bytes.Repeat([]byte{0xab}, 32))},
expLen: 32,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(config)
require.NoError(t, err)
user := &defaultUser{id: []byte("123")}
creation, session, err := w.BeginMediatedRegistration(user, protocol.MediationDefault, tc.opts...)
if tc.err != "" {
assert.EqualError(t, err, tc.err)
assert.Nil(t, creation)
assert.Nil(t, session)
return
}
require.NoError(t, err)
require.NotNil(t, creation)
require.NotNil(t, session)
assert.Len(t, []byte(creation.Response.Challenge), tc.expLen)
assert.NotEmpty(t, session.Challenge)
assert.Equal(t, creation.Response.Challenge.String(), session.Challenge)
})
}
}
func TestBeginMediatedRegistration_EnforceTimeout(t *testing.T) {
config := &Config{
RPID: "example.com",
RPDisplayName: "Test Display Name",
RPOrigins: []string{"https://example.com"},
Timeouts: TimeoutsConfig{
Registration: TimeoutConfig{
Enforce: true,
Timeout: time.Second * 60,
},
},
}
w, err := New(config)
require.NoError(t, err)
user := &defaultUser{id: []byte("123")}
_, session, err := w.BeginMediatedRegistration(user, protocol.MediationConditional)
require.NoError(t, err)
assert.False(t, session.Expires.IsZero())
assert.Equal(t, protocol.MediationConditional, session.Mediation)
}
func TestRegistrationOptions(t *testing.T) {
tv := true
fv := false
testCases := []struct {
name string
opts []RegistrationOption
have protocol.PublicKeyCredentialCreationOptions
expected protocol.PublicKeyCredentialCreationOptions
}{
{
name: "Empty",
opts: nil,
},
{
name: "CredentialParametersDefault",
opts: []RegistrationOption{WithCredentialParameters(CredentialParametersDefault())},
expected: protocol.PublicKeyCredentialCreationOptions{
Parameters: CredentialParametersDefault(),
},
},
{
name: "CredentialParametersL3Extended",
opts: []RegistrationOption{WithCredentialParameters(CredentialParametersExtendedL3())},
expected: protocol.PublicKeyCredentialCreationOptions{
Parameters: CredentialParametersExtendedL3(),
},
},
{
name: "CredentialParametersL3Recommended",
opts: []RegistrationOption{WithCredentialParameters(CredentialParametersRecommendedL3())},
expected: protocol.PublicKeyCredentialCreationOptions{
Parameters: CredentialParametersRecommendedL3(),
},
},
{
name: "Exclusions",
opts: []RegistrationOption{WithExclusions([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123"), Transport: []protocol.AuthenticatorTransport{protocol.Hybrid}}})},
expected: protocol.PublicKeyCredentialCreationOptions{
CredentialExcludeList: []protocol.CredentialDescriptor{
{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123"), Transport: []protocol.AuthenticatorTransport{protocol.Hybrid}},
},
},
},
{
name: "Selections",
opts: []RegistrationOption{WithAuthenticatorSelection(protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.CrossPlatform,
RequireResidentKey: &tv,
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
})},
expected: protocol.PublicKeyCredentialCreationOptions{
AuthenticatorSelection: protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.CrossPlatform,
RequireResidentKey: &tv,
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
},
},
{
name: "ResidentKeyRequirementRequired",
opts: []RegistrationOption{WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)},
expected: protocol.PublicKeyCredentialCreationOptions{
AuthenticatorSelection: protocol.AuthenticatorSelection{
RequireResidentKey: &tv,
ResidentKey: protocol.ResidentKeyRequirementRequired,
},
},
},
{
name: "ResidentKeyRequirementPreferred",
opts: []RegistrationOption{WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred)},
expected: protocol.PublicKeyCredentialCreationOptions{
AuthenticatorSelection: protocol.AuthenticatorSelection{
RequireResidentKey: &fv,
ResidentKey: protocol.ResidentKeyRequirementPreferred,
},
},
},
{
name: "PublicKeyCredentialHints",
opts: []RegistrationOption{WithPublicKeyCredentialHints([]protocol.PublicKeyCredentialHints{
protocol.PublicKeyCredentialHintSecurityKey,
})},
expected: protocol.PublicKeyCredentialCreationOptions{
Hints: []protocol.PublicKeyCredentialHints{
protocol.PublicKeyCredentialHintSecurityKey,
},
},
},
{
name: "ConveyancePreference",
opts: []RegistrationOption{WithConveyancePreference(protocol.PreferEnterpriseAttestation)},
expected: protocol.PublicKeyCredentialCreationOptions{
Attestation: protocol.PreferEnterpriseAttestation,
},
},
{
name: "AttestationFormats",
opts: []RegistrationOption{WithAttestationFormats([]protocol.AttestationFormat{protocol.AttestationFormatPacked})},
expected: protocol.PublicKeyCredentialCreationOptions{
AttestationFormats: []protocol.AttestationFormat{protocol.AttestationFormatPacked},
},
},
{
name: "Extensions",
opts: []RegistrationOption{WithExtensions(map[string]any{"appID": "example"})},
expected: protocol.PublicKeyCredentialCreationOptions{
Extensions: map[string]any{"appID": "example"},
},
},
{
name: "AppIDExcludeExtensionWithNoExclusions",
opts: []RegistrationOption{WithAppIdExcludeExtension("apple")},
expected: protocol.PublicKeyCredentialCreationOptions{},
},
{
name: "AppIDExcludeExtensionWithExclusions",
opts: []RegistrationOption{WithExclusions([]protocol.CredentialDescriptor{
{Type: protocol.PublicKeyCredentialType, AttestationFormat: string(protocol.AttestationFormatFIDOUniversalSecondFactor), CredentialID: []byte("123"), Transport: []protocol.AuthenticatorTransport{protocol.Hybrid}},
}), WithAppIdExcludeExtension("apple")},
expected: protocol.PublicKeyCredentialCreationOptions{
CredentialExcludeList: []protocol.CredentialDescriptor{
{Type: protocol.PublicKeyCredentialType, AttestationFormat: string(protocol.AttestationFormatFIDOUniversalSecondFactor), CredentialID: []byte("123"), Transport: []protocol.AuthenticatorTransport{protocol.Hybrid}},
},
Extensions: map[string]any{"appidExclude": "apple"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := &tc.have
for _, opt := range tc.opts {
opt(opts)
}
assert.Equal(t, tc.expected, *opts)
})
}
}
func TestCreateCredential_Full(t *testing.T) {
credParams := []protocol.CredentialParameter{{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}}
testCases := []struct {
name string
have struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}
expected struct {
attestationType string
attestationFormat string
err string
}
}{
{
name: "ShouldSucceedNoneFormat",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorNoneES256,
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
attestationType: "none",
attestationFormat: "none",
},
},
{
name: "ShouldSucceedPackedSelfFormat",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorPackedSelfES256,
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
attestationType: "basic_surrogate",
attestationFormat: "packed",
},
},
{
name: "ShouldSucceedWithMDS",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorPackedSelfES256,
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
provider.EXPECT().GetValidateEntry(gomock.Any()).Return(false)
},
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
attestationType: "basic_surrogate",
attestationFormat: "packed",
},
},
{
name: "ShouldFailWithMDSGetEntryError",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorPackedSelfES256,
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("entry lookup failed"))
},
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
err: "Failed to validate authenticator metadata for Authenticator Attestation GUID 'df850e09-db6a-fbdf-ab51-697791506cfc'. Error occurred retrieving the metadata entry: entry lookup failed",
},
},
{
name: "ShouldSucceedWithMDSValidateStatusReports",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorPackedSelfES256,
setup: func(t *testing.T, provider *mocks.MockMetadataProvider) {
t.Helper()
provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{
MetadataStatement: metadata.Statement{
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicSurrogate},
},
}, nil)
provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(true)
provider.EXPECT().GetValidateStatus(gomock.Any()).Return(true)
provider.EXPECT().ValidateStatusReports(gomock.Any(), gomock.Any()).Return(nil)
provider.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(false)
},
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
attestationType: "basic_surrogate",
attestationFormat: "packed",
},
},
{
name: "ShouldFailVerifyError",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorNoneES256,
challenge: "wrong-challenge",
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
err: "Error validating challenge",
},
},
{
name: "ShouldSucceedMediationConditional",
have: struct {
specVector func(t *testing.T) (body []byte, challenge string, credentialID []byte)
challenge string
mediation protocol.CredentialMediationRequirement
setup func(t *testing.T, provider *mocks.MockMetadataProvider)
}{
specVector: testRegistrationSpecVectorNoneES256,
mediation: protocol.MediationConditional,
},
expected: struct {
attestationType string
attestationFormat string
err string
}{
attestationType: "none",
attestationFormat: "none",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body, challenge, credentialID := tc.have.specVector(t)
parsedResponse, err := protocol.ParseCredentialCreationResponseBytes(body)
require.NoError(t, err)
if tc.have.challenge != "" {
challenge = tc.have.challenge
}
userID := []byte("test-user-id")
config := &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
}
if tc.have.setup != nil {
ctrl := gomock.NewController(t)
provider := mocks.NewMockMetadataProvider(ctrl)
tc.have.setup(t, provider)
config.MDS = provider
}
w := &WebAuthn{Config: config}
session := SessionData{
Challenge: challenge,
UserID: userID,
CredParams: credParams,
Mediation: tc.have.mediation,
}
user := &defaultUser{id: userID}
credential, err := w.CreateCredential(user, session, parsedResponse)
if tc.expected.err != "" {
assert.Nil(t, credential)
assert.EqualError(t, err, tc.expected.err)
} else {
assert.NoError(t, err)
require.NotNil(t, credential)
assert.Equal(t, credentialID, credential.ID)
assert.Equal(t, tc.expected.attestationType, credential.AttestationType)
assert.Equal(t, tc.expected.attestationFormat, credential.AttestationFormat)
}
})
}
}
func TestFinishRegistration_Success(t *testing.T) {
body, challenge, credentialID := testRegistrationSpecVectorNoneES256(t)
credParams := []protocol.CredentialParameter{{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}}
userID := []byte("test-user-id")
w := &WebAuthn{
Config: &Config{
RPID: "example.org",
RPOrigins: []string{"https://example.org"},
},
}
session := SessionData{
Challenge: challenge,
UserID: userID,
CredParams: credParams,
}
user := &defaultUser{id: userID}
reqBody := io.NopCloser(bytes.NewReader(body))
httpReq := &http.Request{Body: reqBody}
credential, err := w.FinishRegistration(user, session, httpReq)
require.NoError(t, err)
require.NotNil(t, credential)
assert.Equal(t, credentialID, credential.ID)
assert.Equal(t, "none", credential.AttestationType)
assert.Equal(t, "none", credential.AttestationFormat)
}
func TestValidateFilteredCredential(t *testing.T) {
aaguidA := uuid.MustParse("00000000-0000-0000-0000-00000000000a")
aaguidB := uuid.MustParse("00000000-0000-0000-0000-00000000000b")
aaguidC := uuid.MustParse("00000000-0000-0000-0000-00000000000c")
credentialWith := func(aaguid uuid.UUID, backupEligible bool) *Credential {
b, _ := aaguid.MarshalBinary()
flags := protocol.FlagUserPresent
if backupEligible {
flags |= protocol.FlagBackupEligible
}
return &Credential{
Flags: NewCredentialFlags(flags),
Authenticator: Authenticator{AAGUID: b},
}
}
testCases := []struct {
name string
filtering *FilteringConfig
credential *Credential
err string
}{
{
name: "ShouldAllowWhenFilteringNil",
filtering: nil,
credential: credentialWith(aaguidA, true),
},
{
name: "ShouldAllowNilCredentialWhenFilteringNil",
filtering: nil,
credential: nil,
},
{
name: "ShouldRejectNilCredentialWhenFilteringSet",
filtering: &FilteringConfig{},
credential: nil,
err: "Error reading the request data",
},
{
name: "ShouldRejectNilCredentialWhenFilteringHasPermittedList",
filtering: &FilteringConfig{PermittedAAGUIDs: []uuid.UUID{aaguidA}},
credential: nil,
err: "Error reading the request data",
},
{
name: "ShouldAllowWhenFilteringAllZeroValues",
filtering: &FilteringConfig{},
credential: credentialWith(aaguidA, true),
},
{
name: "ShouldRejectBackupEligibleWhenProhibited",
filtering: &FilteringConfig{ProhibitBackupEligibility: true},
credential: credentialWith(aaguidA, true),
err: "Policy restriction prevented the operation from completing",
},
{
name: "ShouldAllowNonBackupEligibleWhenProhibited",
filtering: &FilteringConfig{ProhibitBackupEligibility: true},
credential: credentialWith(aaguidA, false),
},
{
name: "ShouldAllowBackupEligibleWhenNotProhibited",
filtering: &FilteringConfig{ProhibitBackupEligibility: false},
credential: credentialWith(aaguidA, true),
},
{
name: "ShouldRejectMalformedAAGUID",
filtering: &FilteringConfig{},
credential: &Credential{
Authenticator: Authenticator{AAGUID: []byte{0x01, 0x02, 0x03}},
},
err: "Error reading the request data",
},
{
name: "ShouldAllowPermittedAAGUID",
filtering: &FilteringConfig{PermittedAAGUIDs: []uuid.UUID{aaguidA, aaguidB}},
credential: credentialWith(aaguidA, false),
},
{
name: "ShouldRejectAAGUIDNotInPermitList",
filtering: &FilteringConfig{PermittedAAGUIDs: []uuid.UUID{aaguidA, aaguidB}},
credential: credentialWith(aaguidC, false),
err: "Policy restriction prevented the operation from completing",
},
{
name: "ShouldAllowZeroAAGUIDEvenWhenNotInPermitList",
filtering: &FilteringConfig{PermittedAAGUIDs: []uuid.UUID{aaguidA}},
credential: credentialWith(uuid.Nil, false),
},
{
name: "ShouldAllowZeroAAGUIDWhenExplicitlyInPermitList",
filtering: &FilteringConfig{PermittedAAGUIDs: []uuid.UUID{uuid.Nil, aaguidA}},
credential: credentialWith(uuid.Nil, false),
},
{
name: "ShouldRejectProhibitedAAGUID",
filtering: &FilteringConfig{ProhibitedAAGUIDs: []uuid.UUID{aaguidB, aaguidC}},
credential: credentialWith(aaguidB, false),
err: "Policy restriction prevented the operation from completing",
},
{
name: "ShouldAllowAAGUIDNotInProhibitList",
filtering: &FilteringConfig{ProhibitedAAGUIDs: []uuid.UUID{aaguidB, aaguidC}},
credential: credentialWith(aaguidA, false),
},
{
name: "ShouldApplyBothPermittedAndProhibitedListsWhenSet",
filtering: &FilteringConfig{
PermittedAAGUIDs: []uuid.UUID{aaguidA, aaguidB},
ProhibitedAAGUIDs: []uuid.UUID{aaguidB},
},
credential: credentialWith(aaguidB, false),
err: "Policy restriction prevented the operation from completing",
},
{
name: "ShouldAllowWhenPermittedAndNotProhibited",
filtering: &FilteringConfig{
PermittedAAGUIDs: []uuid.UUID{aaguidA, aaguidB},
ProhibitedAAGUIDs: []uuid.UUID{aaguidC},
},
credential: credentialWith(aaguidA, false),
},
{
name: "ShouldCheckBackupEligibilityBeforeAAGUID",
filtering: &FilteringConfig{
ProhibitBackupEligibility: true,
PermittedAAGUIDs: []uuid.UUID{aaguidA},
},
credential: credentialWith(aaguidC, true),
err: "Policy restriction prevented the operation from completing",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateFilteredCredential(tc.credential, tc.filtering)
if tc.err == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.err)
}
})
}
}
// testRegistrationSpecVectorNoneES256 returns the spec test vector data for NoneES256 registration.
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-none-es256
func testRegistrationSpecVectorNoneES256(t *testing.T) (body []byte, challenge string, credentialID []byte) {
t.Helper()
const (
attestationObjectHex = "a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b559000000008446ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220"
clientDataJSONHex = "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22414d4d507434557878475453746e63647134313759447742466938767049612d7077386f4f755657345441222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20426b5165446a646354427258426941774a544c453551227d"
credentialIDHex = "f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4" //nolint:gosec
challengeHex = "00c30fb78531c464d2b6771dab8d7b603c01162f2fa486bea70f283ae556e130"
)
credentialID, err := hex.DecodeString(credentialIDHex)
require.NoError(t, err)
challenge = base64.RawURLEncoding.EncodeToString(testRegDecodeHex(t, challengeHex))
id := base64.RawURLEncoding.EncodeToString(credentialID)
attObj := base64.RawURLEncoding.EncodeToString(testRegDecodeHex(t, attestationObjectHex))
cdj := base64.RawURLEncoding.EncodeToString(testRegDecodeHex(t, clientDataJSONHex))
response := map[string]any{
"id": id,
"rawId": id,
"type": "public-key",
"response": map[string]any{
"attestationObject": attObj,
"clientDataJSON": cdj,
},
}
body, err = json.Marshal(response)
require.NoError(t, err)
return body, challenge, credentialID
}
// testRegistrationSpecVectorPackedSelfES256 returns the spec test vector data for Packed Self ES256 registration.
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-packed-self-es256
func testRegistrationSpecVectorPackedSelfES256(t *testing.T) (body []byte, challenge string, credentialID []byte) {
t.Helper()
const (
attestationObjectHex = "a363666d74667061636b65646761747453746d74a263616c672663736967584630440220067a20754ab925005dbf378097c92120031581c73228d1fb4f5b881bcd7da98302207fc7b147558c7c0eba3af18bd9d121fa3d3a26d17fe3f220272178f473b6006d68617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000df850e09db6afbdfab51697791506cfc0020455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58ca5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2"
clientDataJSONHex = "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a2265476e4374334c55745936366b336a506a796e6962506b31716e666644616966715a774c33417032392d55222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a205539685458764b453255526b4d6e625f307859485667227d"
credentialIDHex = "455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58c" //nolint:gosec
challengeHex = "7869c2b772d4b58eba9378cf8f29e26cf935aa77df0da89fa99c0bdc0a76f7e5"
)
credentialID, err := hex.DecodeString(credentialIDHex)
require.NoError(t, err)
challenge = base64.RawURLEncoding.EncodeToString(testRegDecodeHex(t, challengeHex))
id := base64.RawURLEncoding.EncodeToString(credentialID)
attObj := base64.RawURLEncoding.EncodeToString(testRegDecodeHex(t, attestationObjectHex))
cdj := base64.RawURLEncoding.EncodeToString(testRegDecodeHex(t, clientDataJSONHex))
response := map[string]any{
"id": id,
"rawId": id,
"type": "public-key",
"response": map[string]any{
"attestationObject": attObj,
"clientDataJSON": cdj,
},
}
body, err = json.Marshal(response)
require.NoError(t, err)
return body, challenge, credentialID
}
func testRegDecodeHex(t *testing.T, s string) []byte {
t.Helper()
data, err := hex.DecodeString(s)
require.NoError(t, err)
return data
}
+251
View File
@@ -0,0 +1,251 @@
package webauthn
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/go-webauthn/webauthn/metadata"
"github.com/go-webauthn/webauthn/protocol"
)
// New creates a new [WebAuthn] instance from the provided [Config]. The configuration is validated before the
// instance is returned.
func New(config *Config) (*WebAuthn, error) {
if err := config.validate(); err != nil {
return nil, fmt.Errorf(errFmtConfigValidate, err)
}
return &WebAuthn{
config,
}, nil
}
// WebAuthn is the primary interface of this package. It provides methods to begin and finish both registration and
// login ceremonies. Create an instance using [New] and then call the appropriate Begin/Finish methods for your
// use case. See the package documentation for detailed ceremony flows.
type WebAuthn struct {
Config *Config
}
// Config represents the Relying Party configuration for WebAuthn operations. At minimum, RPID and RPOrigins must
// be configured. The RPID should be the effective domain of the Relying Party (i.e. "example.com") and RPOrigins
// should contain the fully qualified origins that are permitted (i.e. "https://example.com").
type Config struct {
// RPID configures the Relying Party Server ID. This should generally be the origin without a scheme and port.
RPID string
// RPDisplayName configures the display name for the Relying Party Server. This can be any string.
RPDisplayName string
// RPOrigins configures the list of Relying Party Server Origins that are permitted. The provided origins can either
// be fully qualified origins or strings for simple string comparison. The strings are matched using canonical
// origin matching semantics specifically if they start with 'http://' or 'https://' if the provided origin has a
// case-insensitive equal scheme and host component they are equal, otherwise simple string comparison is utilized
// to determine equality.
RPOrigins []string
// RPTopOrigins configures the list of Relying Party Server Top Origins that are permitted. The provided origins can
// either be fully qualified origins or strings for simple string comparison. The strings are matched using
// canonical origin matching semantics specifically if they start with 'http://' or 'https://' if the provided
// origin has a case-insensitive equal scheme and host component they are equal, otherwise simple string comparison
// is utilized to determine equality.
RPTopOrigins []string
// RPTopOriginVerificationMode determines the verification mode for the Top Origin value used in cross-origin
// ceremonies. When the zero value ([protocol.TopOriginDefaultVerificationMode]) is provided, the config
// validator coerces this field to [protocol.TopOriginExplicitVerificationMode]; i.e. any Top Origin supplied
// by the client must appear in [Config.RPTopOrigins]. Set this field explicitly to
// [protocol.TopOriginAutoVerificationMode] or [protocol.TopOriginImplicitVerificationMode] if you need
// different matching semantics; there is no longer a mode that disables verification entirely.
RPTopOriginVerificationMode protocol.TopOriginVerificationMode
// RPAllowCrossOrigin determines whether the RP is allowed to be used in cross-origin contexts. This is disabled
// by default.
RPAllowCrossOrigin bool
// AttestationPreference sets the default attestation conveyance preferences.
AttestationPreference protocol.ConveyancePreference
// AuthenticatorSelection sets the default authenticator selection options.
AuthenticatorSelection protocol.AuthenticatorSelection
// Debug enables various debug options.
Debug bool
// EncodeUserIDAsString ensures the user.id value during registrations is encoded as a raw UTF8 string. This is
// useful when you only use printable ASCII characters for the random user.id but the browser library does not
// decode the URL Safe Base64 data.
EncodeUserIDAsString bool
// Timeouts configures various timeouts.
Timeouts TimeoutsConfig
// MDS configures a FIDO Metadata Service provider for authenticator trust validation. When set, the library
// validates attestation statements against known authenticator metadata including trust anchors, attestation
// types, and authenticator status. Use the providers in [github.com/go-webauthn/webauthn/metadata/providers/memory]
// or [github.com/go-webauthn/webauthn/metadata/providers/cached] to create a provider instance.
MDS metadata.Provider
// Filtering configures the filtering of authenticators based on their AAGUIDs. This is useful for enforcing
// policy on the authenticators that are available to be registered with the Relying Party.
Filtering *FilteringConfig
validated bool
}
// FilteringConfig configures the filtering of authenticators based on their AAGUIDs. This is useful for enforcing
// policy on the authenticators that are available to be registered with the Relying Party.
type FilteringConfig struct {
// ProhibitBackupEligibility if set will prohibit the use of authenticators with the backup eligible flag set.
ProhibitBackupEligibility bool
// PermittedAAGUIDs if set is used to filter authenticators by their AAGUID only allowing specific values. This
// option is mutually exclusive with ProhibitedAAGUIDs and will never exclude a zero AAGUID. To prohibit the use
// of Zero AAGUIDs, use [Config.MDS] or [FilteringConfig.ProhibitedAAGUIDs].
PermittedAAGUIDs []uuid.UUID
// ProhibitedAAGUIDs if set is used to filter authenticators by their AAGUID only prohibiting specific values. This
// option is mutually exclusive with PermittedAAGUIDs.
ProhibitedAAGUIDs []uuid.UUID
}
// TimeoutsConfig configures the timeout durations for both login and registration ceremonies. These values are sent
// to the client as the timeout field in the credential request/creation options and optionally enforced server-side.
type TimeoutsConfig struct {
Login TimeoutConfig
Registration TimeoutConfig
}
// TimeoutConfig configures timeout behavior for a specific WebAuthn ceremony (registration or login).
type TimeoutConfig struct {
// Enforce the timeouts at the Relying Party / Server. This means if enabled and the user takes too long that even
// if the browser does not enforce the timeout the Relying Party / Server will.
Enforce bool
// Timeout is the timeout for logins/registrations when the UserVerificationRequirement is set to anything other
// than discouraged.
Timeout time.Duration
// TimeoutUVD is the timeout for logins/registrations when the UserVerificationRequirement is set to discouraged.
TimeoutUVD time.Duration
}
// Validate that the config flags in Config are properly set.
func (config *Config) validate() (err error) {
if config.validated {
return nil
}
if len(config.RPID) != 0 {
if err = protocol.ValidateRPID(config.RPID); err != nil {
return fmt.Errorf(errFmtFieldNotValidDomainString, "RPID", err)
}
}
defaultTimeoutConfig := defaultTimeout
defaultTimeoutUVDConfig := defaultTimeoutUVD
if config.Timeouts.Login.Timeout.Milliseconds() == 0 {
config.Timeouts.Login.Timeout = defaultTimeoutConfig
}
if config.Timeouts.Login.TimeoutUVD.Milliseconds() == 0 {
config.Timeouts.Login.TimeoutUVD = defaultTimeoutUVDConfig
}
if config.Timeouts.Registration.Timeout.Milliseconds() == 0 {
config.Timeouts.Registration.Timeout = defaultTimeoutConfig
}
if config.Timeouts.Registration.TimeoutUVD.Milliseconds() == 0 {
config.Timeouts.Registration.TimeoutUVD = defaultTimeoutUVDConfig
}
if len(config.RPOrigins) == 0 {
return fmt.Errorf("must provide at least one value to the 'RPOrigins' field")
}
if config.RPTopOriginVerificationMode == protocol.TopOriginDefaultVerificationMode {
config.RPTopOriginVerificationMode = protocol.TopOriginExplicitVerificationMode
}
if config.Filtering != nil {
if len(config.Filtering.PermittedAAGUIDs) > 0 && len(config.Filtering.ProhibitedAAGUIDs) > 0 {
return fmt.Errorf("cannot set both 'PermittedAAGUIDs' and 'ProhibitedAAGUIDs' in the filtering config")
}
}
config.validated = true
return nil
}
// GetRPID returns the configured Relying Party ID.
func (c *Config) GetRPID() string {
return c.RPID
}
// GetOrigins returns the configured Relying Party Origins.
func (c *Config) GetOrigins() []string {
return c.RPOrigins
}
// GetTopOrigins returns the configured Relying Party Top Origins.
func (c *Config) GetTopOrigins() []string {
return c.RPTopOrigins
}
// GetTopOriginVerificationMode returns the configured Top Origin verification mode.
func (c *Config) GetTopOriginVerificationMode() protocol.TopOriginVerificationMode {
return c.RPTopOriginVerificationMode
}
// GetMetaDataProvider returns the configured FIDO Metadata Service provider.
func (c *Config) GetMetaDataProvider() metadata.Provider {
return c.MDS
}
// ConfigProvider is an interface that provides access to the WebAuthn [Config] values. This is useful for
// implementations that wish to provide configuration from alternative sources.
type ConfigProvider interface {
GetRPID() string
GetOrigins() []string
GetTopOrigins() []string
GetTopOriginVerificationMode() protocol.TopOriginVerificationMode
GetMetaDataProvider() metadata.Provider
}
// User is an interface with the Relying Party's User entry and provides the fields and methods needed for WebAuthn
// registration operations.
type User interface {
// WebAuthnID provides the user handle of the user account. A user handle is an opaque byte sequence with a maximum
// size of 64 bytes, and is not meant to be displayed to the user.
//
// To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
// member, not the displayName nor name members. See Section 6.1 of [RFC8266].
//
// It's recommended this value is completely random and uses the entire 64 bytes.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id)
WebAuthnID() []byte
// WebAuthnName provides the name attribute of the user account during registration and is a human-palatable name
// for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party SHOULD
// let the user choose this, and SHOULD NOT restrict the choice more than necessary.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentity)
WebAuthnName() string
// WebAuthnDisplayName provides the name attribute of the user account during registration and is a human-palatable
// name for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party
// SHOULD let the user choose this, and SHOULD NOT restrict the choice more than necessary.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-displayname)
WebAuthnDisplayName() string
// WebAuthnCredentials provides the slice of [Credential] objects owned by the user. This generally should be all
// the [Credential] objects owned by the user regardless of which flow is being used.
WebAuthnCredentials() []Credential
}
+43
View File
@@ -0,0 +1,43 @@
package webauthn
import (
"time"
"github.com/go-webauthn/webauthn/protocol"
)
//go:generate msgp
//msgp:replace protocol.UserVerificationRequirement with:string
//msgp:replace protocol.AuthenticationExtensions with:map[string]any
//msgp:replace protocol.CredentialMediationRequirement with:string
//msgp:clearomitted
// SessionData is the data that must be stored by the Relying Party between the Begin and Finish steps of a WebAuthn
// ceremony. It contains the challenge and other parameters needed to verify the authenticator's response.
//
// The Relying Party must store this data securely and associate it with the user's session. It should not be
// modifiable by the client (i.e. store it server-side or in a signed, opaque cookie). After the ceremony completes,
// the session data should be discarded.
//
// Every field returned by the Begin* functions must be delivered to the matching Finish* / Validate* call with
// the same values; if anything is dropped or reshaped in transit, verification will fail. Treat [SessionData] as
// an atomic record between those two calls.
//
// For consolidated persistence guidance; recommended schema shape, required lookup columns, and the rules
// that also apply to [Credential] records; see the [Storage] section of the
// [github.com/go-webauthn/webauthn/webauthn] package documentation.
//
// [Storage]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#hdr-Storage
type SessionData struct {
Challenge string `json:"challenge" msg:"c"`
RelyingPartyID string `json:"rpId,omitempty" msg:"r,omitempty"`
UserID []byte `json:"user_id,omitempty" msg:"u,omitempty"`
AllowedCredentialIDs [][]byte `json:"allowed_credentials,omitempty" msg:"allow,omitempty"`
Expires time.Time `json:"expires" msg:"exp"`
UserVerification protocol.UserVerificationRequirement `json:"userVerification,omitempty" msg:"uv,omitempty"`
Extensions protocol.AuthenticationExtensions `json:"extensions,omitempty" msg:"exts,omitempty"`
CredParams []protocol.CredentialParameter `json:"credParams,omitempty" msg:"params,omitempty"`
Mediation protocol.CredentialMediationRequirement `json:"mediation,omitempty" msg:"cmr,omitempty"`
}
+653
View File
@@ -0,0 +1,653 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *SessionData) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 7 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "c":
z.Challenge, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Challenge")
return
}
case "r":
z.RelyingPartyID, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "RelyingPartyID")
return
}
zb0001Mask |= 0x1
case "u":
z.UserID, err = dc.ReadBytes(z.UserID)
if err != nil {
err = msgp.WrapError(err, "UserID")
return
}
zb0001Mask |= 0x2
case "allow":
var zb0002 uint32
zb0002, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs")
return
}
if cap(z.AllowedCredentialIDs) >= int(zb0002) {
z.AllowedCredentialIDs = (z.AllowedCredentialIDs)[:zb0002]
} else {
z.AllowedCredentialIDs = make([][]byte, zb0002)
}
for za0003 := range z.AllowedCredentialIDs {
z.AllowedCredentialIDs[za0003], err = dc.ReadBytes(z.AllowedCredentialIDs[za0003])
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs", za0003)
return
}
}
zb0001Mask |= 0x4
case "exp":
z.Expires, err = dc.ReadTime()
if err != nil {
err = msgp.WrapError(err, "Expires")
return
}
case "uv":
{
var zb0003 string
zb0003, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "UserVerification")
return
}
z.UserVerification = protocol.UserVerificationRequirement(zb0003)
}
zb0001Mask |= 0x8
case "exts":
var zb0004 uint32
zb0004, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
if z.Extensions == nil {
z.Extensions = make(map[string]interface{}, zb0004)
} else if len(z.Extensions) > 0 {
clear(z.Extensions)
}
for zb0004 > 0 {
zb0004--
var za0004 string
za0004, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
var za0005 interface{}
za0005, err = dc.ReadIntf()
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
z.Extensions[za0004] = za0005
}
zb0001Mask |= 0x10
case "params":
var zb0005 uint32
zb0005, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err, "CredParams")
return
}
if cap(z.CredParams) >= int(zb0005) {
z.CredParams = (z.CredParams)[:zb0005]
} else {
z.CredParams = make([]protocol.CredentialParameter, zb0005)
}
for za0006 := range z.CredParams {
err = z.CredParams[za0006].DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
zb0001Mask |= 0x20
case "cmr":
{
var zb0006 string
zb0006, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Mediation")
return
}
z.Mediation = protocol.CredentialMediationRequirement(zb0006)
}
zb0001Mask |= 0x40
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7f {
if (zb0001Mask & 0x1) == 0 {
z.RelyingPartyID = ""
}
if (zb0001Mask & 0x2) == 0 {
z.UserID = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AllowedCredentialIDs = nil
}
if (zb0001Mask & 0x8) == 0 {
z.UserVerification = ""
}
if (zb0001Mask & 0x10) == 0 {
z.Extensions = nil
}
if (zb0001Mask & 0x20) == 0 {
z.CredParams = nil
}
if (zb0001Mask & 0x40) == 0 {
z.Mediation = ""
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *SessionData) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(9)
var zb0001Mask uint16 /* 9 bits */
_ = zb0001Mask
if z.RelyingPartyID == "" {
zb0001Len--
zb0001Mask |= 0x2
}
if z.UserID == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AllowedCredentialIDs == nil {
zb0001Len--
zb0001Mask |= 0x8
}
if z.UserVerification == "" {
zb0001Len--
zb0001Mask |= 0x20
}
if z.Extensions == nil {
zb0001Len--
zb0001Mask |= 0x40
}
if z.CredParams == nil {
zb0001Len--
zb0001Mask |= 0x80
}
if z.Mediation == "" {
zb0001Len--
zb0001Mask |= 0x100
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
// write "c"
err = en.Append(0xa1, 0x63)
if err != nil {
return
}
err = en.WriteString(z.Challenge)
if err != nil {
err = msgp.WrapError(err, "Challenge")
return
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "r"
err = en.Append(0xa1, 0x72)
if err != nil {
return
}
err = en.WriteString(z.RelyingPartyID)
if err != nil {
err = msgp.WrapError(err, "RelyingPartyID")
return
}
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "u"
err = en.Append(0xa1, 0x75)
if err != nil {
return
}
err = en.WriteBytes(z.UserID)
if err != nil {
err = msgp.WrapError(err, "UserID")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "allow"
err = en.Append(0xa5, 0x61, 0x6c, 0x6c, 0x6f, 0x77)
if err != nil {
return
}
err = en.WriteArrayHeader(uint32(len(z.AllowedCredentialIDs)))
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs")
return
}
for za0003 := range z.AllowedCredentialIDs {
err = en.WriteBytes(z.AllowedCredentialIDs[za0003])
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs", za0003)
return
}
}
}
// write "exp"
err = en.Append(0xa3, 0x65, 0x78, 0x70)
if err != nil {
return
}
err = en.WriteTime(z.Expires)
if err != nil {
err = msgp.WrapError(err, "Expires")
return
}
if (zb0001Mask & 0x20) == 0 { // if not omitted
// write "uv"
err = en.Append(0xa2, 0x75, 0x76)
if err != nil {
return
}
err = en.WriteString(string(z.UserVerification))
if err != nil {
err = msgp.WrapError(err, "UserVerification")
return
}
}
if (zb0001Mask & 0x40) == 0 { // if not omitted
// write "exts"
err = en.Append(0xa4, 0x65, 0x78, 0x74, 0x73)
if err != nil {
return
}
err = en.WriteMapHeader(uint32(len(z.Extensions)))
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
for za0004, za0005 := range z.Extensions {
err = en.WriteString(za0004)
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
err = en.WriteIntf(za0005)
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
}
}
if (zb0001Mask & 0x80) == 0 { // if not omitted
// write "params"
err = en.Append(0xa6, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73)
if err != nil {
return
}
err = en.WriteArrayHeader(uint32(len(z.CredParams)))
if err != nil {
err = msgp.WrapError(err, "CredParams")
return
}
for za0006 := range z.CredParams {
err = z.CredParams[za0006].EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
}
if (zb0001Mask & 0x100) == 0 { // if not omitted
// write "cmr"
err = en.Append(0xa3, 0x63, 0x6d, 0x72)
if err != nil {
return
}
err = en.WriteString(string(z.Mediation))
if err != nil {
err = msgp.WrapError(err, "Mediation")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *SessionData) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(9)
var zb0001Mask uint16 /* 9 bits */
_ = zb0001Mask
if z.RelyingPartyID == "" {
zb0001Len--
zb0001Mask |= 0x2
}
if z.UserID == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AllowedCredentialIDs == nil {
zb0001Len--
zb0001Mask |= 0x8
}
if z.UserVerification == "" {
zb0001Len--
zb0001Mask |= 0x20
}
if z.Extensions == nil {
zb0001Len--
zb0001Mask |= 0x40
}
if z.CredParams == nil {
zb0001Len--
zb0001Mask |= 0x80
}
if z.Mediation == "" {
zb0001Len--
zb0001Mask |= 0x100
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
// string "c"
o = append(o, 0xa1, 0x63)
o = msgp.AppendString(o, z.Challenge)
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "r"
o = append(o, 0xa1, 0x72)
o = msgp.AppendString(o, z.RelyingPartyID)
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "u"
o = append(o, 0xa1, 0x75)
o = msgp.AppendBytes(o, z.UserID)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "allow"
o = append(o, 0xa5, 0x61, 0x6c, 0x6c, 0x6f, 0x77)
o = msgp.AppendArrayHeader(o, uint32(len(z.AllowedCredentialIDs)))
for za0003 := range z.AllowedCredentialIDs {
o = msgp.AppendBytes(o, z.AllowedCredentialIDs[za0003])
}
}
// string "exp"
o = append(o, 0xa3, 0x65, 0x78, 0x70)
o = msgp.AppendTime(o, z.Expires)
if (zb0001Mask & 0x20) == 0 { // if not omitted
// string "uv"
o = append(o, 0xa2, 0x75, 0x76)
o = msgp.AppendString(o, string(z.UserVerification))
}
if (zb0001Mask & 0x40) == 0 { // if not omitted
// string "exts"
o = append(o, 0xa4, 0x65, 0x78, 0x74, 0x73)
o = msgp.AppendMapHeader(o, uint32(len(z.Extensions)))
for za0004, za0005 := range z.Extensions {
o = msgp.AppendString(o, za0004)
o, err = msgp.AppendIntf(o, za0005)
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
}
}
if (zb0001Mask & 0x80) == 0 { // if not omitted
// string "params"
o = append(o, 0xa6, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73)
o = msgp.AppendArrayHeader(o, uint32(len(z.CredParams)))
for za0006 := range z.CredParams {
o, err = z.CredParams[za0006].MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
}
if (zb0001Mask & 0x100) == 0 { // if not omitted
// string "cmr"
o = append(o, 0xa3, 0x63, 0x6d, 0x72)
o = msgp.AppendString(o, string(z.Mediation))
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *SessionData) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 7 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "c":
z.Challenge, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Challenge")
return
}
case "r":
z.RelyingPartyID, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "RelyingPartyID")
return
}
zb0001Mask |= 0x1
case "u":
z.UserID, bts, err = msgp.ReadBytesBytes(bts, z.UserID)
if err != nil {
err = msgp.WrapError(err, "UserID")
return
}
zb0001Mask |= 0x2
case "allow":
var zb0002 uint32
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs")
return
}
if cap(z.AllowedCredentialIDs) >= int(zb0002) {
z.AllowedCredentialIDs = (z.AllowedCredentialIDs)[:zb0002]
} else {
z.AllowedCredentialIDs = make([][]byte, zb0002)
}
for za0003 := range z.AllowedCredentialIDs {
z.AllowedCredentialIDs[za0003], bts, err = msgp.ReadBytesBytes(bts, z.AllowedCredentialIDs[za0003])
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs", za0003)
return
}
}
zb0001Mask |= 0x4
case "exp":
z.Expires, bts, err = msgp.ReadTimeBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Expires")
return
}
case "uv":
{
var zb0003 string
zb0003, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "UserVerification")
return
}
z.UserVerification = protocol.UserVerificationRequirement(zb0003)
}
zb0001Mask |= 0x8
case "exts":
var zb0004 uint32
zb0004, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
if z.Extensions == nil {
z.Extensions = make(map[string]interface{}, zb0004)
} else if len(z.Extensions) > 0 {
clear(z.Extensions)
}
for zb0004 > 0 {
var za0005 interface{}
zb0004--
var za0004 string
za0004, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
za0005, bts, err = msgp.ReadIntfBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
z.Extensions[za0004] = za0005
}
zb0001Mask |= 0x10
case "params":
var zb0005 uint32
zb0005, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "CredParams")
return
}
if cap(z.CredParams) >= int(zb0005) {
z.CredParams = (z.CredParams)[:zb0005]
} else {
z.CredParams = make([]protocol.CredentialParameter, zb0005)
}
for za0006 := range z.CredParams {
bts, err = z.CredParams[za0006].UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
zb0001Mask |= 0x20
case "cmr":
{
var zb0006 string
zb0006, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Mediation")
return
}
z.Mediation = protocol.CredentialMediationRequirement(zb0006)
}
zb0001Mask |= 0x40
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7f {
if (zb0001Mask & 0x1) == 0 {
z.RelyingPartyID = ""
}
if (zb0001Mask & 0x2) == 0 {
z.UserID = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AllowedCredentialIDs = nil
}
if (zb0001Mask & 0x8) == 0 {
z.UserVerification = ""
}
if (zb0001Mask & 0x10) == 0 {
z.Extensions = nil
}
if (zb0001Mask & 0x20) == 0 {
z.CredParams = nil
}
if (zb0001Mask & 0x40) == 0 {
z.Mediation = ""
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *SessionData) Msgsize() (s int) {
s = 1 + 2 + msgp.StringPrefixSize + len(z.Challenge) + 2 + msgp.StringPrefixSize + len(z.RelyingPartyID) + 2 + msgp.BytesPrefixSize + len(z.UserID) + 6 + msgp.ArrayHeaderSize
for za0003 := range z.AllowedCredentialIDs {
s += msgp.BytesPrefixSize + len(z.AllowedCredentialIDs[za0003])
}
s += 4 + msgp.TimeSize + 3 + msgp.StringPrefixSize + len(string(z.UserVerification)) + 5 + msgp.MapHeaderSize
if z.Extensions != nil {
for za0004, za0005 := range z.Extensions {
_ = za0005
s += msgp.StringPrefixSize + len(za0004) + msgp.GuessSize(za0005)
}
}
s += 7 + msgp.ArrayHeaderSize
for za0006 := range z.CredParams {
s += z.CredParams[za0006].Msgsize()
}
s += 4 + msgp.StringPrefixSize + len(string(z.Mediation))
return
}
@@ -0,0 +1,123 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"bytes"
"testing"
"github.com/tinylib/msgp/msgp"
)
func TestMarshalUnmarshalSessionData(t *testing.T) {
v := SessionData{}
bts, err := v.MarshalMsg(nil)
if err != nil {
t.Fatal(err)
}
left, err := v.UnmarshalMsg(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
}
left, err = msgp.Skip(bts)
if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
}
}
func BenchmarkMarshalMsgSessionData(b *testing.B) {
v := SessionData{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.MarshalMsg(nil)
}
}
func BenchmarkAppendMsgSessionData(b *testing.B) {
v := SessionData{}
bts := make([]byte, 0, v.Msgsize())
bts, _ = v.MarshalMsg(bts[0:0])
b.SetBytes(int64(len(bts)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
bts, _ = v.MarshalMsg(bts[0:0])
}
}
func BenchmarkUnmarshalSessionData(b *testing.B) {
v := SessionData{}
bts, _ := v.MarshalMsg(nil)
b.ReportAllocs()
b.SetBytes(int64(len(bts)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := v.UnmarshalMsg(bts)
if err != nil {
b.Fatal(err)
}
}
}
func TestEncodeDecodeSessionData(t *testing.T) {
v := SessionData{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
m := v.Msgsize()
if buf.Len() > m {
t.Log("WARNING: TestEncodeDecodeSessionData Msgsize() is inaccurate")
}
vn := SessionData{}
err := msgp.Decode(&buf, &vn)
if err != nil {
t.Error(err)
}
buf.Reset()
msgp.Encode(&buf, &v)
err = msgp.NewReader(&buf).Skip()
if err != nil {
t.Error(err)
}
}
func BenchmarkEncodeSessionData(b *testing.B) {
v := SessionData{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
en := msgp.NewWriter(msgp.Nowhere)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.EncodeMsg(en)
}
en.Flush()
}
func BenchmarkDecodeSessionData(b *testing.B) {
v := SessionData{}
var buf bytes.Buffer
msgp.Encode(&buf, &v)
b.SetBytes(int64(buf.Len()))
rd := msgp.NewEndlessReader(buf.Bytes(), b)
dc := msgp.NewReader(rd)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := v.DecodeMsg(dc)
if err != nil {
b.Fatal(err)
}
}
}
+261
View File
@@ -0,0 +1,261 @@
package webauthn
import (
"bytes"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinylib/msgp/msgp"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/protocol/webauthncose"
)
func TestSessionData_MsgpRoundTrip(t *testing.T) {
original := newPopulatedSessionData()
t.Run("MarshalUnmarshalPreservesFields", func(t *testing.T) {
data, err := original.MarshalMsg(nil)
require.NoError(t, err)
var decoded SessionData
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, original.Challenge, decoded.Challenge)
assert.Equal(t, original.RelyingPartyID, decoded.RelyingPartyID)
assert.Equal(t, original.UserID, decoded.UserID)
assert.Equal(t, original.AllowedCredentialIDs, decoded.AllowedCredentialIDs)
assert.True(t, original.Expires.Equal(decoded.Expires))
assert.Equal(t, original.UserVerification, decoded.UserVerification)
assert.Equal(t, original.CredParams, decoded.CredParams)
assert.Equal(t, original.Mediation, decoded.Mediation)
assert.Equal(t, original.Extensions, decoded.Extensions)
assert.LessOrEqual(t, len(data), original.Msgsize())
})
t.Run("EncodeDecodePreservesFields", func(t *testing.T) {
var buf bytes.Buffer
require.NoError(t, msgp.Encode(&buf, &original))
var decoded SessionData
require.NoError(t, msgp.Decode(&buf, &decoded))
assert.Equal(t, original.Challenge, decoded.Challenge)
assert.Equal(t, original.RelyingPartyID, decoded.RelyingPartyID)
assert.Equal(t, original.UserID, decoded.UserID)
assert.Equal(t, original.AllowedCredentialIDs, decoded.AllowedCredentialIDs)
assert.True(t, original.Expires.Equal(decoded.Expires))
assert.Equal(t, original.UserVerification, decoded.UserVerification)
assert.Equal(t, original.CredParams, decoded.CredParams)
assert.Equal(t, original.Mediation, decoded.Mediation)
assert.Equal(t, original.Extensions, decoded.Extensions)
})
t.Run("UnmarshalSkipsUnknownKeys", func(t *testing.T) {
tiny := []byte{0x81, 0xa3, 'x', 'y', 'z', 0xc3}
var decoded SessionData
left, err := decoded.UnmarshalMsg(tiny)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, SessionData{}, decoded)
})
}
func TestSessionData_MsgpEmptyVariants(t *testing.T) {
testCases := []struct {
name string
original SessionData
}{
{
name: "NilAllowedCredentialIDs",
original: SessionData{
Challenge: "c",
RelyingPartyID: "r",
UserID: []byte{0x01},
},
},
{
name: "EmptyAllowedCredentialIDs",
original: SessionData{
Challenge: "c",
RelyingPartyID: "r",
UserID: []byte{0x01},
AllowedCredentialIDs: [][]byte{},
},
},
{
name: "NilExtensions",
original: SessionData{
Challenge: "c",
RelyingPartyID: "r",
UserID: []byte{0x01},
},
},
{
name: "EmptyExtensions",
original: SessionData{
Challenge: "c",
RelyingPartyID: "r",
UserID: []byte{0x01},
Extensions: protocol.AuthenticationExtensions{},
},
},
{
name: "NilCredParams",
original: SessionData{
Challenge: "c",
RelyingPartyID: "r",
UserID: []byte{0x01},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.original.MarshalMsg(nil)
require.NoError(t, err)
var decoded SessionData
left, err := decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left)
assert.Equal(t, tc.original.Challenge, decoded.Challenge)
assert.Equal(t, tc.original.RelyingPartyID, decoded.RelyingPartyID)
assert.Equal(t, tc.original.UserID, decoded.UserID)
assert.Len(t, decoded.AllowedCredentialIDs, len(tc.original.AllowedCredentialIDs))
assert.Len(t, decoded.Extensions, len(tc.original.Extensions))
assert.Len(t, decoded.CredParams, len(tc.original.CredParams))
})
}
}
func TestSessionData_MsgpExpiresRoundTrip(t *testing.T) {
testCases := []struct {
name string
t time.Time
}{
{"Epoch", time.Unix(0, 0).UTC()},
{"Zero", time.Time{}},
{"RecentUTC", time.Date(2026, time.April, 19, 12, 34, 56, 789000000, time.UTC)},
{"FarFuture", time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC)},
{"NonUTC", time.Date(2026, time.April, 19, 12, 34, 56, 0, time.FixedZone("AEST", 10*3600))},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
original := SessionData{Expires: tc.t}
data, err := original.MarshalMsg(nil)
require.NoError(t, err)
var (
decoded SessionData
left []byte
)
left, err = decoded.UnmarshalMsg(data)
require.NoError(t, err)
assert.Empty(t, left)
assert.True(t, tc.t.Equal(decoded.Expires))
})
}
}
func TestSessionData_MsgpEncodeErrorPaths(t *testing.T) {
v := newPopulatedSessionData()
data, err := v.MarshalMsg(nil)
require.NoError(t, err)
exerciseEncodeMsgErrorPaths(t, &v, data)
}
func TestSessionData_DecodeMsgInvalidTypes(t *testing.T) {
t.Run("NotAMap", func(t *testing.T) {
var s SessionData
_, err := s.UnmarshalMsg(msgpString("not a map"))
require.Error(t, err)
var s2 SessionData
require.Error(t, msgp.Decode(bytes.NewReader(msgpString("not a map")), &s2))
})
testCases := []struct {
name string
data []byte
wantSub string
}{
{"ChallengeAsInt", msgpOneFieldMap("c", msgpInt64(42)), "Challenge"},
{"RelyingPartyIDAsBool", msgpOneFieldMap("r", msgpBool(true)), "RelyingPartyID"},
{"UserIDAsInt", msgpOneFieldMap("u", msgpInt64(42)), "UserID"},
{"AllowedCredentialIDsNotArray", msgpOneFieldMap("allow", msgpBool(true)), "AllowedCredentialIDs"},
{"AllowedCredentialIDElementNotBytes", msgpOneFieldMap("allow", func() []byte {
b := msgp.AppendArrayHeader(nil, 1)
return append(b, msgpBool(true)...)
}()), "AllowedCredentialIDs"},
{"ExpiresAsString", msgpOneFieldMap("exp", msgpString("x")), ""},
{"UserVerificationAsInt", msgpOneFieldMap("uv", msgpInt64(42)), "UserVerification"},
{"ExtensionsNotMap", msgpOneFieldMap("exts", msgpBool(true)), "Extensions"},
{"CredParamsNotArray", msgpOneFieldMap("params", msgpBool(true)), "CredParams"},
{"MediationAsBool", msgpOneFieldMap("cmr", msgpBool(true)), "Mediation"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var s SessionData
_, err := s.UnmarshalMsg(tc.data)
require.Error(t, err)
if tc.wantSub != "" {
assert.Contains(t, err.Error(), tc.wantSub)
}
var s2 SessionData
streamErr := msgp.Decode(bytes.NewReader(tc.data), &s2)
require.Error(t, streamErr)
if tc.wantSub != "" {
assert.Contains(t, streamErr.Error(), tc.wantSub)
}
})
}
}
func newPopulatedSessionData() SessionData {
return SessionData{
Challenge: "challenge-bytes-b64url",
RelyingPartyID: "example.com",
UserID: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
AllowedCredentialIDs: [][]byte{
{0xAA, 0xBB, 0xCC},
{0xDD, 0xEE, 0xFF, 0x00},
},
Expires: time.Date(2026, time.April, 19, 12, 34, 56, 0, time.UTC),
UserVerification: protocol.VerificationRequired,
Extensions: protocol.AuthenticationExtensions{
"appid": "https://example.com",
"credProtect": int64(2),
"largeBlob": true,
},
CredParams: []protocol.CredentialParameter{
{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256},
{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgRS256},
},
Mediation: protocol.MediationConditional,
}
}
+301
View File
@@ -0,0 +1,301 @@
package webauthn
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/go-webauthn/webauthn/protocol"
)
func TestConfig_Getters(t *testing.T) {
testCases := []struct {
name string
config *Config
expectedRPID string
expectedOrigins []string
expectedTopOrigins []string
expectedTopOriginVerification protocol.TopOriginVerificationMode
expectedMetaDataProviderIsNil bool
}{
{
name: "ShouldReturnAllValues",
config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
RPTopOrigins: []string{"https://top.example.com"},
RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode,
},
expectedRPID: "example.com",
expectedOrigins: []string{"https://example.com"},
expectedTopOrigins: []string{"https://top.example.com"},
expectedTopOriginVerification: protocol.TopOriginExplicitVerificationMode,
expectedMetaDataProviderIsNil: true,
},
{
name: "ShouldReturnDefaults",
config: &Config{
RPOrigins: []string{"https://example.com"},
},
expectedRPID: "",
expectedOrigins: []string{"https://example.com"},
expectedTopOrigins: nil,
expectedTopOriginVerification: protocol.TopOriginDefaultVerificationMode,
expectedMetaDataProviderIsNil: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expectedRPID, tc.config.GetRPID())
assert.Equal(t, tc.expectedOrigins, tc.config.GetOrigins())
assert.Equal(t, tc.expectedTopOrigins, tc.config.GetTopOrigins())
assert.Equal(t, tc.expectedTopOriginVerification, tc.config.GetTopOriginVerificationMode())
if tc.expectedMetaDataProviderIsNil {
assert.Nil(t, tc.config.GetMetaDataProvider())
} else {
assert.NotNil(t, tc.config.GetMetaDataProvider())
}
})
}
}
func TestNew(t *testing.T) {
testCases := []struct {
name string
config *Config
err string
}{
{
name: "ShouldPassMinimalConfig",
config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
},
},
{
name: "ShouldFailBadRPID",
config: &Config{
RPID: "%%&&",
RPOrigins: []string{"https://example.com"},
},
err: "error occurred validating the configuration: field 'RPID' is not a valid domain string: parse \"%%&&\": invalid URL escape \"%%&\"",
},
{
name: "ShouldFailNoRPOrigins",
config: &Config{
RPID: "example.com",
},
err: "error occurred validating the configuration: must provide at least one value to the 'RPOrigins' field",
},
{
name: "ShouldAllowEmptyRPTopOriginsExplicit",
config: &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w, err := New(tc.config)
if tc.err == "" {
assert.NotNil(t, w)
assert.NoError(t, err)
assert.NoError(t, tc.config.validate())
} else {
assert.Nil(t, w)
assert.EqualError(t, err, tc.err)
assert.Error(t, tc.config.validate())
}
})
}
}
func TestConfig_Validate_DefaultsRPTopOriginVerificationModeToExplicit(t *testing.T) {
testCases := []struct {
name string
input protocol.TopOriginVerificationMode
expect protocol.TopOriginVerificationMode
}{
{
name: "ShouldCoerceZeroValueToExplicit",
input: protocol.TopOriginVerificationMode(0),
expect: protocol.TopOriginExplicitVerificationMode,
},
{
name: "ShouldCoerceDefaultToExplicit",
input: protocol.TopOriginDefaultVerificationMode,
expect: protocol.TopOriginExplicitVerificationMode,
},
{
name: "ShouldPreserveExplicit",
input: protocol.TopOriginExplicitVerificationMode,
expect: protocol.TopOriginExplicitVerificationMode,
},
{
name: "ShouldPreserveAuto",
input: protocol.TopOriginAutoVerificationMode,
expect: protocol.TopOriginAutoVerificationMode,
},
{
name: "ShouldPreserveImplicit",
input: protocol.TopOriginImplicitVerificationMode,
expect: protocol.TopOriginImplicitVerificationMode,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config := &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
RPTopOriginVerificationMode: tc.input,
}
w, err := New(config)
assert.NoError(t, err)
assert.NotNil(t, w)
assert.Equal(t, tc.expect, config.RPTopOriginVerificationMode,
"Config.RPTopOriginVerificationMode should be %v after New(), got %v", tc.expect, config.RPTopOriginVerificationMode)
assert.Equal(t, tc.expect, config.GetTopOriginVerificationMode())
})
}
t.Run("ShouldCoerceDirectValidateCall", func(t *testing.T) {
config := &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
RPTopOriginVerificationMode: protocol.TopOriginDefaultVerificationMode,
}
require.NoError(t, config.validate())
assert.Equal(t, protocol.TopOriginExplicitVerificationMode, config.RPTopOriginVerificationMode)
})
}
func TestConfig_Validate_FilteringMutuallyExclusive(t *testing.T) {
aaguidA := uuid.MustParse("00000000-0000-0000-0000-00000000000a")
aaguidB := uuid.MustParse("00000000-0000-0000-0000-00000000000b")
testCases := []struct {
name string
filtering *FilteringConfig
err string
}{
{
name: "ShouldAllowNilFiltering",
filtering: nil,
},
{
name: "ShouldAllowEmptyFiltering",
filtering: &FilteringConfig{},
},
{
name: "ShouldAllowPermittedOnly",
filtering: &FilteringConfig{PermittedAAGUIDs: []uuid.UUID{aaguidA}},
},
{
name: "ShouldAllowProhibitedOnly",
filtering: &FilteringConfig{ProhibitedAAGUIDs: []uuid.UUID{aaguidB}},
},
{
name: "ShouldAllowProhibitBackupEligibilityOnly",
filtering: &FilteringConfig{ProhibitBackupEligibility: true},
},
{
name: "ShouldAllowProhibitBackupEligibilityWithPermittedList",
filtering: &FilteringConfig{
ProhibitBackupEligibility: true,
PermittedAAGUIDs: []uuid.UUID{aaguidA},
},
},
{
name: "ShouldAllowProhibitBackupEligibilityWithProhibitedList",
filtering: &FilteringConfig{
ProhibitBackupEligibility: true,
ProhibitedAAGUIDs: []uuid.UUID{aaguidB},
},
},
{
name: "ShouldRejectBothPermittedAndProhibited",
filtering: &FilteringConfig{
PermittedAAGUIDs: []uuid.UUID{aaguidA},
ProhibitedAAGUIDs: []uuid.UUID{aaguidB},
},
err: "cannot set both 'PermittedAAGUIDs' and 'ProhibitedAAGUIDs' in the filtering config",
},
{
name: "ShouldRejectBothPermittedAndProhibitedAlongsideBackupEligibility",
filtering: &FilteringConfig{
ProhibitBackupEligibility: true,
PermittedAAGUIDs: []uuid.UUID{aaguidA},
ProhibitedAAGUIDs: []uuid.UUID{aaguidB},
},
err: "cannot set both 'PermittedAAGUIDs' and 'ProhibitedAAGUIDs' in the filtering config",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config := &Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
Filtering: tc.filtering,
}
err := config.validate()
if tc.err == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.err)
}
})
}
t.Run("ShouldRejectViaNew", func(t *testing.T) {
w, err := New(&Config{
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
Filtering: &FilteringConfig{
PermittedAAGUIDs: []uuid.UUID{aaguidA},
ProhibitedAAGUIDs: []uuid.UUID{aaguidB},
},
})
assert.Nil(t, w)
assert.EqualError(t, err, "error occurred validating the configuration: cannot set both 'PermittedAAGUIDs' and 'ProhibitedAAGUIDs' in the filtering config")
})
}
// Supporting test types and functions.
type defaultUser struct {
id []byte
credentials []Credential
}
var _ User = (*defaultUser)(nil)
func (user *defaultUser) WebAuthnID() []byte {
return user.id
}
func (user *defaultUser) WebAuthnName() string {
return "newUser"
}
func (user *defaultUser) WebAuthnDisplayName() string {
return "New User"
}
func (user *defaultUser) WebAuthnCredentials() []Credential {
return user.credentials
}
+40
View File
@@ -0,0 +1,40 @@
package webauthn
import "bytes"
func isByteArrayInSlice(needle []byte, haystack ...[]byte) (valid bool) {
for _, hay := range haystack {
if bytes.Equal(needle, hay) {
return true
}
}
return false
}
func isCredentialsAllowedMatchingOwned(allowedCredentialIDs [][]byte, credentials []Credential) (valid bool) {
var credential Credential
allowed:
for _, allowedCredentialID := range allowedCredentialIDs {
for _, credential = range credentials {
if bytes.Equal(credential.ID, allowedCredentialID) {
continue allowed
}
}
return false
}
return true
}
func isCredentialIDInCredentials(credentialID []byte, credentials []Credential) (valid bool) {
for _, credential := range credentials {
if bytes.Equal(credential.ID, credentialID) {
return true
}
}
return false
}
+66
View File
@@ -0,0 +1,66 @@
package webauthn_test
import (
"crypto/subtle"
"fmt"
"github.com/go-webauthn/webauthn/webauthn"
)
type defaultUser struct {
id []byte
credentials []webauthn.Credential
}
var _ webauthn.User = (*defaultUser)(nil)
func (user *defaultUser) WebAuthnID() []byte {
return user.id
}
func (user *defaultUser) WebAuthnName() string {
return "newUser"
}
func (user *defaultUser) WebAuthnDisplayName() string {
return "New User"
}
func (user *defaultUser) WebAuthnCredentials() []webauthn.Credential {
return user.credentials
}
var testUser *defaultUser
// GetUser is a crude and abstract example of getting users.
func GetUser() *defaultUser {
return &defaultUser{}
}
// LoadUser is a crude and abstract example of loading users.
func LoadUser() (user *defaultUser, err error) {
if testUser != nil {
return testUser, nil
}
return GetUser(), nil
}
func LoadUserByHandle(handle []byte) (user *defaultUser, err error) {
if testUser != nil {
return nil, fmt.Errorf("not initialized")
}
if subtle.ConstantTimeCompare(testUser.id, handle) != 1 {
return nil, fmt.Errorf("not found")
}
return testUser, nil
}
// SaveUser is a crude and abstract example of saving users.
func SaveUser(user *defaultUser) (err error) {
testUser = user
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package webauthn
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsByteArrayInSlice(t *testing.T) {
testCases := []struct {
name string
have []byte
haystack [][]byte
expected bool
}{
{
"ShouldMatchSingleEntry",
[]byte("123"),
[][]byte{[]byte("123")},
true,
},
{
"ShouldMatchMultiEntry",
[]byte("123"),
[][]byte{[]byte("bac"), []byte("123")},
true,
},
{
"ShouldNotMatchEmpty",
[]byte("123"),
nil,
false,
},
{
"ShouldNotMatchNotInSlice",
[]byte("123"),
[][]byte{[]byte("bac"), []byte("no")},
false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, isByteArrayInSlice(tc.have, tc.haystack...))
})
}
}
func TestIsCredentialsAllowedMatchingOwned(t *testing.T) {
testCases := []struct {
name string
allowed [][]byte
credentials []Credential
expected bool
}{
{
"ShouldMatchSingleEntry",
[][]byte{[]byte("123")},
[]Credential{
{
ID: []byte("123"),
},
},
true,
},
{
"ShouldMatchMultipleEntry",
[][]byte{[]byte("123")},
[]Credential{
{
ID: []byte("123"),
},
{
ID: []byte("ab"),
},
},
true,
},
{
"ShouldMatchMultipleEntryAlt",
[][]byte{[]byte("123"), []byte("ab")},
[]Credential{
{
ID: []byte("123"),
},
{
ID: []byte("ab"),
},
},
true,
},
{
"ShouldNotMatchDifferentCredentials",
[][]byte{[]byte("123")},
[]Credential{
{
ID: []byte("456"),
},
},
false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, isCredentialsAllowedMatchingOwned(tc.allowed, tc.credentials))
})
}
}