Skip to content

Feature/webauthn passkeys - #5

Merged
raymondproguy merged 17 commits into
mainfrom
feature/webauthn-passkeys
Sep 3, 2026
Merged

Feature/webauthn passkeys#5
raymondproguy merged 17 commits into
mainfrom
feature/webauthn-passkeys

Conversation

@raymondproguy

Copy link
Copy Markdown
Contributor

No description provided.

Unlike TOTPSecret (one per user), a user can have several passkeys —
one per device/authenticator. CredentialData is the JSON-marshaled
form of the go-webauthn library's own Credential struct, stored as a
blob rather than decomposed into columns — that struct gains fields
as the library evolves, and a blob avoids the storage layer drifting
out of sync with it. CredentialID is denormalized out of that blob
purely so it's indexable without deserializing every row first.

Also adds webauthn_registered/webauthn_removed/webauthn_challenge_failed
audit event types.
For tests and local experimentation only, matching the existing
in-memory store conventions (not a supported production backend).
credential_data is passed to the driver as a string, not a raw
[]byte — lib/pq sends a []byte argument as bytea on the wire, which
Postgres won't implicitly cast into a jsonb column; a string argument
is sent as text, which jsonb's input parser reads correctly.
Unlike TOTPGenerator and Encryptor, this interface exposes
go-webauthn's own types directly (SessionData, webauthn.Credential,
protocol.CredentialCreation/Assertion) rather than hiding them behind
primitive strings — a WebAuthn ceremony is too rich to flatten into a
small custom vocabulary without reinventing a parallel API for no real
benefit. The public cryden facade still deals in plain []byte JSON at
its own boundary; these richer types stay internal to the engine.

v2 ships one implementation, GoWebAuthnProvider, wrapping
github.com/go-webauthn/webauthn — a WebAuthn ceremony has enough real
attack surface (origin/RP-ID validation, attestation formats,
signature-counter checks for cloned authenticators, challenge replay)
that hand-rolling it would be a serious security liability, unlike
TOTP where hand-rolling was a realistic option we chose not to take.

Adds github.com/go-webauthn/webauthn to go.mod. Its own transitive
dependencies aren't individually pinned here — run 'go mod tidy' after
pulling this branch; no network access to the Go module proxy was
available while authoring this change to do it here.
…cator

Uses github.com/descope/virtualwebauthn to drive an actual
cryptographically valid registration and login round trip through the
provider — the only way to exercise CreateCredential/ValidateLogin's
real success path; a hand-built fake response can only ever test
rejection.

Covers: registration produces a valid credential, a full login round
trip succeeds and advances the signature counter (the mechanism that
makes cloned-authenticator detection possible), and a garbage login
response is rejected.

Adds github.com/descope/virtualwebauthn to go.mod — only ever imported
from _test.go files and cmd/smoketest/webauthn-passkeys, never by the
engine itself.
Renamed now, before this branch and the TOTP branch it sits on are
tagged/released — no external code depends on the TOTP-only shape yet,
so there's no back-compat cost to unifying now versus carrying two
separate error types (one per second-factor method) forward.

ErrSecondFactorRequired{PendingToken, Methods []string} replaces
ErrTOTPRequired{PendingToken} — Methods lists which confirmed second
factors the account has ("totp", "webauthn", or both), so Login has
one method-agnostic pause state regardless of which method(s) an
account has enrolled, rather than a separate error per method.

Also adds ErrNoPasskeysEnrolled, ErrInvalidWebAuthnResponse, and
ErrInvalidCeremonyToken for the WebAuthn flows landing in the next
few commits.
Login takes a new webauthnStore param (nil-safe, same pattern as
totpStore). After password verification, it now collects ALL
confirmed second-factor methods — a confirmed TOTP secret AND/OR one
or more registered passkeys — into a single Methods list, and pauses
with *ErrSecondFactorRequired if that list is non-empty. An account
with both enrolled reports both; the caller decides which to prompt
for.

