c726a6a9e2
---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.
189 lines
5.5 KiB
Go
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)
|
|
}
|