webauthnutil: add helpers for webauthn (#2686)

* devices: add device protobuf types

* webauthnutil: add helpers for webauthn
This commit is contained in:
Caleb Doxsey 2021-10-19 13:39:01 -06:00 committed by GitHub
parent 961bc8abb4
commit 1c445c426d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 872 additions and 2 deletions

6
go.mod
View file

@ -43,6 +43,7 @@ require (
github.com/ory/dockertest/v3 v3.8.0
github.com/peterbourgon/ff/v3 v3.1.2
github.com/pomerium/csrf v1.7.0
github.com/pomerium/webauthn v0.0.0-20211014213840-422c7ce1077f
github.com/prometheus/client_golang v1.11.0
github.com/prometheus/client_model v0.2.0
github.com/prometheus/common v0.31.1
@ -115,6 +116,7 @@ require (
github.com/fatih/color v1.12.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.3.0 // indirect
github.com/fzipp/gocyclo v0.3.1 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-critic/go-critic v0.5.6 // indirect
@ -142,6 +144,7 @@ require (
github.com/golangci/misspell v0.3.5 // indirect
github.com/golangci/revgrep v0.0.0-20210208091834-cd28932614b5 // indirect
github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 // indirect
github.com/google/go-tpm v0.3.2 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
github.com/gordonklaus/ineffassign v0.0.0-20210225214923-2e10b2664254 // indirect
@ -227,6 +230,7 @@ require (
github.com/ultraware/funlen v0.0.3 // indirect
github.com/ultraware/whitespace v0.0.4 // indirect
github.com/uudashr/gocognit v1.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
@ -242,7 +246,7 @@ require (
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/DataDog/dd-trace-go.v1 v1.22.0 // indirect
gopkg.in/ini.v1 v1.63.2 // indirect
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.2.1 // indirect
mvdan.cc/gofumpt v0.1.1 // indirect

16
go.sum
View file

@ -436,6 +436,8 @@ github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWp
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
github.com/fullstorydev/grpcurl v1.6.0/go.mod h1:ZQ+ayqbKMJNhzLmbpCiurTVlaK2M/3nqZCxaQ2Ze/sM=
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/fzipp/gocyclo v0.3.1 h1:A9UeX3HJSXTBzvHzhqoYVuE0eAhe+aM8XBCCwsPMZOc=
github.com/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E=
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
@ -611,6 +613,12 @@ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-jsonnet v0.17.0 h1:/9NIEfhK1NQRKl3sP2536b2+x5HnZMdql7x3yK/l8JY=
github.com/google/go-jsonnet v0.17.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
github.com/google/go-tpm v0.1.2-0.20190725015402-ae6dd98980d4/go.mod h1:H9HbmUG2YgV/PHITkO7p6wxEEj/v5nlsVWIwumwH2NI=
github.com/google/go-tpm v0.3.0/go.mod h1:iVLWvrPp/bHeEkxTFi9WG6K9w0iy2yIszHwZGHPbzAw=
github.com/google/go-tpm v0.3.2 h1:3iQQ2dlEf+1no7CLlfLPYzxhQy7j2G/emBqU5okydaw=
github.com/google/go-tpm v0.3.2/go.mod h1:j71sMBTfp3X5jPHz852ZOfQMUOf65Gb/Th8pRmp7fvg=
github.com/google/go-tpm-tools v0.0.0-20190906225433-1614c142f845/go.mod h1:AVfHadzbdzHo54inR2x1v640jdi1YSi3NauM2DUsxk0=
github.com/google/go-tpm-tools v0.2.0/go.mod h1:npUd03rQ60lxN7tzeBJreG38RvWwme2N1reF/eeiBk4=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@ -1044,6 +1052,8 @@ github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349 h1:Kq/3kL0k
github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
github.com/pomerium/csrf v1.7.0 h1:Qp4t6oyEod3svQtKfJZs589mdUTWKVf7q0PgCKYCshY=
github.com/pomerium/csrf v1.7.0/go.mod h1:hAPZV47mEj2T9xFs+ysbum4l7SF1IdrryYaY6PdoIqw=
github.com/pomerium/webauthn v0.0.0-20211014213840-422c7ce1077f h1:442shkoI4Oh4RHdzFaGma1t9Ji/T+8pfCxQQzmY5kj8=
github.com/pomerium/webauthn v0.0.0-20211014213840-422c7ce1077f/go.mod h1:wgH3ualWdXu/qwbhOoSQedXzco+38Iz7qKKGCJcKPXg=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
@ -1275,6 +1285,8 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@ -1575,6 +1587,7 @@ golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -1914,8 +1927,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=

View file

@ -0,0 +1,61 @@
package webauthnutil
import (
"context"
"github.com/btcsuite/btcutil/base58"
"github.com/pomerium/webauthn"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/device"
)
// CredentialStorage stores credentials in the databroker.
type CredentialStorage struct {
client databroker.DataBrokerServiceClient
}
// NewCredentialStorage creates a new CredentialStorage.
func NewCredentialStorage(client databroker.DataBrokerServiceClient) *CredentialStorage {
return &CredentialStorage{
client: client,
}
}
// GetCredential gets a credential from the databroker.
func (storage *CredentialStorage) GetCredential(
ctx context.Context,
credentialID []byte,
) (*webauthn.Credential, error) {
record, err := device.GetOwnerCredentialRecord(ctx, storage.client, credentialID)
if status.Code(err) == codes.NotFound {
return nil, webauthn.ErrCredentialNotFound
} else if err != nil {
return nil, err
}
return &webauthn.Credential{
ID: record.GetId(),
OwnerID: record.GetOwnerId(),
PublicKey: record.GetPublicKey(),
}, nil
}
// SetCredential sets the credential for the enrollment.
func (storage *CredentialStorage) SetCredential(
ctx context.Context,
credential *webauthn.Credential,
) error {
record := &device.OwnerCredentialRecord{
Id: credential.ID,
OwnerId: credential.OwnerID,
PublicKey: credential.PublicKey,
}
return device.PutOwnerCredentialRecord(ctx, storage.client, record)
}
// GetDeviceCredentialID gets the device credential id from a public key credential id.
func GetDeviceCredentialID(credentialID []byte) string {
return base58.Encode(credentialID)
}

View file

@ -0,0 +1,60 @@
package webauthnutil
import (
"context"
"testing"
"github.com/pomerium/webauthn"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
)
type mockDataBrokerServiceClient struct {
databroker.DataBrokerServiceClient
get func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error)
put func(ctx context.Context, in *databroker.PutRequest, opts ...grpc.CallOption) (*databroker.PutResponse, error)
}
func (m mockDataBrokerServiceClient) Get(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
return m.get(ctx, in, opts...)
}
func (m mockDataBrokerServiceClient) Put(ctx context.Context, in *databroker.PutRequest, opts ...grpc.CallOption) (*databroker.PutResponse, error) {
return m.put(ctx, in, opts...)
}
func TestCredentialStorage(t *testing.T) {
m := map[string]*databroker.Record{}
client := &mockDataBrokerServiceClient{
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
record, ok := m[in.GetType()+"/"+in.GetId()]
if !ok {
return nil, status.Error(codes.NotFound, "record not found")
}
return &databroker.GetResponse{
Record: record,
}, nil
},
put: func(ctx context.Context, in *databroker.PutRequest, opts ...grpc.CallOption) (*databroker.PutResponse, error) {
m[in.GetRecord().GetType()+"/"+in.GetRecord().GetId()] = in.GetRecord()
return &databroker.PutResponse{
Record: in.GetRecord(),
}, nil
},
}
storage := NewCredentialStorage(client)
_, err := storage.GetCredential(context.Background(), []byte{0, 1, 2, 3, 4})
assert.ErrorIs(t, err, webauthn.ErrCredentialNotFound)
err = storage.SetCredential(context.Background(), &webauthn.Credential{
ID: []byte{0, 1, 2, 3, 4},
})
assert.NoError(t, err)
c, err := storage.GetCredential(context.Background(), []byte{0, 1, 2, 3, 4})
assert.NoError(t, err)
assert.Equal(t, []byte{0, 1, 2, 3, 4}, c.ID)
}

View file

@ -0,0 +1,52 @@
package webauthnutil
import (
"context"
"github.com/pomerium/webauthn/cose"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/device"
)
var predefinedDeviceTypes = map[string]*device.Type{
"default": {
Id: "default",
Name: "default",
Specifier: &device.Type_Webauthn{
Webauthn: &device.Type_WebAuthn{
Options: &device.WebAuthnOptions{
Attestation: device.WebAuthnOptions_DIRECT.Enum(),
AuthenticatorSelection: &device.WebAuthnOptions_AuthenticatorSelectionCriteria{
UserVerification: device.WebAuthnOptions_USER_VERIFICATION_PREFERRED.Enum(),
},
PubKeyCredParams: []*device.WebAuthnOptions_PublicKeyCredentialParameters{
{Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmES256)},
{Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmRS256)},
{Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmRS1)},
},
},
},
},
},
}
// GetDeviceType gets the device type from the databroker. If the device type does not exist in the databroker
// a pre-defined device type may be returned.
func GetDeviceType(
ctx context.Context,
client databroker.DataBrokerServiceClient,
deviceTypeID string,
) (*device.Type, error) {
deviceType, err := device.GetType(ctx, client, deviceTypeID)
if status.Code(err) == codes.NotFound {
var ok bool
deviceType, ok = predefinedDeviceTypes[deviceTypeID]
if ok {
err = nil
}
}
return deviceType, err
}

View file

@ -0,0 +1,61 @@
package webauthnutil
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/device"
)
func TestGetDeviceType(t *testing.T) {
ctx := context.Background()
t.Run("from databroker", func(t *testing.T) {
client := &mockDataBrokerServiceClient{
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
assert.Equal(t, "type.googleapis.com/pomerium.device.Type", in.GetType())
assert.Equal(t, "default", in.GetId())
any, _ := anypb.New(&device.Type{
Id: "default",
Name: "Example",
})
return &databroker.GetResponse{
Record: &databroker.Record{
Type: in.GetType(),
Id: in.GetId(),
Data: any,
},
}, nil
},
}
deviceType, err := GetDeviceType(ctx, client, "default")
assert.NoError(t, err)
assert.Equal(t, "Example", deviceType.GetName())
})
t.Run("default", func(t *testing.T) {
client := &mockDataBrokerServiceClient{
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
return nil, status.Error(codes.NotFound, "not found")
},
}
deviceType, err := GetDeviceType(ctx, client, "default")
assert.NoError(t, err)
assert.Equal(t, "default", deviceType.GetName())
})
t.Run("not found", func(t *testing.T) {
client := &mockDataBrokerServiceClient{
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
return nil, status.Error(codes.NotFound, "not found")
},
}
_, err := GetDeviceType(ctx, client, "example")
assert.Error(t, err)
})
}

