From 3a57554407e0cf80d4c9249187529d34102bfddf Mon Sep 17 00:00:00 2001 From: Tobias Assarsson Date: Sun, 22 Dec 2024 21:51:48 +0100 Subject: [PATCH] feat(webauthn): add login option to manually set challenge (#359) The WithChallenge login option customizes the challenge sent to the client. Can be used as a form of document signing method. Closes #353 --- webauthn/login.go | 32 +++++++++++++++++++++++++------- webauthn/login_test.go | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/webauthn/login.go b/webauthn/login.go index 2132c8db..7122e979 100644 --- a/webauthn/login.go +++ b/webauthn/login.go @@ -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, @@ -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 { @@ -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(), @@ -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) diff --git a/webauthn/login_test.go b/webauthn/login_test.go index a869cf9e..098c913f 100644 --- a/webauthn/login_test.go +++ b/webauthn/login_test.go @@ -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", @@ -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 { @@ -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()) + } } }) }