ProofedMesh/services/approval-service/internal/webauthn/webauthn.go

226 lines
8 KiB
Go
Raw Permalink Normal View History

approval-service: Phase-1 content-binding approval service (DEV-608) New services/approval-service with the Session → Preview → Confirm pipeline required by DEV-606/DEV-608: * POST /api/v1/sessions starts a Pocket-ID-bound approval session. * POST /api/v1/approvals/preview renders content server-side, computes H_content = sha256(canonical JSON), stores the (nonce, salt, canonical, challenge) tuple and returns the human verification digits plus the WebAuthn challenge = nonce || H_content. * POST /api/v1/approvals/confirm verifies the WebAuthn assertion against the stored challenge, DSSE-signs the Approval statement via the signing-service (key_ref=trust-api/approval-signer-2026-q4), submits to the transparency-log, and returns {approval_id, attestation_ref, log_index}. TLog failures leave the row at attestation-pending for the reconciler. * db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables approval_sessions, approval_previews, approvals (schema matches the Phase 1 store contract). * internal/content: canonical JSON + H_content + 6-digit verification code (byte-compatible with signing-service/internal/canonical). * internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn. * internal/signer, internal/tlog: HTTP clients mapping 5xx / connection errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the HTTP layer can return 502 for signing failures and persist attestation-pending on TLog failure. * internal/service.Orchestrator ties the pipeline together and persists the Approval BEFORE the TLog submit (never return a log_index for an un-persisted approval). Tests cover: happy path, tampered H_content → 400, expired challenge → 403, signer 5xx + conn-refused → 502, TLog failure → row stays attestation-pending, duplicate confirm → 409, session expired → 403. go vet clean, go test ./... passes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 19:42:06 +00:00
// Package webauthn is the approval-service's Phase-1 WebAuthn verifier.
//
// It is NOT the full go-webauthn/webauthn library — Phase 1 only needs to
// verify assertions against mock credentials registered in-memory for the
// test agent. The data types on the wire (`client_data_json`,
// `authenticator_data`, `signature`, `credential_id`) stay WebAuthn
// compatible so a Phase-2 swap to github.com/go-webauthn/webauthn is a
// pure dependency change, not an API break for the browser.
//
// Threat model this implementation addresses:
//
// - The challenge the browser signs is bound to H_content via
// content.ChallengeFor (nonce || sha256(canonical content)). An
// attacker rewriting the preview payload after the human saw it would
// have to find a hash collision, so we check the stored challenge
// bytes-for-bytes.
// - The authenticator type field (`type` in clientDataJSON) MUST be
// "webauthn.get", so a reused registration ceremony signature is
// rejected.
// - The RP ID hash in authenticator_data MUST match our configured RP;
// otherwise an assertion scoped to a different origin would be accepted.
//
// What this implementation deliberately does NOT do:
//
// - It does not enforce user verification flags (UV bit). Phase 2 will.
// - It does not implement the WebAuthn attestation path (registration);
// credentials are inserted directly via Store.AddCredential.
// - It only knows about Ed25519 public keys — all our mock credentials
// use Ed25519. ES256/RS256 land with the library swap in Phase 2.
package webauthn
import (
"crypto/ed25519"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
)
// Assertion mirrors the WebAuthn PublicKeyCredential assertion response
// (base64url-encoded fields; raw bytes are decoded by Verify).
type Assertion struct {
CredentialID string `json:"credential_id"`
ClientDataJSON string `json:"client_data_json"`
AuthenticatorData string `json:"authenticator_data"`
Signature string `json:"signature"`
}
// Credential is a registered WebAuthn credential bound to a user sub.
type Credential struct {
ID string // base64url, matches assertion.credential_id
UserSub string // Pocket-ID subject
PublicKey ed25519.PublicKey // Phase 1: only Ed25519.
}
// Store is the in-memory credential registry.
type Store struct {
mu sync.RWMutex
creds map[string]Credential // key = CredentialID
}
// NewStore constructs an empty in-memory credential store.
func NewStore() *Store {
return &Store{creds: map[string]Credential{}}
}
// AddCredential registers c. Used by test agents and the Phase-2 passkey
// enrollment flow.
func (s *Store) AddCredential(c Credential) error {
if c.ID == "" {
return errors.New("webauthn: credential ID is empty")
}
if c.UserSub == "" {
return errors.New("webauthn: credential has no user sub")
}
if len(c.PublicKey) != ed25519.PublicKeySize {
return errors.New("webauthn: credential public key must be Ed25519 (32 bytes)")
}
s.mu.Lock()
defer s.mu.Unlock()
s.creds[c.ID] = c
return nil
}
// Get returns the credential registered under id.
func (s *Store) Get(id string) (Credential, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.creds[id]
return c, ok
}
// VerifyConfig configures Verify's RP and origin checks.
type VerifyConfig struct {
// RPID is the hostname the authenticator scoped the credential to.
// authenticator_data starts with sha256(RPID).
RPID string
// AllowedOrigins is the exact set of accepted browser origins (e.g.
// "https://trust.basicstack.de"). The assertion's clientDataJSON
// `origin` MUST be one of these.
AllowedOrigins []string
}
// ErrChallengeMismatch means the challenge in clientDataJSON did not match
// what the preview endpoint stored — i.e. H_content was tampered with.
var ErrChallengeMismatch = errors.New("webauthn: challenge in clientDataJSON does not match stored challenge")
// ErrCredentialUnknown means the asserted credential is not registered.
var ErrCredentialUnknown = errors.New("webauthn: credential not registered")
// ErrAssertionInvalid is returned for any lower-level verification failure
// (bad origin, wrong type, bad signature). The caller maps this to HTTP
// 401 to avoid leaking which specific check failed to a probing attacker.
var ErrAssertionInvalid = errors.New("webauthn: assertion invalid")
// Verify checks a WebAuthn assertion against (a) the expected challenge
// bytes and (b) a registered credential. On success it returns the
// matched credential.
func Verify(cfg VerifyConfig, store *Store, a Assertion, expectedChallenge []byte) (Credential, error) {
if store == nil {
return Credential{}, errors.New("webauthn: store is nil")
}
if cfg.RPID == "" {
return Credential{}, errors.New("webauthn: RPID is empty")
}
if len(cfg.AllowedOrigins) == 0 {
return Credential{}, errors.New("webauthn: no allowed origins configured")
}
cred, ok := store.Get(a.CredentialID)
if !ok {
return Credential{}, ErrCredentialUnknown
}
cdJSON, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.ClientDataJSON))
if err != nil {
return cred, fmt.Errorf("%w: client_data_json b64: %v", ErrAssertionInvalid, err)
}
authData, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.AuthenticatorData))
if err != nil {
return cred, fmt.Errorf("%w: authenticator_data b64: %v", ErrAssertionInvalid, err)
}
sig, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.Signature))
if err != nil {
return cred, fmt.Errorf("%w: signature b64: %v", ErrAssertionInvalid, err)
}
var cd clientData
if err := json.Unmarshal(cdJSON, &cd); err != nil {
return cred, fmt.Errorf("%w: client_data decode: %v", ErrAssertionInvalid, err)
}
if cd.Type != "webauthn.get" {
return cred, fmt.Errorf("%w: type %q", ErrAssertionInvalid, cd.Type)
}
if !originAllowed(cfg.AllowedOrigins, cd.Origin) {
return cred, fmt.Errorf("%w: origin %q not allowed", ErrAssertionInvalid, cd.Origin)
}
gotChal, err := base64.RawURLEncoding.DecodeString(stripB64Pad(cd.Challenge))
if err != nil {
return cred, fmt.Errorf("%w: challenge b64: %v", ErrAssertionInvalid, err)
}
if subtle.ConstantTimeCompare(gotChal, expectedChallenge) != 1 {
return cred, ErrChallengeMismatch
}
// authenticator_data layout per the WebAuthn spec:
// rpIdHash (32) || flags (1) || signCount (4) || <attested cred data>?
if len(authData) < 37 {
return cred, fmt.Errorf("%w: authenticator_data too short", ErrAssertionInvalid)
}
rpHash := sha256.Sum256([]byte(cfg.RPID))
if subtle.ConstantTimeCompare(authData[:32], rpHash[:]) != 1 {
return cred, fmt.Errorf("%w: rpIdHash mismatch", ErrAssertionInvalid)
}
// Signed-over bytes per the spec: authenticator_data || sha256(clientDataJSON).
cdHash := sha256.Sum256(cdJSON)
signed := make([]byte, 0, len(authData)+len(cdHash))
signed = append(signed, authData...)
signed = append(signed, cdHash[:]...)
if !ed25519.Verify(cred.PublicKey, signed, sig) {
return cred, fmt.Errorf("%w: ed25519 signature verification failed", ErrAssertionInvalid)
}
return cred, nil
}
// clientData is the subset of CollectedClientData we care about.
type clientData struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Origin string `json:"origin"`
}
func originAllowed(allowed []string, got string) bool {
for _, o := range allowed {
if strings.EqualFold(o, got) {
return true
}
}
return false
}
// stripB64Pad lets us accept both padded (`...==`) and unpadded base64url
// since different browsers emit different forms.
func stripB64Pad(s string) string {
return strings.TrimRight(s, "=")
}
// MakeAuthenticatorData is a test helper: builds a minimal
// authenticator_data (rpIdHash || flags=0x01 || signCount=0) for the
// given RP ID. Production enrollment never comes through this path.
func MakeAuthenticatorData(rpID string) []byte {
sum := sha256.Sum256([]byte(rpID))
buf := make([]byte, 32+1+4)
copy(buf, sum[:])
buf[32] = 0x01 // UP (user present); UV left zero for the Phase-1 shim.
// sign count stays zero
return buf
}