- 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
218 lines
7.1 KiB
Go
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}
|
|
}
|