Skip to content

Commit

Permalink
feat(webauthn): add login option to manually set challenge (#359)
Browse files Browse the repository at this point in the history
The WithChallenge login option customizes the challenge sent to the client. Can be used as a form of document signing method.

Closes #353
  • Loading branch information
Daedaluz authored Dec 22, 2024
1 parent 4e6350f commit 3a57554
Show file tree
Hide file tree
Showing 2 changed files with 55 additions and 12 deletions.
32 changes: 25 additions & 7 deletions webauthn/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,8 @@ func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protoco
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
}

challenge, err := protocol.CreateChallenge()
if err != nil {
return nil, nil, err
}

assertion = &protocol.CredentialAssertion{
Response: protocol.PublicKeyCredentialRequestOptions{
Challenge: challenge,
RelyingPartyID: webauthn.Config.RPID,
UserVerification: webauthn.Config.AuthenticatorSelection.UserVerification,
AllowedCredentials: allowedCredentials,
Expand All @@ -74,6 +68,18 @@ func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protoco
opt(&assertion.Response)
}

if len(assertion.Response.Challenge) == 0 {
challenge, err := protocol.CreateChallenge()
if err != nil {
return nil, nil, err
}
assertion.Response.Challenge = challenge
}

if len(assertion.Response.Challenge) < 16 {
return nil, nil, fmt.Errorf("error generating assertion: the challenge must be at least 16 bytes")
}

if len(assertion.Response.RelyingPartyID) == 0 {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id must be provided via the configuration or a functional option for a login")
} else if _, err = url.Parse(assertion.Response.RelyingPartyID); err != nil {
Expand All @@ -90,7 +96,7 @@ func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protoco
}

session = &SessionData{
Challenge: challenge.String(),
Challenge: assertion.Response.Challenge.String(),
RelyingPartyID: assertion.Response.RelyingPartyID,
UserID: userID,
AllowedCredentialIDs: assertion.Response.GetAllowedCredentialIDs(),
Expand Down Expand Up @@ -165,6 +171,18 @@ func WithLoginRelyingPartyID(id string) LoginOption {
}
}

// WithChallenge overrides the default random challenge with a user supplied value.
// In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible.
// Challenges SHOULD therefore be at least 16 bytes long.
// This function is EXPERIMENTAL and can be removed without warning.
//
// Specification: §13.4.3. Cryptographic Challenges (https://www.w3.org/TR/webauthn/#sctn-cryptographic-challenges)
func WithChallenge(challenge []byte) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Challenge = challenge
}
}

// FinishLogin takes the response from the client and validate it against the user credentials and stored session data.
func (webauthn *WebAuthn) FinishLogin(user User, session SessionData, response *http.Request) (*Credential, error) {
parsedResponse, err := protocol.ParseCredentialRequestResponse(response)
Expand Down
35 changes: 30 additions & 5 deletions webauthn/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ func TestLogin_FinishLoginFailure(t *testing.T) {

func TestWithLoginRelyingPartyID(t *testing.T) {
testCases := []struct {
name string
have *Config
opts []LoginOption
expectedID string
err string
name string
have *Config
opts []LoginOption
expectedID string
expectedChallenge []byte
err string
}{
{
name: "OptionDefinedInConfig",
Expand Down Expand Up @@ -84,6 +85,27 @@ func TestWithLoginRelyingPartyID(t *testing.T) {
opts: nil,
err: "error generating assertion: the relying party id must be provided via the configuration or a functional option for a login",
},
{
name: "TooShortWithChallengeOption",
have: &Config{
RPID: "https://example.com",
RPOrigins: []string{"https://example.com"},
RPDisplayName: "Test Display Name",
},
opts: []LoginOption{WithChallenge([]byte("1234567890"))},
err: "error generating assertion: the challenge must be at least 16 bytes",
},
{
name: "WithChallengeOption",
have: &Config{
RPID: "https://example.com",
RPOrigins: []string{"https://example.com"},
RPDisplayName: "Test Display Name",
},
opts: []LoginOption{WithChallenge([]byte("00000000000000000000000000000000"))},
expectedID: "https://example.com",
expectedChallenge: []byte("00000000000000000000000000000000"),
},
}

for _, tc := range testCases {
Expand All @@ -104,6 +126,9 @@ func TestWithLoginRelyingPartyID(t *testing.T) {
assert.NoError(t, err)
require.NotNil(t, creation)
assert.Equal(t, tc.expectedID, creation.Response.RelyingPartyID)
if len(tc.expectedChallenge) > 0 {
assert.Equal(t, protocol.URLEncodedBase64(tc.expectedChallenge).String(), creation.Response.Challenge.String())
}
}
})
}
Expand Down

0 comments on commit 3a57554

Please sign in to comment.