NextMyDay/internal/core/auth/webauthn/webauthn.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

218 lines
7.1 KiB
Go

// Package webauthn wraps github.com/go-webauthn/webauthn behind a small
// interface so the enroll/login flows (SPEC-006) can be exercised without a
// browser. The ceremony is browser-bound, so tests inject a fake Service.
//
// The package persists nothing: it turns a begin/response pair into a
// Credential the caller stores (SPEC-004 §4 `webauthn_credentials`), keeping
// the storage decision in the auth service.
//
// Spec: docs/specs/spec-006-enroll-login.md §4/§5.
package webauthn
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-webauthn/webauthn/protocol"
lib "github.com/go-webauthn/webauthn/webauthn"
)
// Credential is the persisted WebAuthn credential (SPEC-004 §4). It is the
// subset of the library's Credential the core stores.
type Credential struct {
// ID is the credential id (primary key).
ID []byte
// PublicKey is the COSE-encoded credential public key.
PublicKey []byte
// SignCount is the authenticator's signature counter (clone detection).
SignCount uint32
// Transports are the authenticator transport hints, comma-joined at rest.
Transports []string
// AAGUID identifies the authenticator model (may be empty).
AAGUID []byte
}
// User is the Relying Party's user record for a ceremony. ID is the WebAuthn
// user handle (<= 64 bytes) — it must be stable for the life of the identity.
type User struct {
ID []byte
Name string
DisplayName string
}
// Begin pairs the browser options (JSON) with the server-side session data the
// finish step must carry. The flow serializes it into a signed cookie between
// the two calls.
type Begin struct {
// Options is the JSON for navigator.credentials.create/get.
Options json.RawMessage
// SessionData is the opaque server state (challenge, expiry, …).
SessionData []byte
}
// Service is WebAuthn behind an interface (browser-bound, so tests inject a
// fake). Finish* parse the response from r's body.
type Service interface {
BeginRegistration(ctx context.Context, u User, existing []Credential) (*Begin, error)
FinishRegistration(ctx context.Context, u User, b *Begin, r *http.Request) (Credential, error)
BeginLogin(ctx context.Context, u User, creds []Credential) (*Begin, error)
FinishLogin(ctx context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error)
}
// Config is the Relying Party configuration (derived from server.base_url).
type Config struct {
// RPID is the relying party id — the origin's host without scheme/port.
RPID string
// RPDisplayName is a human-readable name.
RPDisplayName string
// RPOrigins are the permitted origins, fully qualified.
RPOrigins []string
}
// impl is the real Service backed by go-webauthn.
type impl struct {
wa *lib.WebAuthn
}
// New builds the real WebAuthn service. It fails closed when the configuration
// is invalid (no origin, malformed RPID).
func New(cfg Config) (Service, error) {
if cfg.RPDisplayName == "" {
cfg.RPDisplayName = "MyDay"
}
wa, err := lib.New(&lib.Config{
RPID: cfg.RPID,
RPDisplayName: cfg.RPDisplayName,
RPOrigins: cfg.RPOrigins,
})
if err != nil {
return nil, fmt.Errorf("webauthn: config: %w", err)
}
return &impl{wa: wa}, nil
}
var _ Service = (*impl)(nil)
// BeginRegistration implements Service.
func (w *impl) BeginRegistration(_ context.Context, u User, existing []Credential) (*Begin, error) {
creation, session, err := w.wa.BeginRegistration(buildUser(u, existing))
if err != nil {
return nil, fmt.Errorf("webauthn: begin registration: %w", err)
}
return newBegin(creation, session)
}
// FinishRegistration implements Service.
func (w *impl) FinishRegistration(_ context.Context, u User, b *Begin, r *http.Request) (Credential, error) {
session, err := decodeSession(b)
if err != nil {
return Credential{}, err
}
cred, err := w.wa.FinishRegistration(buildUser(u, nil), session, r)
if err != nil {
return Credential{}, fmt.Errorf("webauthn: finish registration: %w", err)
}
return fromLib(cred), nil
}
// BeginLogin implements Service.
func (w *impl) BeginLogin(_ context.Context, u User, creds []Credential) (*Begin, error) {
assertion, session, err := w.wa.BeginLogin(buildUser(u, creds))
if err != nil {
return nil, fmt.Errorf("webauthn: begin login: %w", err)
}
return newBegin(assertion, session)
}
// FinishLogin implements Service.
func (w *impl) FinishLogin(_ context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error) {
session, err := decodeSession(b)
if err != nil {
return Credential{}, err
}
cred, err := w.wa.FinishLogin(buildUser(u, creds), session, r)
if err != nil {
return Credential{}, fmt.Errorf("webauthn: finish login: %w", err)
}
return fromLib(cred), nil
}
// newBegin marshals the options and the session data into a Begin.
func newBegin(options any, session *lib.SessionData) (*Begin, error) {
opts, err := json.Marshal(options)
if err != nil {
return nil, fmt.Errorf("webauthn: marshal options: %w", err)
}
sd, err := json.Marshal(session)
if err != nil {
return nil, fmt.Errorf("webauthn: marshal session: %w", err)
}
return &Begin{Options: opts, SessionData: sd}, nil
}
// decodeSession restores the library session data from a Begin.
func decodeSession(b *Begin) (lib.SessionData, error) {
if b == nil || len(b.SessionData) == 0 {
return lib.SessionData{}, fmt.Errorf("webauthn: missing ceremony state")
}
var session lib.SessionData
if err := json.Unmarshal(b.SessionData, &session); err != nil {
return lib.SessionData{}, fmt.Errorf("webauthn: decode session: %w", err)
}
return session, nil
}
// fromLib maps a library credential to the persisted subset.
func fromLib(c *lib.Credential) Credential {
if c == nil {
return Credential{}
}
transports := make([]string, 0, len(c.Transport))
for _, t := range c.Transport {
transports = append(transports, string(t))
}
return Credential{
ID: c.ID,
PublicKey: c.PublicKey,
SignCount: c.Authenticator.SignCount,
Transports: transports,
AAGUID: c.Authenticator.AAGUID,
}
}
// libUser adapts our User to the library's User interface.
type libUser struct {
id []byte
name string
displayName string
creds []lib.Credential
}
func (u libUser) WebAuthnID() []byte { return u.id }
func (u libUser) WebAuthnName() string { return u.name }
func (u libUser) WebAuthnDisplayName() string { return u.displayName }
func (u libUser) WebAuthnCredentials() []lib.Credential { return u.creds }
// buildUser adapts a User plus its stored credentials for the library.
func buildUser(u User, creds []Credential) libUser {
libCreds := make([]lib.Credential, 0, len(creds))
for _, c := range creds {
transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transports))
for _, t := range c.Transports {
transports = append(transports, protocol.AuthenticatorTransport(t))
}
libCreds = append(libCreds, lib.Credential{
ID: c.ID,
PublicKey: c.PublicKey,
Transport: transports,
Authenticator: lib.Authenticator{
AAGUID: c.AAGUID,
SignCount: c.SignCount,
},
})
}
return libUser{id: u.ID, name: u.Name, displayName: u.DisplayName, creds: libCreds}
}