// 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) || ? 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 }