diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c4bbfb..3fcbcec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push env: - GO_VERSION: 1.21 + GO_VERSION: 1.24 jobs: build: diff --git a/README.md b/README.md index fec9122..7ae759c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Check the [test](test/webauthn_test.go) for a working example on how to use this - Generate [attestation](https://www.w3.org/TR/webauthn-2/#authenticatorattestationresponse) and [assertion](https://www.w3.org/TR/webauthn-2/#authenticatorassertionresponse) responses - Supports `EC2` and `RSA` keys with `SHA256` - Supports `packed` attestation format -- Supports WebAuthn Level 3 compatible responses with `clientExtensionResults` +- Supports WebAuthn Level 3 compatible responses with `clientExtensionResults` and `transports` ## Usage @@ -24,9 +24,15 @@ First we create mock entities to work with for running tests. // The relying party settings should mirror those on the actual WebAuthn server rp := virtualwebauthn.RelyingParty{Name: "Example Corp", ID: "example.com", Origin: "https://example.com"} -// A mock authenticator that represents a security key or biometrics module +// A mock authenticator that represents a platform authenticator (defaults to internal transport) authenticator := virtualwebauthn.NewAuthenticator() +// Optionally configure authenticator transports and client extension results +authenticator = virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportUSB, virtualwebauthn.TransportInternal}, + ClientExtensionResults: map[string]any{"credProps": map[string]any{"rk": true}}, +}) + // Create a new credential that we'll try to register with the relying party credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) ``` diff --git a/assertion.go b/assertion.go index 615ff96..d12ae5a 100644 --- a/assertion.go +++ b/assertion.go @@ -51,13 +51,7 @@ func ParseAssertionOptions(str string) (assertionOptions *AssertionOptions, err /// Response -// CreateAssertionResponse creates an assertion response with default empty clientExtensionResults func CreateAssertionResponse(rp RelyingParty, auth Authenticator, cred Credential, options AssertionOptions) string { - return CreateAssertionResponseWithExtensions(rp, auth, cred, options, map[string]interface{}{}) -} - -// CreateAssertionResponseWithExtensions creates an assertion response with custom clientExtensionResults -func CreateAssertionResponseWithExtensions(rp RelyingParty, auth Authenticator, cred Credential, options AssertionOptions, clientExtensionResults map[string]interface{}) string { clientData := clientData{ Type: "webauthn.get", Challenge: base64.RawURLEncoding.EncodeToString(options.Challenge), @@ -99,6 +93,11 @@ func CreateAssertionResponseWithExtensions(rp RelyingParty, auth Authenticator, credIDEncoded := base64.RawURLEncoding.EncodeToString(cred.ID) + clientExtensionResults := auth.Options.ClientExtensionResults + if clientExtensionResults == nil { + clientExtensionResults = map[string]any{} + } + assertionResponse := assertionResponse{ AuthenticatorData: authDataEncoded, ClientDataJSON: clientDataJSONEncoded, @@ -144,9 +143,9 @@ type assertionResponse struct { } type assertionResult struct { - Type string `json:"type"` - ID string `json:"id"` - RawID string `json:"rawId"` - Response assertionResponse `json:"response"` - ClientExtensionResults map[string]interface{} `json:"clientExtensionResults"` + Type string `json:"type"` + ID string `json:"id"` + RawID string `json:"rawId"` + Response assertionResponse `json:"response"` + ClientExtensionResults map[string]any `json:"clientExtensionResults"` } diff --git a/attestation.go b/attestation.go index 6901aaa..c2f32c4 100644 --- a/attestation.go +++ b/attestation.go @@ -65,13 +65,7 @@ func ParseAttestationOptions(str string) (attestationOptions *AttestationOptions /// Response -// CreateAttestationResponse creates an attestation response with default empty clientExtensionResults and Default Transports (Hybrid and Internal) func CreateAttestationResponse(rp RelyingParty, auth Authenticator, cred Credential, options AttestationOptions) string { - return CreateAttestationResponseWithExtensions(rp, auth, cred, options, map[string]interface{}{}, []Transport{TransportHybrid, TransportInternal}) -} - -// CreateAttestationResponseWithExtensions creates an attestation response with custom clientExtensionResults and transports -func CreateAttestationResponseWithExtensions(rp RelyingParty, auth Authenticator, cred Credential, options AttestationOptions, clientExtensionResults map[string]interface{}, transports []Transport) string { clientData := clientData{ Type: "webauthn.create", Challenge: base64.RawURLEncoding.EncodeToString(options.Challenge), @@ -139,8 +133,17 @@ func CreateAttestationResponseWithExtensions(rp RelyingParty, auth Authenticator credIDEncoded := base64.RawURLEncoding.EncodeToString(cred.ID) + transports := auth.Options.Transports + if len(transports) == 0 { + transports = []Transport{TransportInternal} + } translatedTransports := translateTransports(transports) + clientExtensionResults := auth.Options.ClientExtensionResults + if clientExtensionResults == nil { + clientExtensionResults = map[string]any{} + } + attestationResponse := attestationResponse{ AttestationObject: attestationObjectEncoded, ClientDataJSON: clientDataJSONEncoded, @@ -207,9 +210,9 @@ type attestationResponse struct { } type attestationResult struct { - Type string `json:"type"` - ID string `json:"id"` - RawID string `json:"rawId"` - Response attestationResponse `json:"response"` - ClientExtensionResults map[string]interface{} `json:"clientExtensionResults"` + Type string `json:"type"` + ID string `json:"id"` + RawID string `json:"rawId"` + Response attestationResponse `json:"response"` + ClientExtensionResults map[string]any `json:"clientExtensionResults"` } diff --git a/authenticator.go b/authenticator.go index c0d17fc..edfb58a 100644 --- a/authenticator.go +++ b/authenticator.go @@ -1,11 +1,13 @@ package virtualwebauthn type AuthenticatorOptions struct { - UserHandle []byte - UserNotPresent bool - UserNotVerified bool - BackupEligible bool - BackupState bool + UserHandle []byte + UserNotPresent bool + UserNotVerified bool + BackupEligible bool + BackupState bool + Transports []Transport + ClientExtensionResults map[string]any } type Authenticator struct { diff --git a/go.mod b/go.mod index 3855c93..13042a8 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/descope/virtualwebauthn go 1.24.0 -toolchain go1.24.5 +toolchain go1.24.6 require ( github.com/fxamacker/cbor/v2 v2.9.0 diff --git a/test/client_extension_results_test.go b/test/client_extension_results_test.go deleted file mode 100644 index aac6b9e..0000000 --- a/test/client_extension_results_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package test - -import ( - "encoding/json" - "testing" - - "github.com/descope/virtualwebauthn" - "github.com/stretchr/testify/require" -) - -// TestAttestationClientExtensionResults verifies that attestation responses include -// an empty clientExtensionResults map -func TestAttestationClientExtensionResults(t *testing.T) { - // Create a mock relying party, mock authenticator and a mock credential - rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin} - authenticator := virtualwebauthn.NewAuthenticator() - cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) - - // Start an attestation request - attestation := startWebauthnRegister(t) - - // Parse the attestation options - attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options) - require.NoError(t, err) - require.NotNil(t, attestationOptions) - - // Create an attestation response - attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions) - require.NotEmpty(t, attestationResponse) - - // Parse the response to verify clientExtensionResults - var response map[string]interface{} - err = json.Unmarshal([]byte(attestationResponse), &response) - require.NoError(t, err) - - // Verify clientExtensionResults exists - clientExtensionResults, exists := response["clientExtensionResults"] - require.True(t, exists, "clientExtensionResults should exist in attestation response") - require.NotNil(t, clientExtensionResults, "clientExtensionResults should not be nil") - - // Verify clientExtensionResults is an empty map - clientExtensionResultsMap, ok := clientExtensionResults.(map[string]interface{}) - require.True(t, ok, "clientExtensionResults should be a map") - require.Empty(t, clientExtensionResultsMap, "clientExtensionResults should be an empty map") -} - -// TestAssertionClientExtensionResults verifies that assertion responses include -// the empty clientExtensionResults map -func TestAssertionClientExtensionResults(t *testing.T) { - // Create a mock relying party, mock authenticator and a mock credential - rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin} - authenticator := virtualwebauthn.NewAuthenticator() - cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) - - // Register the credential first - attestation := startWebauthnRegister(t) - attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options) - require.NoError(t, err) - - attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions) - webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse) - - authenticator.Options.UserHandle = []byte(UserID) - authenticator.AddCredential(cred) - - // Start an assertion request - assertion := startWebauthnLogin(t, webauthnCredential, cred.ID) - - // Parse the assertion options - assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options) - require.NoError(t, err) - require.NotNil(t, assertionOptions) - - // Create an assertion response - assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions) - require.NotEmpty(t, assertionResponse) - - // Parse the response to verify clientExtensionResults - var response map[string]interface{} - err = json.Unmarshal([]byte(assertionResponse), &response) - require.NoError(t, err) - - // Verify clientExtensionResults exists - clientExtensionResults, exists := response["clientExtensionResults"] - require.True(t, exists, "clientExtensionResults should exist in assertion response") - require.NotNil(t, clientExtensionResults, "clientExtensionResults should not be nil") - - // Verify clientExtensionResults is an empty map - clientExtensionResultsMap, ok := clientExtensionResults.(map[string]interface{}) - require.True(t, ok, "clientExtensionResults should be a map") - require.Empty(t, clientExtensionResultsMap, "clientExtensionResults should be an empty map") -} - -// TestBothKeyTypesWithClientExtensionResults verifies that both EC2 and RSA key types -// work correctly with clientExtensionResults -func TestBothKeyTypesWithClientExtensionResults(t *testing.T) { - // Test EC2 key - t.Run("EC2 Key", func(t *testing.T) { - testKeyTypeWithClientExtensionResults(t, virtualwebauthn.KeyTypeEC2) - }) - - // Test RSA key - t.Run("RSA Key", func(t *testing.T) { - testKeyTypeWithClientExtensionResults(t, virtualwebauthn.KeyTypeRSA) - }) -} - -func testKeyTypeWithClientExtensionResults(t *testing.T, keyType virtualwebauthn.KeyType) { - // Create a mock relying party, mock authenticator and a mock credential - rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin} - authenticator := virtualwebauthn.NewAuthenticator() - cred := virtualwebauthn.NewCredential(keyType) - - // Test attestation - attestation := startWebauthnRegister(t) - attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options) - require.NoError(t, err) - - attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions) - webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse) - - // Verify attestation response has clientExtensionResults - var attestationResponseMap map[string]interface{} - err = json.Unmarshal([]byte(attestationResponse), &attestationResponseMap) - require.NoError(t, err) - require.Contains(t, attestationResponseMap, "clientExtensionResults") - - // Test assertion - authenticator.Options.UserHandle = []byte(UserID) - authenticator.AddCredential(cred) - - assertion := startWebauthnLogin(t, webauthnCredential, cred.ID) - assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options) - require.NoError(t, err) - - assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions) - finishWebauthnLogin(t, assertion, assertionResponse) - - // Verify assertion response has clientExtensionResults - var assertionResponseMap map[string]interface{} - err = json.Unmarshal([]byte(assertionResponse), &assertionResponseMap) - require.NoError(t, err) - require.Contains(t, assertionResponseMap, "clientExtensionResults") -} diff --git a/test/extensions_test.go b/test/extensions_test.go new file mode 100644 index 0000000..363b13c --- /dev/null +++ b/test/extensions_test.go @@ -0,0 +1,117 @@ +package test + +import ( + "encoding/json" + "testing" + + "github.com/descope/virtualwebauthn" + "github.com/stretchr/testify/require" +) + +func TestDefaultClientExtensionResultsAndTransports(t *testing.T) { + rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin} + authenticator := virtualwebauthn.NewAuthenticator() + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + attestation := startWebauthnRegister(t) + attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options) + require.NoError(t, err) + + attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions) + var attestationJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(attestationResponse), &attestationJSON)) + + clientExtensionResults, exists := attestationJSON["clientExtensionResults"] + require.True(t, exists) + require.Equal(t, map[string]any{}, clientExtensionResults) + + transports, exists := attestationJSON["response"].(map[string]any)["transports"] + require.True(t, exists) + require.Equal(t, []any{"internal"}, transports) + + webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse) + authenticator.Options.UserHandle = []byte(UserID) + authenticator.AddCredential(cred) + + assertion := startWebauthnLogin(t, webauthnCredential, cred.ID) + assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options) + require.NoError(t, err) + + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions) + var assertionJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(assertionResponse), &assertionJSON)) + + clientExtensionResults, exists = assertionJSON["clientExtensionResults"] + require.True(t, exists) + require.Equal(t, map[string]any{}, clientExtensionResults) +} + +func TestCustomClientExtensionResults(t *testing.T) { + rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin} + authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + ClientExtensionResults: map[string]any{ + "credProps": map[string]any{"rk": true}, + }, + }) + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + attestation := startWebauthnRegister(t) + attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options) + require.NoError(t, err) + + attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions) + var attestationJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(attestationResponse), &attestationJSON)) + + credProps := attestationJSON["clientExtensionResults"].(map[string]any)["credProps"].(map[string]any) + require.Equal(t, true, credProps["rk"]) + + webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse) + authenticator.Options.UserHandle = []byte(UserID) + authenticator.AddCredential(cred) + + assertion := startWebauthnLogin(t, webauthnCredential, cred.ID) + assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options) + require.NoError(t, err) + + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions) + var assertionJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(assertionResponse), &assertionJSON)) + + credProps = assertionJSON["clientExtensionResults"].(map[string]any)["credProps"].(map[string]any) + require.Equal(t, true, credProps["rk"]) +} + +func TestCustomTransports(t *testing.T) { + rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin} + authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportUSB, virtualwebauthn.TransportInternal}, + }) + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + attestation := startWebauthnRegister(t) + attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options) + require.NoError(t, err) + + attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions) + var attestationJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(attestationResponse), &attestationJSON)) + + transports := attestationJSON["response"].(map[string]any)["transports"].([]any) + require.Equal(t, []any{"usb", "internal"}, transports) + + webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse) + authenticator.Options.UserHandle = []byte(UserID) + authenticator.AddCredential(cred) + + assertion := startWebauthnLogin(t, webauthnCredential, cred.ID) + assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options) + require.NoError(t, err) + + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions) + var assertionJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(assertionResponse), &assertionJSON)) + + _, exists := assertionJSON["response"].(map[string]any)["transports"] + require.False(t, exists, "transports should not appear in assertion responses") +} diff --git a/constants.go b/transport.go similarity index 89% rename from constants.go rename to transport.go index e8f4c1d..7737aca 100644 --- a/constants.go +++ b/transport.go @@ -11,7 +11,7 @@ const ( TransportInternal ) -var Transports = map[Transport]string{ +var transportNames = map[Transport]string{ TransportUSB: "usb", TransportNFC: "nfc", TransportBLE: "ble", diff --git a/utils.go b/utils.go index ce0d499..0e48ab2 100644 --- a/utils.go +++ b/utils.go @@ -67,9 +67,11 @@ func authenticatorDataFlags(userPresent, userVerified, backupEligible, backupSta } func translateTransports(transports []Transport) []string { - encodedTransports := make([]string, 0, len(transports)) + result := []string{} for _, t := range transports { - encodedTransports = append(encodedTransports, Transports[t]) + if name, ok := transportNames[t]; ok { + result = append(result, name) + } } - return encodedTransports + return result }