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:
2026-03-16 16:12:59 -07:00
parent 19fa0c9a8e
commit 25417b24f4
42 changed files with 4214 additions and 84 deletions

View File

@@ -0,0 +1,28 @@
// Package webauthn provides the adapter between the go-webauthn library and
// MCIAS internal types. It handles WebAuthn instance configuration and
// encryption/decryption of credential material stored in the database.
package webauthn
import (
"fmt"
"github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/config"
)
// NewWebAuthn creates a configured go-webauthn instance from MCIAS config.
func NewWebAuthn(cfg *config.WebAuthnConfig) (*webauthn.WebAuthn, error) {
if cfg.RPID == "" || cfg.RPOrigin == "" {
return nil, fmt.Errorf("webauthn: RPID and RPOrigin are required")
}
displayName := cfg.DisplayName
if displayName == "" {
displayName = "MCIAS"
}
return webauthn.New(&webauthn.Config{
RPID: cfg.RPID,
RPDisplayName: displayName,
RPOrigins: []string{cfg.RPOrigin},
})
}

View File

@@ -0,0 +1,75 @@
package webauthn
import (
"testing"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/config"
)
func TestNewWebAuthn(t *testing.T) {
cfg := &config.WebAuthnConfig{
RPID: "example.com",
RPOrigin: "https://example.com",
DisplayName: "Test App",
}
wa, err := NewWebAuthn(cfg)
if err != nil {
t.Fatalf("NewWebAuthn: %v", err)
}
if wa == nil {
t.Fatal("expected non-nil WebAuthn instance")
}
}
func TestNewWebAuthnMissingFields(t *testing.T) {
_, err := NewWebAuthn(&config.WebAuthnConfig{})
if err == nil {
t.Error("expected error for empty config")
}
_, err = NewWebAuthn(&config.WebAuthnConfig{RPID: "example.com"})
if err == nil {
t.Error("expected error for missing RPOrigin")
}
}
func TestNewWebAuthnDefaultDisplayName(t *testing.T) {
cfg := &config.WebAuthnConfig{
RPID: "example.com",
RPOrigin: "https://example.com",
}
wa, err := NewWebAuthn(cfg)
if err != nil {
t.Fatalf("NewWebAuthn: %v", err)
}
if wa == nil {
t.Fatal("expected non-nil WebAuthn instance")
}
}
func TestAccountUserInterface(t *testing.T) {
uuidBytes := []byte("12345678-1234-1234-1234-123456789abc")
creds := []libwebauthn.Credential{
{ID: []byte("cred1")},
{ID: []byte("cred2")},
}
user := NewAccountUser(uuidBytes, "alice", creds)
// Verify interface compliance.
var _ libwebauthn.User = user
if string(user.WebAuthnID()) != string(uuidBytes) {
t.Error("WebAuthnID mismatch")
}
if user.WebAuthnName() != "alice" {
t.Errorf("WebAuthnName = %q, want %q", user.WebAuthnName(), "alice")
}
if user.WebAuthnDisplayName() != "alice" {
t.Errorf("WebAuthnDisplayName = %q, want %q", user.WebAuthnDisplayName(), "alice")
}
if len(user.WebAuthnCredentials()) != 2 {
t.Errorf("WebAuthnCredentials len = %d, want 2", len(user.WebAuthnCredentials()))
}
}

View File