View file

@ -0,0 +1,35 @@
package webauthnutil
import (
"time"
"github.com/google/uuid"
"github.com/pomerium/pomerium/pkg/cryptutil"
)
// NewEnrollmentToken creates a new EnrollmentToken.
func NewEnrollmentToken(key []byte, ttl time.Duration, deviceEnrollmentID string) (string, error) {
id, err := uuid.Parse(deviceEnrollmentID)
if err != nil {
return "", err
}
secureToken := cryptutil.GenerateSecureToken(key, time.Now().Add(ttl), cryptutil.Token(id))
return secureToken.String(), nil
}
// ParseAndVerifyEnrollmentToken parses and verifies an enrollment token
func ParseAndVerifyEnrollmentToken(key []byte, rawEnrollmentToken string) (string, error) {
secureToken, ok := cryptutil.SecureTokenFromString(rawEnrollmentToken)
if !ok {
return "", cryptutil.ErrInvalid
}
err := secureToken.Verify(key, time.Now())
if err != nil {
return "", err
}
return secureToken.Token().UUID().String(), nil
}

View file

@ -0,0 +1,18 @@
package webauthnutil
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestEnrollmentToken(t *testing.T) {
key := []byte{1, 2, 3}
deviceEnrollmentID := "19be0131-184e-4873-acab-2be79321c30b"
token, err := NewEnrollmentToken(key, time.Second*30, deviceEnrollmentID)
assert.NoError(t, err)
id, err := ParseAndVerifyEnrollmentToken(key, token)
assert.NoError(t, err)
assert.Equal(t, deviceEnrollmentID, id)
}

292
pkg/webauthnutil/options.go Normal file
View file

@ -0,0 +1,292 @@
package webauthnutil
import (
"encoding/base64"
"fmt"
"time"
"github.com/pomerium/webauthn"
"github.com/pomerium/webauthn/cose"
"github.com/pomerium/pomerium/pkg/cryptutil"
"github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/pomerium/pkg/grpc/user"
)
const (
ceremonyTimeout = time.Minute * 15
rpName = "Pomerium"
)
// GenerateChallenge generates a new Challenge.
func GenerateChallenge(key []byte, expiry time.Time) cryptutil.SecureToken {
return cryptutil.GenerateSecureToken(key, expiry, cryptutil.NewRandomToken())
}
// GenerateCreationOptions generates creation options for WebAuthn.
func GenerateCreationOptions(
key []byte,
deviceType *device.Type,
user *user.User,
) *webauthn.PublicKeyCredentialCreationOptions {
expiry := time.Now().Add(ceremonyTimeout)
return newCreationOptions(
GenerateChallenge(key, expiry).Bytes(),
deviceType,
user,
)
}
// GenerateRequestOptions generates request options for WebAuthn.
func GenerateRequestOptions(
key []byte,
deviceType *device.Type,
knownDeviceCredentials []*device.Credential,
) *webauthn.PublicKeyCredentialRequestOptions {
expiry := time.Now().Add(ceremonyTimeout)
return newRequestOptions(
GenerateChallenge(key, expiry).Bytes(),
deviceType,
knownDeviceCredentials,
)
}
// GetCreationOptionsForCredential gets the creation options for the public key creation credential. An error may be
// returned if the challenge used to generate the credential is invalid.
func GetCreationOptionsForCredential(
key []byte,
deviceType *device.Type,
user *user.User,
credential *webauthn.PublicKeyCreationCredential,
) (*webauthn.PublicKeyCredentialCreationOptions, error) {
clientData, err := credential.Response.UnmarshalClientData()
if err != nil {
return nil, fmt.Errorf("invalid client data: %w", err)
}
rawChallenge, err := base64.RawURLEncoding.DecodeString(clientData.Challenge)
if err != nil {
return nil, fmt.Errorf("invalid challenge: %w", err)
}
var challenge cryptutil.SecureToken
copy(challenge[:], rawChallenge)
err = challenge.Verify(key, time.Now())
if err != nil {
return nil, err
}
return newCreationOptions(challenge.Bytes(), deviceType, user), nil
}
// GetRequestOptionsForCredential gets the request options for the public key request credential. An error may be
// returned if the challenge used to generate the credential is invalid.
func GetRequestOptionsForCredential(
key []byte,
deviceType *device.Type,
knownDeviceCredentials []*device.Credential,
credential *webauthn.PublicKeyAssertionCredential,
) (*webauthn.PublicKeyCredentialRequestOptions, error) {
clientData, err := credential.Response.UnmarshalClientData()
if err != nil {
return nil, fmt.Errorf("invalid client data: %w", err)
}
rawChallenge, err := base64.RawURLEncoding.DecodeString(clientData.Challenge)
if err != nil {
return nil, fmt.Errorf("invalid challenge: %w", err)
}
var challenge cryptutil.SecureToken
copy(challenge[:], rawChallenge)
err = challenge.Verify(key, time.Now())
if err != nil {
return nil, err
}
return newRequestOptions(challenge.Bytes(), deviceType, knownDeviceCredentials), nil
}
// newCreationOptions gets the creation options for WebAuthn with the provided challenge.
func newCreationOptions(
challenge []byte,
deviceType *device.Type,
user *user.User,
) *webauthn.PublicKeyCredentialCreationOptions {
options := &webauthn.PublicKeyCredentialCreationOptions{
RP: webauthn.PublicKeyCredentialRPEntity{
Name: rpName,
},
User: GetUserEntity(user),
Challenge: challenge,
Timeout: ceremonyTimeout,
}
if deviceOptions := deviceType.GetWebauthn().GetOptions(); deviceOptions != nil {
fillAllPublicKeyCredentialParameters(options, deviceOptions.GetPubKeyCredParams())
fillAuthenticatorSelection(options, deviceOptions.GetAuthenticatorSelection())
fillAttestationConveyance(options, deviceOptions.Attestation)
}
return options
}
// newRequestOptions gets the request options for WebAuthn with the provided challenge.
func newRequestOptions(
challenge []byte,
deviceType *device.Type,
knownDeviceCredentials []*device.Credential,
) *webauthn.PublicKeyCredentialRequestOptions {
options := &webauthn.PublicKeyCredentialRequestOptions{
Challenge: challenge,
Timeout: ceremonyTimeout,
}
fillRequestUserVerificationRequirement(
options,
deviceType.GetWebauthn().GetOptions().GetAuthenticatorSelection().UserVerification,
)
for _, knownDeviceCredential := range knownDeviceCredentials {
if publicKey := knownDeviceCredential.GetWebauthn(); publicKey != nil {
options.AllowCredentials = append(options.AllowCredentials, webauthn.PublicKeyCredentialDescriptor{
Type: webauthn.PublicKeyCredentialTypePublicKey,
ID: publicKey.GetId(),
})
}
}
return options
}
func fillAllPublicKeyCredentialParameters(
options *webauthn.PublicKeyCredentialCreationOptions,
allDeviceParams []*device.WebAuthnOptions_PublicKeyCredentialParameters,
) {
options.PubKeyCredParams = nil
for _, deviceParams := range allDeviceParams {
p := webauthn.PublicKeyCredentialParameters{}
fillPublicKeyCredentialParameters(&p, deviceParams)
options.PubKeyCredParams = append(options.PubKeyCredParams, p)
}
}
func fillAttestationConveyance(
options *webauthn.PublicKeyCredentialCreationOptions,
attestationConveyance *device.WebAuthnOptions_AttestationConveyancePreference,
) {
options.Attestation = ""
if attestationConveyance == nil {
return
}
switch *attestationConveyance {
case device.WebAuthnOptions_NONE:
options.Attestation = webauthn.AttestationConveyanceNone
case device.WebAuthnOptions_INDIRECT:
options.Attestation = webauthn.AttestationConveyanceIndirect
case device.WebAuthnOptions_DIRECT:
options.Attestation = webauthn.AttestationConveyanceDirect
case device.WebAuthnOptions_ENTERPRISE:
options.Attestation = webauthn.AttestationConveyanceEnterprise
}
}
func fillAuthenticatorAttachment(
criteria *webauthn.AuthenticatorSelectionCriteria,
authenticatorAttachment *device.WebAuthnOptions_AuthenticatorAttachment,
) {
criteria.AuthenticatorAttachment = ""
if authenticatorAttachment == nil {
return
}
switch *authenticatorAttachment {
case device.WebAuthnOptions_CROSS_PLATFORM:
criteria.AuthenticatorAttachment = webauthn.AuthenticatorAttachmentCrossPlatform
case device.WebAuthnOptions_PLATFORM:
criteria.AuthenticatorAttachment = webauthn.AuthenticatorAttachmentPlatform
}
}
func fillAuthenticatorSelection(
options *webauthn.PublicKeyCredentialCreationOptions,
deviceCriteria *device.WebAuthnOptions_AuthenticatorSelectionCriteria,
) {
options.AuthenticatorSelection = new(webauthn.AuthenticatorSelectionCriteria)
fillAuthenticatorAttachment(options.AuthenticatorSelection, deviceCriteria.AuthenticatorAttachment)
fillResidentKeyRequirement(options.AuthenticatorSelection, deviceCriteria.ResidentKeyRequirement)
options.AuthenticatorSelection.RequireResidentKey = deviceCriteria.GetRequireResidentKey()
fillUserVerificationRequirement(options.AuthenticatorSelection, deviceCriteria.UserVerification)
}
func fillPublicKeyCredentialParameters(
params *webauthn.PublicKeyCredentialParameters,
deviceParams *device.WebAuthnOptions_PublicKeyCredentialParameters,
) {
params.Type = ""
params.COSEAlgorithmIdentifier = 0
if deviceParams == nil {
return
}
switch deviceParams.Type {
case device.WebAuthnOptions_PUBLIC_KEY:
params.Type = webauthn.PublicKeyCredentialTypePublicKey
}
params.COSEAlgorithmIdentifier = cose.Algorithm(deviceParams.GetAlg())
}
func fillRequestUserVerificationRequirement(
options *webauthn.PublicKeyCredentialRequestOptions,
userVerificationRequirement *device.WebAuthnOptions_UserVerificationRequirement,
) {
options.UserVerification = ""
if userVerificationRequirement == nil {
return
}
switch *userVerificationRequirement {
case device.WebAuthnOptions_USER_VERIFICATION_DISCOURAGED:
options.UserVerification = webauthn.UserVerificationDiscouraged
case device.WebAuthnOptions_USER_VERIFICATION_PREFERRED:
options.UserVerification = webauthn.UserVerificationPreferred
case device.WebAuthnOptions_USER_VERIFICATION_REQUIRED:
options.UserVerification = webauthn.UserVerificationRequired
}
}
func fillResidentKeyRequirement(
criteria *webauthn.AuthenticatorSelectionCriteria,
residentKeyRequirement *device.WebAuthnOptions_ResidentKeyRequirement,
) {
criteria.ResidentKey = ""
if residentKeyRequirement == nil {
return
}
switch *residentKeyRequirement {
case device.WebAuthnOptions_RESIDENT_KEY_DISCOURAGED:
criteria.ResidentKey = webauthn.ResidentKeyDiscouraged
case device.WebAuthnOptions_RESIDENT_KEY_PREFERRED:
criteria.ResidentKey = webauthn.ResidentKeyPreferred
case device.WebAuthnOptions_RESIDENT_KEY_REQUIRED:
criteria.ResidentKey = webauthn.ResidentKeyRequired
}
}
func fillUserVerificationRequirement(
criteria *webauthn.AuthenticatorSelectionCriteria,
userVerificationRequirement *device.WebAuthnOptions_UserVerificationRequirement,
) {
criteria.UserVerification = ""
if userVerificationRequirement == nil {
return
}
switch *userVerificationRequirement {
case device.WebAuthnOptions_USER_VERIFICATION_DISCOURAGED:
criteria.UserVerification = webauthn.UserVerificationDiscouraged
case device.WebAuthnOptions_USER_VERIFICATION_PREFERRED:
criteria.UserVerification = webauthn.UserVerificationPreferred
case device.WebAuthnOptions_USER_VERIFICATION_REQUIRED:
criteria.UserVerification = webauthn.UserVerificationRequired
}
}

