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>
100 lines
3.1 KiB
Go
100 lines
3.1 KiB
Go
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
|
|
}
|