@@ -0,0 +1,99 @@
package webauthn
import (
"encoding/hex"
"fmt"
"strings"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// DecryptCredential decrypts a stored WebAuthn credential's ID and public key
// and returns a webauthn.Credential suitable for the go-webauthn library.
func DecryptCredential(masterKey []byte, cred *model.WebAuthnCredential) (*webauthn.Credential, error) {
credID, err := crypto.OpenAESGCM(masterKey, cred.CredentialIDNonce, cred.CredentialIDEnc)
if err != nil {
return nil, fmt.Errorf("webauthn: decrypt credential ID: %w", err)
}
pubKey, err := crypto.OpenAESGCM(masterKey, cred.PublicKeyNonce, cred.PublicKeyEnc)
if err != nil {
return nil, fmt.Errorf("webauthn: decrypt public key: %w", err)
}
// Parse transports from comma-separated string.
var transports []protocol.AuthenticatorTransport
if cred.Transports != "" {
for _, t := range strings.Split(cred.Transports, ",") {
transports = append(transports, protocol.AuthenticatorTransport(strings.TrimSpace(t)))
}
}
// Parse AAGUID from hex string.
var aaguid []byte
if cred.AAGUID != "" {
aaguid, _ = hex.DecodeString(cred.AAGUID)
}
return &webauthn.Credential{
ID: credID,
PublicKey: pubKey,
Transport: transports,
Flags: webauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: cred.Discoverable,
},
Authenticator: webauthn.Authenticator{
AAGUID: aaguid,
SignCount: cred.SignCount,
},
}, nil
}
// DecryptCredentials decrypts all stored credentials for use with the library.
func DecryptCredentials(masterKey []byte, dbCreds []*model.WebAuthnCredential) ([]webauthn.Credential, error) {
result := make([]webauthn.Credential, 0, len(dbCreds))
for _, c := range dbCreds {
decrypted, err := DecryptCredential(masterKey, c)
if err != nil {
return nil, err
}
result = append(result, *decrypted)
}
return result, nil
}
// EncryptCredential encrypts a library credential for database storage.
// Returns a model.WebAuthnCredential with encrypted fields populated.
func EncryptCredential(masterKey []byte, cred *webauthn.Credential, name string, discoverable bool) (*model.WebAuthnCredential, error) {
credIDEnc, credIDNonce, err := crypto.SealAESGCM(masterKey, cred.ID)
if err != nil {
return nil, fmt.Errorf("webauthn: encrypt credential ID: %w", err)
}
pubKeyEnc, pubKeyNonce, err := crypto.SealAESGCM(masterKey, cred.PublicKey)
if err != nil {
return nil, fmt.Errorf("webauthn: encrypt public key: %w", err)
}
// Serialize transports as comma-separated string.
var transportStrs []string
for _, t := range cred.Transport {
transportStrs = append(transportStrs, string(t))
}
return &model.WebAuthnCredential{
Name: name,
CredentialIDEnc: credIDEnc,
CredentialIDNonce: credIDNonce,
PublicKeyEnc: pubKeyEnc,
PublicKeyNonce: pubKeyNonce,
AAGUID: hex.EncodeToString(cred.Authenticator.AAGUID),
SignCount: cred.Authenticator.SignCount,
Discoverable: discoverable,
Transports: strings.Join(transportStrs, ","),
}, nil
}

View File