View file

@ -0,0 +1,175 @@
package webauthnutil
import (
"testing"
"github.com/pomerium/webauthn"
"github.com/pomerium/webauthn/cose"
"github.com/stretchr/testify/assert"
"github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/pomerium/pkg/grpc/user"
)
func TestGenerateCreationOptions(t *testing.T) {
t.Run("random challenge", func(t *testing.T) {
key := []byte{1, 2, 3}
options1 := GenerateCreationOptions(key, predefinedDeviceTypes["default"], &user.User{
Id: "example",
Email: "test@example.com",
Name: "Test User",
})
options2 := GenerateCreationOptions(key, predefinedDeviceTypes["default"], &user.User{
Id: "example",
Email: "test@example.com",
Name: "Test User",
})
assert.NotEqual(t, options1.Challenge, options2.Challenge)
})
t.Run("default", func(t *testing.T) {
key := []byte{1, 2, 3}
options := GenerateCreationOptions(key, predefinedDeviceTypes["default"], &user.User{
Id: "example",
Email: "test@example.com",
Name: "Test User",
})
options.Challenge = nil
assert.Equal(t, &webauthn.PublicKeyCredentialCreationOptions{
RP: webauthn.PublicKeyCredentialRPEntity{
Name: "Pomerium",
},
User: webauthn.PublicKeyCredentialUserEntity{
ID: []byte{
0x14, 0x7b, 0x2e, 0x3b, 0xae, 0x95, 0x5b, 0x99,
0xbb, 0x4e, 0x89, 0xdd, 0x03, 0xac, 0xae, 0x1d,
},
DisplayName: "Test User",
Name: "test@example.com",
},
Challenge: nil,
PubKeyCredParams: []webauthn.PublicKeyCredentialParameters{
{Type: "public-key", COSEAlgorithmIdentifier: -7},
{Type: "public-key", COSEAlgorithmIdentifier: -257},
{Type: "public-key", COSEAlgorithmIdentifier: -65535},
},
Timeout: 900000000000,
ExcludeCredentials: nil,
AuthenticatorSelection: &webauthn.AuthenticatorSelectionCriteria{
UserVerification: "preferred",
},
Attestation: "direct",
}, options)
})
}
func TestGenerateRequestOptions(t *testing.T) {
t.Run("random challenge", func(t *testing.T) {
key := []byte{1, 2, 3}
options1 := GenerateRequestOptions(key, predefinedDeviceTypes["default"], nil)
options2 := GenerateRequestOptions(key, predefinedDeviceTypes["default"], nil)
assert.NotEqual(t, options1.Challenge, options2.Challenge)
})
t.Run("default", func(t *testing.T) {
key := []byte{1, 2, 3}
options := GenerateRequestOptions(key, predefinedDeviceTypes["default"], []*device.Credential{
{Id: "device1", Specifier: &device.Credential_Webauthn{Webauthn: &device.Credential_WebAuthn{
Id: []byte{4, 5, 6},
}}},
})
options.Challenge = nil
assert.Equal(t, &webauthn.PublicKeyCredentialRequestOptions{
Timeout: 900000000000,
AllowCredentials: []webauthn.PublicKeyCredentialDescriptor{
{Type: "public-key", ID: []byte{4, 5, 6}},
},
UserVerification: "preferred",
}, options)
})
}
func TestFillAttestationConveyance(t *testing.T) {
for _, testCase := range []struct {
expect webauthn.AttestationConveyancePreference
in *device.WebAuthnOptions_AttestationConveyancePreference
}{
{"", nil},
{"none", device.WebAuthnOptions_NONE.Enum()},
{"indirect", device.WebAuthnOptions_INDIRECT.Enum()},
{"direct", device.WebAuthnOptions_DIRECT.Enum()},
{"enterprise", device.WebAuthnOptions_ENTERPRISE.Enum()},
} {
options := new(webauthn.PublicKeyCredentialCreationOptions)
fillAttestationConveyance(options, testCase.in)
actual := options.Attestation
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
}
}
func TestFillAuthenticatorSelection(t *testing.T) {
for _, testCase := range []struct {
expect webauthn.AuthenticatorAttachment
in *device.WebAuthnOptions_AuthenticatorAttachment
}{
{"", nil},
{"cross-platform", device.WebAuthnOptions_CROSS_PLATFORM.Enum()},
{"platform", device.WebAuthnOptions_PLATFORM.Enum()},
} {
criteria := new(webauthn.AuthenticatorSelectionCriteria)
fillAuthenticatorAttachment(criteria, testCase.in)
actual := criteria.AuthenticatorAttachment
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
}
}
func TestFillPublicKeyCredentialParameters(t *testing.T) {
for _, testCase := range []struct {
expectedType webauthn.PublicKeyCredentialType
expectedAlgorithm cose.Algorithm
in *device.WebAuthnOptions_PublicKeyCredentialParameters
}{
{"", 0, nil},
{"public-key", -7, &device.WebAuthnOptions_PublicKeyCredentialParameters{
Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: -7}},
} {
params := new(webauthn.PublicKeyCredentialParameters)
fillPublicKeyCredentialParameters(params, testCase.in)
actualType := params.Type
assert.Equal(t, testCase.expectedType, actualType, "expected %v for %v", testCase.expectedType, testCase.in)
actualAlgorithm := params.COSEAlgorithmIdentifier
assert.Equal(t, testCase.expectedAlgorithm, actualAlgorithm, "expected %v for %v", testCase.expectedType, testCase.in)
}
}
func TestFillResidentKeyRequirement(t *testing.T) {
for _, testCase := range []struct {
expect webauthn.ResidentKeyType
in *device.WebAuthnOptions_ResidentKeyRequirement
}{
{"", nil},
{"discouraged", device.WebAuthnOptions_RESIDENT_KEY_DISCOURAGED.Enum()},
{"preferred", device.WebAuthnOptions_RESIDENT_KEY_PREFERRED.Enum()},
{"required", device.WebAuthnOptions_RESIDENT_KEY_REQUIRED.Enum()},
} {
criteria := new(webauthn.AuthenticatorSelectionCriteria)
fillResidentKeyRequirement(criteria, testCase.in)
actual := criteria.ResidentKey
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
}
}
func TestFillUserVerificationRequirement(t *testing.T) {
for _, testCase := range []struct {
expect webauthn.UserVerificationRequirement
in *device.WebAuthnOptions_UserVerificationRequirement
}{
{"", nil},
{"discouraged", device.WebAuthnOptions_USER_VERIFICATION_DISCOURAGED.Enum()},
{"preferred", device.WebAuthnOptions_USER_VERIFICATION_PREFERRED.Enum()},
{"required", device.WebAuthnOptions_USER_VERIFICATION_REQUIRED.Enum()},
} {
criteria := new(webauthn.AuthenticatorSelectionCriteria)
fillUserVerificationRequirement(criteria, testCase.in)
actual := criteria.UserVerification
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
}
}

48
pkg/webauthnutil/user.go Normal file
View file

@ -0,0 +1,48 @@
package webauthnutil
import (
"github.com/google/uuid"
"github.com/pomerium/webauthn"
"github.com/pomerium/pomerium/pkg/grpc/user"
)
var pomeriumUserNamespace = uuid.MustParse("2929d3f7-f0b0-478f-9dd5-970d51eb3859")
// GetUserEntity gets the PublicKeyCredentialUserEntity from a Pomerium user.
func GetUserEntity(pomeriumUser *user.User) webauthn.PublicKeyCredentialUserEntity {
name := pomeriumUser.GetEmail()
if name == "" {
name = pomeriumUser.GetId()
}
displayName := pomeriumUser.GetName()
if displayName == "" {
displayName = name
}
return webauthn.PublicKeyCredentialUserEntity{
ID: GetUserEntityID(pomeriumUser.GetId()),
DisplayName: displayName,
Name: name,
}
}
// GetUserEntityID gets the UserEntity ID.
//
// The WebAuthn spec states:
//
// > The user handle of the user account entity. 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].
// >
// > The user handle MUST NOT contain personally identifying information about the user, such as a username or e-mail
// > address; see §14.6.1 User Handle Contents for details. The user handle MUST NOT be empty, though it MAY be
// > null.
//
// To meet these requirements we hash the user ID (since it's often an email address in the IdP) using a UUID v5 in a
// custom UUID namespace: 2929d3f7-f0b0-478f-9dd5-970d51eb3859.
func GetUserEntityID(pomeriumUserID string) []byte {
id := uuid.NewSHA1(pomeriumUserNamespace, []byte(pomeriumUserID))
return id[:]
}

