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:
99
internal/webauthn/convert.go
Normal file
99
internal/webauthn/convert.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user