Updates auth/mfa.go's doc comment and existing lockout_test.go/
login_test.go/login_totp_test.go call sites for the new param and the
renamed error type.
- BeginRegisterPasskey/FinishRegisterPasskey: begin/finish ceremony for
  an already-authenticated user. The ceremony's challenge state
  (webauthn.SessionData) is JSON-encoded and encrypted with the same
  Encryptor used for TOTP secrets, handed back as an opaque ceremony
  token — no new ephemeral store needed, the engine stays fully
  stateless between the two calls.
- ListPasskeys/DeletePasskey: DeletePasskey requires the current
  password as re-confirmation, same reasoning as DisableTOTP,
  regardless of how many other factors remain enrolled afterward.
- BeginWebAuthnLogin/CompleteLoginWithWebAuthn: the passkey half of a
  paused login. Each call independently re-verifies pendingToken —
  callers must never assume ceremony state carries authentication
  state over implicitly. CompleteLoginWithWebAuthn persists the
  authenticator's updated signature counter after a successful login,
  which is what makes cloned-authenticator detection possible on a
  future login (the library rejects a non-advancing counter).

webauthnUser adapts a store.User plus their stored passkeys to the
go-webauthn library's own User interface; our UUIDv7 user IDs are used
directly as the library's opaque user handle.
Drives BeginRegisterPasskey/FinishRegisterPasskey/BeginWebAuthnLogin/
CompleteLoginWithWebAuthn through a real simulated authenticator
(virtualwebauthn) so the success paths are genuinely exercised, not
just the rejection paths a fake response would be limited to.

Covers: registration stores a credential with its nickname, a garbage
registration response is rejected, a tampered ceremony token is
rejected, delete requires the correct password, a full login
completion issues tokens, a garbage login response is rejected, and
an account with no registered passkeys can't begin a webauthn login.
Covers: WebAuthn-only enrollment reports Methods == ["webauthn"],
TOTP + WebAuthn both enrolled report both methods, and an account with
neither still issues tokens directly — confirms the unification in
Login didn't change behavior for accounts using only one method, or
neither.
- Config.WebAuthn (optional, store.WebAuthnCredentialStore),
  WebAuthnRPID/WebAuthnRPDisplayName/WebAuthnRPOrigins (all required
  together if WebAuthn is set). RPID is a genuine security parameter,
  not cosmetic — credentials are cryptographically bound to it.
- New() validates the RP fields are all set whenever WebAuthn is, and
  constructs the pending-login issuer and encryptor if EITHER TOTP or
  WebAuthn is configured (shared infrastructure between both methods —
  one EncryptionKey, two consumers).
- New facade functions: BeginRegisterPasskey, FinishRegisterPasskey,
  ListPasskeys, DeletePasskey, BeginWebAuthnLogin,
  CompleteLoginWithWebAuthn, each returning
  cryden.ErrWebAuthnNotConfigured if called without Config.WebAuthn
  set. New Passkey DTO for ListPasskeys — a storage-detail-free view
  (base64url credential ID, nickname, timestamps).
- Login's facade signature is unchanged; it now threads e.webauthn
  through to auth.Login internally alongside e.totp.
Also updates the TOTP section's error-handling example for the
ErrTOTPRequired -> ErrSecondFactorRequired rename.
Runnable end-to-end check with no database dependency: go run
./cmd/smoketest/webauthn-passkeys. Uses a real simulated authenticator
(virtualwebauthn) to exercise actual cryptographic verification, not
just rejection paths.

Walks: login before registration (direct), begin/finish registration
rejecting a garbage response first, listing the passkey, login after
registration (paused, reports Methods == ["webauthn"]), the login
ceremony's own begin/finish round trip rejecting a garbage response
and a tampered ceremony token, a successful completion, a real access
token rejected when used as a pending token, deleting the passkey
with wrong password rejected first, and login reverting to direct
afterward.
@raymondproguy
raymondproguy merged commit 1b9ce38 into main Sep 3, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant