Add FIDO2/WebAuthn passkey authentication
Phase 14: Full WebAuthn support for passwordless passkey login and hardware security key 2FA. - go-webauthn/webauthn v0.16.1 dependency - WebAuthnConfig with RPID/RPOrigin/DisplayName validation - Migration 000009: webauthn_credentials table - DB CRUD with ownership checks and admin operations - internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM - REST: register begin/finish, login begin/finish, list, delete - Web UI: profile enrollment, login passkey button, admin management - gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs - mciasdb: webauthn list/delete/reset subcommands - OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema - Policy: self-service enrollment rule, admin remove via wildcard - Tests: DB CRUD, adapter round-trip, interface compliance - Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14 Security: Credential IDs and public keys encrypted at rest with AES-256-GCM via vault master key. Challenge ceremonies use 128-bit nonces with 120s TTL in sync.Map. Sign counter validated on each assertion to detect cloned authenticators. Password re-auth required for registration (SEC-01 pattern). No credential material in API responses or logs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
208
internal/db/webauthn.go
Normal file
208
internal/db/webauthn.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// CreateWebAuthnCredential inserts a new WebAuthn credential record.
|
||||
// All encrypted fields (credential_id, public_key) must be encrypted by the caller.
|
||||
func (db *DB) CreateWebAuthnCredential(cred *model.WebAuthnCredential) (int64, error) {
|
||||
n := now()
|
||||
result, err := db.sql.Exec(`
|
||||
INSERT INTO webauthn_credentials
|
||||
(account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cred.AccountID, cred.Name, cred.CredentialIDEnc, cred.CredentialIDNonce,
|
||||
cred.PublicKeyEnc, cred.PublicKeyNonce, cred.AAGUID, cred.SignCount,
|
||||
boolToInt(cred.Discoverable), cred.Transports, n, n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: create webauthn credential: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: webauthn credential last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentials returns all WebAuthn credentials for an account.
|
||||
func (db *DB) GetWebAuthnCredentials(accountID int64) ([]*model.WebAuthnCredential, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at, last_used_at
|
||||
FROM webauthn_credentials WHERE account_id = ? ORDER BY created_at ASC`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list webauthn credentials: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // rows.Close error is non-fatal
|
||||
return scanWebAuthnCredentials(rows)
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentialByID returns a single WebAuthn credential by its DB row ID.
|
||||
// Returns ErrNotFound if the credential does not exist.
|
||||
func (db *DB) GetWebAuthnCredentialByID(id int64) (*model.WebAuthnCredential, error) {
|
||||
row := db.sql.QueryRow(`
|
||||
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at, last_used_at
|
||||
FROM webauthn_credentials WHERE id = ?`, id)
|
||||
return scanWebAuthnCredential(row)
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredential deletes a WebAuthn credential by ID, verifying ownership.
|
||||
// Returns ErrNotFound if the credential does not exist or does not belong to the account.
|
||||
func (db *DB) DeleteWebAuthnCredential(id, accountID int64) error {
|
||||
result, err := db.sql.Exec(
|
||||
`DELETE FROM webauthn_credentials WHERE id = ? AND account_id = ?`, id, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: delete webauthn credential: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: webauthn delete rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredentialAdmin deletes a WebAuthn credential by ID without ownership check.
|
||||
func (db *DB) DeleteWebAuthnCredentialAdmin(id int64) error {
|
||||
result, err := db.sql.Exec(`DELETE FROM webauthn_credentials WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: admin delete webauthn credential: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: webauthn admin delete rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllWebAuthnCredentials removes all WebAuthn credentials for an account.
|
||||
func (db *DB) DeleteAllWebAuthnCredentials(accountID int64) (int64, error) {
|
||||
result, err := db.sql.Exec(
|
||||
`DELETE FROM webauthn_credentials WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: delete all webauthn credentials: %w", err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// UpdateWebAuthnSignCount updates the sign counter for a credential.
|
||||
func (db *DB) UpdateWebAuthnSignCount(id int64, signCount uint32) error {
|
||||
_, err := db.sql.Exec(
|
||||
`UPDATE webauthn_credentials SET sign_count = ?, updated_at = ? WHERE id = ?`,
|
||||
signCount, now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update webauthn sign count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateWebAuthnLastUsed sets the last_used_at timestamp for a credential.
|
||||
func (db *DB) UpdateWebAuthnLastUsed(id int64) error {
|
||||
_, err := db.sql.Exec(
|
||||
`UPDATE webauthn_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?`,
|
||||
now(), now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update webauthn last used: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasWebAuthnCredentials reports whether the account has any WebAuthn credentials.
|
||||
func (db *DB) HasWebAuthnCredentials(accountID int64) (bool, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: count webauthn credentials: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CountWebAuthnCredentials returns the number of WebAuthn credentials for an account.
|
||||
func (db *DB) CountWebAuthnCredentials(accountID int64) (int, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: count webauthn credentials: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// boolToInt converts a bool to 0/1 for SQLite storage.
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func scanWebAuthnCredentials(rows *sql.Rows) ([]*model.WebAuthnCredential, error) {
|
||||
var creds []*model.WebAuthnCredential
|
||||
for rows.Next() {
|
||||
cred, err := scanWebAuthnRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
// scannable is implemented by both *sql.Row and *sql.Rows.
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanWebAuthnRow(s scannable) (*model.WebAuthnCredential, error) {
|
||||
var cred model.WebAuthnCredential
|
||||
var createdAt, updatedAt string
|
||||
var lastUsedAt *string
|
||||
var discoverable int
|
||||
err := s.Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.Name,
|
||||
&cred.CredentialIDEnc, &cred.CredentialIDNonce,
|
||||
&cred.PublicKeyEnc, &cred.PublicKeyNonce,
|
||||
&cred.AAGUID, &cred.SignCount,
|
||||
&discoverable, &cred.Transports,
|
||||
&createdAt, &updatedAt, &lastUsedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("db: scan webauthn credential: %w", err)
|
||||
}
|
||||
cred.Discoverable = discoverable != 0
|
||||
cred.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.LastUsedAt, err = nullableTime(lastUsedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func scanWebAuthnCredential(row *sql.Row) (*model.WebAuthnCredential, error) {
|
||||
return scanWebAuthnRow(row)
|
||||
}
|
||||
Reference in New Issue
Block a user