@@ -0,0 +1,148 @@
package webauthn
import (
"bytes"
"testing"
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
func testMasterKey(t *testing.T) []byte {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return key
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
masterKey := testMasterKey(t)
original := &libwebauthn.Credential{
ID: []byte("credential-id-12345"),
PublicKey: []byte("public-key-bytes-here"),
Transport: []protocol.AuthenticatorTransport{
protocol.USB,
protocol.NFC,
},
Flags: libwebauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: true,
},
Authenticator: libwebauthn.Authenticator{
AAGUID: []byte{0x2f, 0xc0, 0x57, 0x9f, 0x81, 0x13, 0x47, 0xea, 0xb1, 0x16, 0xbb, 0x5a, 0x8d, 0xb9, 0x20, 0x2a},
SignCount: 42,
},
}
// Encrypt.
encrypted, err := EncryptCredential(masterKey, original, "YubiKey 5", true)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if encrypted.Name != "YubiKey 5" {
t.Errorf("Name = %q, want %q", encrypted.Name, "YubiKey 5")
}
if !encrypted.Discoverable {
t.Error("expected discoverable=true")
}
if encrypted.SignCount != 42 {
t.Errorf("SignCount = %d, want 42", encrypted.SignCount)
}
if encrypted.Transports != "usb,nfc" {
t.Errorf("Transports = %q, want %q", encrypted.Transports, "usb,nfc")
}
// Encrypted fields should not be plaintext.
if bytes.Equal(encrypted.CredentialIDEnc, original.ID) {
t.Error("credential ID should be encrypted")
}
if bytes.Equal(encrypted.PublicKeyEnc, original.PublicKey) {
t.Error("public key should be encrypted")
}
// Decrypt.
decrypted, err := DecryptCredential(masterKey, encrypted)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if !bytes.Equal(decrypted.ID, original.ID) {
t.Errorf("credential ID mismatch after roundtrip")
}
if !bytes.Equal(decrypted.PublicKey, original.PublicKey) {
t.Errorf("public key mismatch after roundtrip")
}
if decrypted.Authenticator.SignCount != 42 {
t.Errorf("SignCount = %d, want 42", decrypted.Authenticator.SignCount)
}
if len(decrypted.Transport) != 2 {
t.Errorf("expected 2 transports, got %d", len(decrypted.Transport))
}
}
func TestDecryptCredentials(t *testing.T) {
masterKey := testMasterKey(t)
// Create two encrypted credentials.
var dbCreds []*model.WebAuthnCredential
for i := range 3 {
cred := &libwebauthn.Credential{
ID: []byte{byte(i), 1, 2, 3},
PublicKey: []byte{byte(i), 4, 5, 6},
Authenticator: libwebauthn.Authenticator{
SignCount: uint32(i),
},
}
enc, err := EncryptCredential(masterKey, cred, "key", false)
if err != nil {
t.Fatalf("encrypt %d: %v", i, err)
}
dbCreds = append(dbCreds, enc)
}
decrypted, err := DecryptCredentials(masterKey, dbCreds)
if err != nil {
t.Fatalf("decrypt all: %v", err)
}
if len(decrypted) != 3 {
t.Fatalf("expected 3 decrypted, got %d", len(decrypted))
}
for i, d := range decrypted {
if d.ID[0] != byte(i) {
t.Errorf("cred %d: ID[0] = %d, want %d", i, d.ID[0], byte(i))
}
}
}
func TestDecryptWithWrongKey(t *testing.T) {
masterKey := testMasterKey(t)
wrongKey := make([]byte, 32)
for i := range wrongKey {
wrongKey[i] = byte(i + 100)
}
// Encrypt with correct key.
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte("secret"))
if err != nil {
t.Fatalf("seal: %v", err)
}
dbCred := &model.WebAuthnCredential{
CredentialIDEnc: enc,
CredentialIDNonce: nonce,
PublicKeyEnc: enc,
PublicKeyNonce: nonce,
}
// Decrypt with wrong key should fail.
_, err = DecryptCredential(wrongKey, dbCred)
if err == nil {
t.Error("expected error decrypting with wrong key")
}
}

37
internal/webauthn/user.go Normal file
View File

@@ -0,0 +1,37 @@
package webauthn
import (
"github.com/go-webauthn/webauthn/webauthn"
)
// AccountUser implements the webauthn.User interface for an MCIAS account.
// The WebAuthnCredentials field must be populated with decrypted credentials
// before passing to the library.
type AccountUser struct {
id []byte // UUID as bytes
name string
displayName string
credentials []webauthn.Credential
}
// NewAccountUser creates a new AccountUser from account details and decrypted credentials.
func NewAccountUser(uuidBytes []byte, username string, creds []webauthn.Credential) *AccountUser {
return &AccountUser{
id: uuidBytes,
name: username,
displayName: username,
credentials: creds,
}
}
// WebAuthnID returns the user's unique ID as bytes.
func (u *AccountUser) WebAuthnID() []byte { return u.id }
// WebAuthnName returns the user's login name.
func (u *AccountUser) WebAuthnName() string { return u.name }
// WebAuthnDisplayName returns the user's display name.
func (u *AccountUser) WebAuthnDisplayName() string { return u.displayName }
// WebAuthnCredentials returns the user's registered credentials.
func (u *AccountUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }