// Package webauthn implements the WebAuthn (passkeys) connector for // the bundled Dex (REQ-148, D-240, D-243, D-244). Passkeys are // public-key credentials — the private key never leaves the // authenticator — directly satisfying R-021 (no passwords, no shared // secrets). The connector serves registration + login ceremonies // behind Traefik at /orca/webauthn/{register,login}. // // Credential storage: SQLite at ClusterDir()/webauthn-credentials.db // (0600). Stores public keys + credential IDs + sign counts only. // No private keys, no secrets. package webauthn import ( "database/sql" "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" "sync" "time" _ "modernc.org/sqlite" ) // Credential is a stored WebAuthn public-key credential. type Credential struct { UserID string `json:"user_id"` CredentialID []byte `json:"credential_id"` PublicKey []byte `json:"public_key"` SignCount uint32 `json:"sign_count"` AAGUID string `json:"aaguid"` CreatedAt time.Time `json:"created_at"` } // Store is the SQLite-backed credential store. type Store struct { db *sql.DB path string mu sync.Mutex } // NewStore opens (or creates) the WebAuthn credential DB at the given // path. The DB file mode is enforced at 0600. func NewStore(dbPath string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { return nil, fmt.Errorf("webauthn: mkdir: %w", err) } dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)", dbPath) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("webauthn: open db: %w", err) } if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("webauthn: ping: %w", err) } schema := ` CREATE TABLE IF NOT EXISTS credentials ( user_id TEXT PRIMARY KEY, credential_id BLOB NOT NULL, public_key BLOB NOT NULL, sign_count INTEGER NOT NULL DEFAULT 0, aaguid TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL );` if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("webauthn: schema: %w", err) } // Enforce 0600 on the DB file. if err := os.Chmod(dbPath, 0o600); err != nil { // Non-fatal: the file may not exist yet (WAL mode creates on first write). _ = err } return &Store{db: db, path: dbPath}, nil } // PutCredential stores a credential (insert or replace by user_id). func (s *Store) PutCredential(c *Credential) error { s.mu.Lock() defer s.mu.Unlock() _, err := s.db.Exec( `INSERT INTO credentials (user_id, credential_id, public_key, sign_count, aaguid, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET credential_id = excluded.credential_id, public_key = excluded.public_key, sign_count = excluded.sign_count`, c.UserID, c.CredentialID, c.PublicKey, c.SignCount, c.AAGUID, c.CreatedAt.Format(time.RFC3339), ) if err != nil { return fmt.Errorf("webauthn: put: %w", err) } return nil } // GetCredential retrieves a credential by user_id. func (s *Store) GetCredential(userID string) (*Credential, error) { s.mu.Lock() defer s.mu.Unlock() var c Credential var createdStr string err := s.db.QueryRow( `SELECT user_id, credential_id, public_key, sign_count, aaguid, created_at FROM credentials WHERE user_id = ?`, userID, ).Scan(&c.UserID, &c.CredentialID, &c.PublicKey, &c.SignCount, &c.AAGUID, &createdStr) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("webauthn: get: %w", err) } c.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) return &c, nil } // ListCredentials returns all stored credentials (for admin/debug). func (s *Store) ListCredentials() ([]*Credential, error) { s.mu.Lock() defer s.mu.Unlock() rows, err := s.db.Query( `SELECT user_id, credential_id, public_key, sign_count, aaguid, created_at FROM credentials ORDER BY created_at`) if err != nil { return nil, fmt.Errorf("webauthn: list: %w", err) } defer rows.Close() var out []*Credential for rows.Next() { var c Credential var createdStr string if err := rows.Scan(&c.UserID, &c.CredentialID, &c.PublicKey, &c.SignCount, &c.AAGUID, &createdStr); err != nil { return nil, err } c.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) out = append(out, &c) } return out, nil } // DeleteCredential removes a credential (revoke a passkey). func (s *Store) DeleteCredential(userID string) error { s.mu.Lock() defer s.mu.Unlock() _, err := s.db.Exec(`DELETE FROM credentials WHERE user_id = ?`, userID) if err != nil { return fmt.Errorf("webauthn: delete: %w", err) } return nil } // UpdateSignCount updates the sign count after a successful login. func (s *Store) UpdateSignCount(userID string, count uint32) error { s.mu.Lock() defer s.mu.Unlock() _, err := s.db.Exec(`UPDATE credentials SET sign_count = ? WHERE user_id = ?`, count, userID) if err != nil { return fmt.Errorf("webauthn: update count: %w", err) } return nil } // Close closes the DB. func (s *Store) Close() error { return s.db.Close() } // EncodeID base64-encodes a credential ID for transport. func EncodeID(id []byte) string { return base64.RawURLEncoding.EncodeToString(id) } // DecodeID base64-decodes a credential ID. func DecodeID(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } // User represents a WebAuthn user (for the ceremony). type User struct { ID []byte Name string DisplayName string Credentials []*Credential } // ToJSON marshals a value for the connector response. func ToJSON(v any) ([]byte, error) { return json.Marshal(v) }