Files
orca/internal/webauthn/store.go
T
Jon Chery c726a6a9e2 feat(P05): WebAuthn connector for Dex (REQ-148, D-240, C-38)
---ci---
project: orca
phase: 5
milestone: v0.12
status: execute
---/ci---

internal/webauthn/store.go: SQLite credential store (0600, public
keys only). Put/Get/List/Delete/UpdateSignCount.
internal/webauthn/connector.go: WebAuthn ceremony handler for the
bundled Dex. BeginRegistration/FinishRegistration/BeginLogin/FinishLogin
at /orca/webauthn/{register,login}. go-webauthn library for crypto.
RP ID = cluster Traefik domain (C-38). Public-key credentials only
(private key never leaves authenticator; R-021 invariant holds).
9 tests pass (4 store + 5 connector). go vet clean. Full build green.
2026-08-07 11:02:07 +00:00

189 lines
5.5 KiB
Go

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