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>
252 lines
6.8 KiB
Go
252 lines
6.8 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
)
|
|
|
|
func TestWebAuthnCRUD(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
acct, err := database.CreateAccount("webauthnuser", model.AccountTypeHuman, "hash")
|
|
if err != nil {
|
|
t.Fatalf("create account: %v", err)
|
|
}
|
|
|
|
// Empty state.
|
|
has, err := database.HasWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("has credentials: %v", err)
|
|
}
|
|
if has {
|
|
t.Error("expected no credentials")
|
|
}
|
|
|
|
count, err := database.CountWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("count credentials: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected 0 credentials, got %d", count)
|
|
}
|
|
|
|
creds, err := database.GetWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("get credentials (empty): %v", err)
|
|
}
|
|
if len(creds) != 0 {
|
|
t.Errorf("expected 0 credentials, got %d", len(creds))
|
|
}
|
|
|
|
// Create credential.
|
|
cred := &model.WebAuthnCredential{
|
|
AccountID: acct.ID,
|
|
Name: "Test Key",
|
|
CredentialIDEnc: []byte("enc-cred-id"),
|
|
CredentialIDNonce: []byte("nonce-cred-id"),
|
|
PublicKeyEnc: []byte("enc-pubkey"),
|
|
PublicKeyNonce: []byte("nonce-pubkey"),
|
|
AAGUID: "2fc0579f811347eab116bb5a8db9202a",
|
|
SignCount: 0,
|
|
Discoverable: true,
|
|
Transports: "usb,nfc",
|
|
}
|
|
id, err := database.CreateWebAuthnCredential(cred)
|
|
if err != nil {
|
|
t.Fatalf("create credential: %v", err)
|
|
}
|
|
if id == 0 {
|
|
t.Error("expected non-zero credential ID")
|
|
}
|
|
|
|
// Now has credentials.
|
|
has, err = database.HasWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("has credentials after create: %v", err)
|
|
}
|
|
if !has {
|
|
t.Error("expected credentials to exist")
|
|
}
|
|
|
|
count, err = database.CountWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("count after create: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 credential, got %d", count)
|
|
}
|
|
|
|
// Get by ID.
|
|
got, err := database.GetWebAuthnCredentialByID(id)
|
|
if err != nil {
|
|
t.Fatalf("get by ID: %v", err)
|
|
}
|
|
if got.Name != "Test Key" {
|
|
t.Errorf("Name = %q, want %q", got.Name, "Test Key")
|
|
}
|
|
if !got.Discoverable {
|
|
t.Error("expected discoverable=true")
|
|
}
|
|
if got.Transports != "usb,nfc" {
|
|
t.Errorf("Transports = %q, want %q", got.Transports, "usb,nfc")
|
|
}
|
|
if got.AccountID != acct.ID {
|
|
t.Errorf("AccountID = %d, want %d", got.AccountID, acct.ID)
|
|
}
|
|
|
|
// Get list.
|
|
creds, err = database.GetWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("get credentials: %v", err)
|
|
}
|
|
if len(creds) != 1 {
|
|
t.Fatalf("expected 1 credential, got %d", len(creds))
|
|
}
|
|
if creds[0].ID != id {
|
|
t.Errorf("credential ID = %d, want %d", creds[0].ID, id)
|
|
}
|
|
|
|
// Update sign count.
|
|
if err := database.UpdateWebAuthnSignCount(id, 5); err != nil {
|
|
t.Fatalf("update sign count: %v", err)
|
|
}
|
|
got, _ = database.GetWebAuthnCredentialByID(id)
|
|
if got.SignCount != 5 {
|
|
t.Errorf("SignCount = %d, want 5", got.SignCount)
|
|
}
|
|
|
|
// Update last used.
|
|
if err := database.UpdateWebAuthnLastUsed(id); err != nil {
|
|
t.Fatalf("update last used: %v", err)
|
|
}
|
|
got, _ = database.GetWebAuthnCredentialByID(id)
|
|
if got.LastUsedAt == nil {
|
|
t.Error("expected LastUsedAt to be set")
|
|
}
|
|
}
|
|
|
|
func TestWebAuthnDeleteOwnership(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
acct1, _ := database.CreateAccount("wa1", model.AccountTypeHuman, "hash")
|
|
acct2, _ := database.CreateAccount("wa2", model.AccountTypeHuman, "hash")
|
|
|
|
cred := &model.WebAuthnCredential{
|
|
AccountID: acct1.ID,
|
|
Name: "Key",
|
|
CredentialIDEnc: []byte("enc"),
|
|
CredentialIDNonce: []byte("nonce"),
|
|
PublicKeyEnc: []byte("enc"),
|
|
PublicKeyNonce: []byte("nonce"),
|
|
}
|
|
id, _ := database.CreateWebAuthnCredential(cred)
|
|
|
|
// Delete with wrong owner should fail.
|
|
err := database.DeleteWebAuthnCredential(id, acct2.ID)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound for wrong owner, got %v", err)
|
|
}
|
|
|
|
// Delete with correct owner succeeds.
|
|
if err := database.DeleteWebAuthnCredential(id, acct1.ID); err != nil {
|
|
t.Fatalf("delete with correct owner: %v", err)
|
|
}
|
|
|
|
// Verify gone.
|
|
_, err = database.GetWebAuthnCredentialByID(id)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWebAuthnDeleteAdmin(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
acct, _ := database.CreateAccount("waadmin", model.AccountTypeHuman, "hash")
|
|
cred := &model.WebAuthnCredential{
|
|
AccountID: acct.ID,
|
|
Name: "Key",
|
|
CredentialIDEnc: []byte("enc"),
|
|
CredentialIDNonce: []byte("nonce"),
|
|
PublicKeyEnc: []byte("enc"),
|
|
PublicKeyNonce: []byte("nonce"),
|
|
}
|
|
id, _ := database.CreateWebAuthnCredential(cred)
|
|
|
|
// Admin delete (no ownership check).
|
|
if err := database.DeleteWebAuthnCredentialAdmin(id); err != nil {
|
|
t.Fatalf("admin delete: %v", err)
|
|
}
|
|
|
|
// Non-existent should return ErrNotFound.
|
|
if err := database.DeleteWebAuthnCredentialAdmin(id); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound for non-existent, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWebAuthnDeleteAll(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
acct, _ := database.CreateAccount("wada", model.AccountTypeHuman, "hash")
|
|
|
|
for i := range 3 {
|
|
cred := &model.WebAuthnCredential{
|
|
AccountID: acct.ID,
|
|
Name: "Key",
|
|
CredentialIDEnc: []byte{byte(i)},
|
|
CredentialIDNonce: []byte("n"),
|
|
PublicKeyEnc: []byte{byte(i)},
|
|
PublicKeyNonce: []byte("n"),
|
|
}
|
|
if _, err := database.CreateWebAuthnCredential(cred); err != nil {
|
|
t.Fatalf("create %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
deleted, err := database.DeleteAllWebAuthnCredentials(acct.ID)
|
|
if err != nil {
|
|
t.Fatalf("delete all: %v", err)
|
|
}
|
|
if deleted != 3 {
|
|
t.Errorf("expected 3 deleted, got %d", deleted)
|
|
}
|
|
|
|
count, _ := database.CountWebAuthnCredentials(acct.ID)
|
|
if count != 0 {
|
|
t.Errorf("expected 0 after delete all, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestWebAuthnCascadeDelete(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
acct, _ := database.CreateAccount("wacascade", model.AccountTypeHuman, "hash")
|
|
cred := &model.WebAuthnCredential{
|
|
AccountID: acct.ID,
|
|
Name: "Key",
|
|
CredentialIDEnc: []byte("enc"),
|
|
CredentialIDNonce: []byte("nonce"),
|
|
PublicKeyEnc: []byte("enc"),
|
|
PublicKeyNonce: []byte("nonce"),
|
|
}
|
|
id, _ := database.CreateWebAuthnCredential(cred)
|
|
|
|
// Delete the account — credentials should cascade.
|
|
if err := database.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
|
t.Fatalf("update status: %v", err)
|
|
}
|
|
|
|
// The credential should still be retrievable (soft delete on account doesn't cascade).
|
|
// But if we hard-delete via SQL, the FK cascade should clean up.
|
|
// For now just verify the credential still exists after a status change.
|
|
got, err := database.GetWebAuthnCredentialByID(id)
|
|
if err != nil {
|
|
t.Fatalf("get after account status change: %v", err)
|
|
}
|
|
if got.ID != id {
|
|
t.Errorf("credential ID = %d, want %d", got.ID, id)
|
|
}
|
|
}
|