Skip to content

Commit 600c003

Browse files
authored
feature: Add clientExtensionResults to attestation and assertion and transports to attestation responses for WebAuthn Level 3 compatibility (#80)
1 parent 449f319 commit 600c003

File tree

6 files changed

+212
-18
lines changed

6 files changed

+212
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Check the [test](test/webauthn_test.go) for a working example on how to use this
1212
- Generate [attestation](https://www.w3.org/TR/webauthn-2/#authenticatorattestationresponse) and [assertion](https://www.w3.org/TR/webauthn-2/#authenticatorassertionresponse) responses
1313
- Supports `EC2` and `RSA` keys with `SHA256`
1414
- Supports `packed` attestation format
15+
- Supports WebAuthn Level 3 compatible responses with `clientExtensionResults`
1516

1617
## Usage
1718

assertion.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@ func ParseAssertionOptions(str string) (assertionOptions *AssertionOptions, err
5151

5252
/// Response
5353

54+
// CreateAssertionResponse creates an assertion response with default empty clientExtensionResults
5455
func CreateAssertionResponse(rp RelyingParty, auth Authenticator, cred Credential, options AssertionOptions) string {
56+
return CreateAssertionResponseWithExtensions(rp, auth, cred, options, map[string]interface{}{})
57+
}
58+
59+
// CreateAssertionResponseWithExtensions creates an assertion response with custom clientExtensionResults
60+
func CreateAssertionResponseWithExtensions(rp RelyingParty, auth Authenticator, cred Credential, options AssertionOptions, clientExtensionResults map[string]interface{}) string {
5561
clientData := clientData{
5662
Type: "webauthn.get",
5763
Challenge: base64.RawURLEncoding.EncodeToString(options.Challenge),
@@ -101,10 +107,11 @@ func CreateAssertionResponse(rp RelyingParty, auth Authenticator, cred Credentia
101107
}
102108

103109
assertionResult := assertionResult{
104-
Type: "public-key",
105-
ID: credIDEncoded,
106-
RawID: credIDEncoded,
107-
Response: assertionResponse,
110+
Type: "public-key",
111+
ID: credIDEncoded,
112+
RawID: credIDEncoded,
113+
Response: assertionResponse,
114+
ClientExtensionResults: clientExtensionResults,
108115
}
109116

110117
assertionResultBytes, err := json.Marshal(assertionResult)
@@ -137,8 +144,9 @@ type assertionResponse struct {
137144
}
138145

139146
type assertionResult struct {
140-
Type string `json:"type"`
141-
ID string `json:"id"`
142-
RawID string `json:"rawId"`
143-
Response assertionResponse `json:"response"`
147+
Type string `json:"type"`
148+
ID string `json:"id"`
149+
RawID string `json:"rawId"`
150+
Response assertionResponse `json:"response"`
151+
ClientExtensionResults map[string]interface{} `json:"clientExtensionResults"`
144152
}

attestation.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ func ParseAttestationOptions(str string) (attestationOptions *AttestationOptions
6565

6666
/// Response
6767

68+
// CreateAttestationResponse creates an attestation response with default empty clientExtensionResults and Default Transports (Hybrid and Internal)
6869
func CreateAttestationResponse(rp RelyingParty, auth Authenticator, cred Credential, options AttestationOptions) string {
70+
return CreateAttestationResponseWithExtensions(rp, auth, cred, options, map[string]interface{}{}, []Transport{TransportHybrid, TransportInternal})
71+
}
72+
73+
// CreateAttestationResponseWithExtensions creates an attestation response with custom clientExtensionResults and transports
74+
func CreateAttestationResponseWithExtensions(rp RelyingParty, auth Authenticator, cred Credential, options AttestationOptions, clientExtensionResults map[string]interface{}, transports []Transport) string {
6975
clientData := clientData{
7076
Type: "webauthn.create",
7177
Challenge: base64.RawURLEncoding.EncodeToString(options.Challenge),
@@ -133,16 +139,20 @@ func CreateAttestationResponse(rp RelyingParty, auth Authenticator, cred Credent
133139

134140
credIDEncoded := base64.RawURLEncoding.EncodeToString(cred.ID)
135141

142+
translatedTransports := translateTransports(transports)
143+
136144
attestationResponse := attestationResponse{
137145
AttestationObject: attestationObjectEncoded,
138146
ClientDataJSON: clientDataJSONEncoded,
147+
Transports: translatedTransports,
139148
}
140149

141150
attestationResult := attestationResult{
142-
Type: "public-key",
143-
ID: credIDEncoded,
144-
RawID: credIDEncoded,
145-
Response: attestationResponse,
151+
Type: "public-key",
152+
ID: credIDEncoded,
153+
RawID: credIDEncoded,
154+
Response: attestationResponse,
155+
ClientExtensionResults: clientExtensionResults,
146156
}
147157

148158
attestationResultBytes, err := json.Marshal(attestationResult)
@@ -191,13 +201,15 @@ type attestationObject struct {
191201
}
192202

193203
type attestationResponse struct {
194-
AttestationObject string `json:"attestationObject"`
195-
ClientDataJSON string `json:"clientDataJSON"`
204+
AttestationObject string `json:"attestationObject"`
205+
ClientDataJSON string `json:"clientDataJSON"`
206+
Transports []string `json:"transports"`
196207
}
197208

198209
type attestationResult struct {
199-
Type string `json:"type"`
200-
ID string `json:"id"`
201-
RawID string `json:"rawId"`
202-
Response attestationResponse `json:"response"`
210+
Type string `json:"type"`
211+
ID string `json:"id"`
212+
RawID string `json:"rawId"`
213+
Response attestationResponse `json:"response"`
214+
ClientExtensionResults map[string]interface{} `json:"clientExtensionResults"`
203215
}

constants.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package virtualwebauthn
2+
3+
type Transport int
4+
5+
const (
6+
TransportUSB Transport = iota
7+
TransportNFC
8+
TransportBLE
9+
TransportSmartCard
10+
TransportHybrid
11+
TransportInternal
12+
)
13+
14+
var Transports = map[Transport]string{
15+
TransportUSB: "usb",
16+
TransportNFC: "nfc",
17+
TransportBLE: "ble",
18+
TransportSmartCard: "smart-card",
19+
TransportHybrid: "hybrid",
20+
TransportInternal: "internal",
21+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package test
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/descope/virtualwebauthn"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// TestAttestationClientExtensionResults verifies that attestation responses include
12+
// an empty clientExtensionResults map
13+
func TestAttestationClientExtensionResults(t *testing.T) {
14+
// Create a mock relying party, mock authenticator and a mock credential
15+
rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin}
16+
authenticator := virtualwebauthn.NewAuthenticator()
17+
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
18+
19+
// Start an attestation request
20+
attestation := startWebauthnRegister(t)
21+
22+
// Parse the attestation options
23+
attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options)
24+
require.NoError(t, err)
25+
require.NotNil(t, attestationOptions)
26+
27+
// Create an attestation response
28+
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions)
29+
require.NotEmpty(t, attestationResponse)
30+
31+
// Parse the response to verify clientExtensionResults
32+
var response map[string]interface{}
33+
err = json.Unmarshal([]byte(attestationResponse), &response)
34+
require.NoError(t, err)
35+
36+
// Verify clientExtensionResults exists
37+
clientExtensionResults, exists := response["clientExtensionResults"]
38+
require.True(t, exists, "clientExtensionResults should exist in attestation response")
39+
require.NotNil(t, clientExtensionResults, "clientExtensionResults should not be nil")
40+
41+
// Verify clientExtensionResults is an empty map
42+
clientExtensionResultsMap, ok := clientExtensionResults.(map[string]interface{})
43+
require.True(t, ok, "clientExtensionResults should be a map")
44+
require.Empty(t, clientExtensionResultsMap, "clientExtensionResults should be an empty map")
45+
}
46+
47+
// TestAssertionClientExtensionResults verifies that assertion responses include
48+
// the empty clientExtensionResults map
49+
func TestAssertionClientExtensionResults(t *testing.T) {
50+
// Create a mock relying party, mock authenticator and a mock credential
51+
rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin}
52+
authenticator := virtualwebauthn.NewAuthenticator()
53+
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
54+
55+
// Register the credential first
56+
attestation := startWebauthnRegister(t)
57+
attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options)
58+
require.NoError(t, err)
59+
60+
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions)
61+
webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse)
62+
63+
authenticator.Options.UserHandle = []byte(UserID)
64+
authenticator.AddCredential(cred)
65+
66+
// Start an assertion request
67+
assertion := startWebauthnLogin(t, webauthnCredential, cred.ID)
68+
69+
// Parse the assertion options
70+
assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options)
71+
require.NoError(t, err)
72+
require.NotNil(t, assertionOptions)
73+
74+
// Create an assertion response
75+
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions)
76+
require.NotEmpty(t, assertionResponse)
77+
78+
// Parse the response to verify clientExtensionResults
79+
var response map[string]interface{}
80+
err = json.Unmarshal([]byte(assertionResponse), &response)
81+
require.NoError(t, err)
82+
83+
// Verify clientExtensionResults exists
84+
clientExtensionResults, exists := response["clientExtensionResults"]
85+
require.True(t, exists, "clientExtensionResults should exist in assertion response")
86+
require.NotNil(t, clientExtensionResults, "clientExtensionResults should not be nil")
87+
88+
// Verify clientExtensionResults is an empty map
89+
clientExtensionResultsMap, ok := clientExtensionResults.(map[string]interface{})
90+
require.True(t, ok, "clientExtensionResults should be a map")
91+
require.Empty(t, clientExtensionResultsMap, "clientExtensionResults should be an empty map")
92+
}
93+
94+
// TestBothKeyTypesWithClientExtensionResults verifies that both EC2 and RSA key types
95+
// work correctly with clientExtensionResults
96+
func TestBothKeyTypesWithClientExtensionResults(t *testing.T) {
97+
// Test EC2 key
98+
t.Run("EC2 Key", func(t *testing.T) {
99+
testKeyTypeWithClientExtensionResults(t, virtualwebauthn.KeyTypeEC2)
100+
})
101+
102+
// Test RSA key
103+
t.Run("RSA Key", func(t *testing.T) {
104+
testKeyTypeWithClientExtensionResults(t, virtualwebauthn.KeyTypeRSA)
105+
})
106+
}
107+
108+
func testKeyTypeWithClientExtensionResults(t *testing.T, keyType virtualwebauthn.KeyType) {
109+
// Create a mock relying party, mock authenticator and a mock credential
110+
rp := virtualwebauthn.RelyingParty{Name: WebauthnDisplayName, ID: WebauthnDomain, Origin: WebauthnOrigin}
111+
authenticator := virtualwebauthn.NewAuthenticator()
112+
cred := virtualwebauthn.NewCredential(keyType)
113+
114+
// Test attestation
115+
attestation := startWebauthnRegister(t)
116+
attestationOptions, err := virtualwebauthn.ParseAttestationOptions(attestation.Options)
117+
require.NoError(t, err)
118+
119+
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOptions)
120+
webauthnCredential := finishWebauthnRegister(t, attestation, attestationResponse)
121+
122+
// Verify attestation response has clientExtensionResults
123+
var attestationResponseMap map[string]interface{}
124+
err = json.Unmarshal([]byte(attestationResponse), &attestationResponseMap)
125+
require.NoError(t, err)
126+
require.Contains(t, attestationResponseMap, "clientExtensionResults")
127+
128+
// Test assertion
129+
authenticator.Options.UserHandle = []byte(UserID)
130+
authenticator.AddCredential(cred)
131+
132+
assertion := startWebauthnLogin(t, webauthnCredential, cred.ID)
133+
assertionOptions, err := virtualwebauthn.ParseAssertionOptions(assertion.Options)
134+
require.NoError(t, err)
135+
136+
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOptions)
137+
finishWebauthnLogin(t, assertion, assertionResponse)
138+
139+
// Verify assertion response has clientExtensionResults
140+
var assertionResponseMap map[string]interface{}
141+
err = json.Unmarshal([]byte(assertionResponse), &assertionResponseMap)
142+
require.NoError(t, err)
143+
require.Contains(t, assertionResponseMap, "clientExtensionResults")
144+
}

utils.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,11 @@ func authenticatorDataFlags(userPresent, userVerified, backupEligible, backupSta
6565
}
6666
return flags
6767
}
68+
69+
func translateTransports(transports []Transport) []string {
70+
encodedTransports := make([]string, 0, len(transports))
71+
for _, t := range transports {
72+
encodedTransports = append(encodedTransports, Transports[t])
73+
}
74+
return encodedTransports
75+
}

0 commit comments

Comments
 (0)