This commit is contained in:
@@ -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 assertion’s 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 credential’s 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 credential’s id attribute.
|
||||
// Update the stored signature counter value, associated with credential’s id attribute, to be the value of
|
||||
// authData.signCount.
|
||||
//
|
||||
// → Less than or equal to the signature counter value stored in conjunction with credential’s 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 credential’s 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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user