226 lines
8 KiB
Go
226 lines
8 KiB
Go
|
|
// 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
|
||
|
|
}
|