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>
149 lines
3.7 KiB
Go
149 lines
3.7 KiB
Go
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")
|
|
}
|
|
}
|