View file

@ -0,0 +1,48 @@
package webauthnutil
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/pomerium/pomerium/pkg/grpc/user"
)
func TestGetUserEntity(t *testing.T) {
t.Run("name as email", func(t *testing.T) {
ue := GetUserEntity(&user.User{
Id: "test",
Email: "test@example.com",
})
assert.Equal(t, "test@example.com", ue.Name)
})
t.Run("name as id", func(t *testing.T) {
ue := GetUserEntity(&user.User{
Id: "test",
})
assert.Equal(t, "test", ue.Name)
})
t.Run("displayName as name", func(t *testing.T) {
ue := GetUserEntity(&user.User{
Id: "test",
Name: "Test User",
})
assert.Equal(t, "Test User", ue.DisplayName)
})
t.Run("displayName as email", func(t *testing.T) {
ue := GetUserEntity(&user.User{
Id: "test",
Email: "test@example.com",
})
assert.Equal(t, "test@example.com", ue.DisplayName)
})
}
func TestGetUserEntityID(t *testing.T) {
userID := "test@example.com"
rawUserEntityID := GetUserEntityID(userID)
userEntityUUID, err := uuid.FromBytes(rawUserEntityID)
assert.NoError(t, err, "should return a UUID")
assert.Equal(t, "8c0ac353-406f-5c08-845d-b72779779a42", userEntityUUID.String())
}

View file

@ -0,0 +1,2 @@
// Package webauthnutil contains types and functions for working with the webauthn package.
package webauthnutil