diff --git a/go.mod b/go.mod index fa4417099..235d9d220 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 2b12395a2..cfcaacee8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/webauthnutil/credential_storage.go b/pkg/webauthnutil/credential_storage.go new file mode 100644 index 000000000..34e0518a3 --- /dev/null +++ b/pkg/webauthnutil/credential_storage.go @@ -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) +} diff --git a/pkg/webauthnutil/credential_storage_test.go b/pkg/webauthnutil/credential_storage_test.go new file mode 100644 index 000000000..fd776694d --- /dev/null +++ b/pkg/webauthnutil/credential_storage_test.go @@ -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) +} diff --git a/pkg/webauthnutil/device_type.go b/pkg/webauthnutil/device_type.go new file mode 100644 index 000000000..4e18b0d7f --- /dev/null +++ b/pkg/webauthnutil/device_type.go @@ -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 +} diff --git a/pkg/webauthnutil/device_type_test.go b/pkg/webauthnutil/device_type_test.go new file mode 100644 index 000000000..d064d1937 --- /dev/null +++ b/pkg/webauthnutil/device_type_test.go @@ -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) + }) +} diff --git a/pkg/webauthnutil/enrollment_token.go b/pkg/webauthnutil/enrollment_token.go new file mode 100644 index 000000000..2d67996b2 --- /dev/null +++ b/pkg/webauthnutil/enrollment_token.go @@ -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 +} diff --git a/pkg/webauthnutil/enrollment_token_test.go b/pkg/webauthnutil/enrollment_token_test.go new file mode 100644 index 000000000..5f0464297 --- /dev/null +++ b/pkg/webauthnutil/enrollment_token_test.go @@ -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) +} diff --git a/pkg/webauthnutil/options.go b/pkg/webauthnutil/options.go new file mode 100644 index 000000000..5348617ca --- /dev/null +++ b/pkg/webauthnutil/options.go @@ -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 + } +} diff --git a/pkg/webauthnutil/options_test.go b/pkg/webauthnutil/options_test.go new file mode 100644 index 000000000..fc8799ac8 --- /dev/null +++ b/pkg/webauthnutil/options_test.go @@ -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) + } +} diff --git a/pkg/webauthnutil/user.go b/pkg/webauthnutil/user.go new file mode 100644 index 000000000..d5e86178c --- /dev/null +++ b/pkg/webauthnutil/user.go @@ -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[:] +} diff --git a/pkg/webauthnutil/user_test.go b/pkg/webauthnutil/user_test.go new file mode 100644 index 000000000..f84876b9e --- /dev/null +++ b/pkg/webauthnutil/user_test.go @@ -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()) +} diff --git a/pkg/webauthnutil/webauthnutil.go b/pkg/webauthnutil/webauthnutil.go new file mode 100644 index 000000000..c46b62fe5 --- /dev/null +++ b/pkg/webauthnutil/webauthnutil.go @@ -0,0 +1,2 @@ +// Package webauthnutil contains types and functions for working with the webauthn package. +package webauthnutil