Files
mpc/pkg/api/handlers_webauthn.go
zeekay 28938be4ef neutral: drop 'Lux'/'Hanzo' brand prefixes in user-facing strings
MPC and KMS are protocols, not products. The Go module path
github.com/luxfi/mpc remains canonical (location), but user-facing
strings (--help, README, comments, banners) are brand-neutral.
'Lux MPC' → 'MPC', 'Lux KMS' → 'KMS', 'Hanzo MPC' → 'MPC'.
2025-12-28 15:34:18 -08:00

387 lines
13 KiB
Go

package api
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
// WebAuthn/FIDO2 registration and authentication for biometric signing.
// This enables Face ID, Touch ID, YubiKey, and Windows Hello as MPC approval factors.
// handleRegisterWebAuthnBegin starts a WebAuthn registration ceremony.
// The client calls this to get a challenge, then presents it to the authenticator.
func (s *Server) handleRegisterWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
orgID := getOrgID(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
// Generate challenge
challenge := make([]byte, 32)
if _, err := randRead(challenge); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate challenge")
return
}
// Store challenge in DB for verification
cred := orm.New[db.WebAuthnCredential](s.db.ORM)
cred.OrgID = orgID
cred.UserID = userID
cred.Challenge = base64.URLEncoding.EncodeToString(challenge)
cred.Status = "pending_registration"
if err := cred.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to store challenge")
return
}
// Return PublicKeyCredentialCreationOptions
writeJSON(w, http.StatusOK, map[string]interface{}{
"challenge": base64.URLEncoding.EncodeToString(challenge),
"rp": map[string]string{
"id": s.webauthnRPID,
"name": "MPC",
},
"user": map[string]string{
"id": base64.URLEncoding.EncodeToString([]byte(userID)),
"name": userID,
"displayName": userID,
},
"pubKeyCredParams": []map[string]interface{}{
{"type": "public-key", "alg": -7}, // ES256 (P-256)
{"type": "public-key", "alg": -257}, // RS256
},
"timeout": 60000,
"attestation": "direct",
"authenticatorSelection": map[string]interface{}{
"authenticatorAttachment": "platform", // Force biometric (not USB key)
"userVerification": "required",
"residentKey": "preferred",
},
"credential_id": cred.Id(), // Track which credential record to update
})
}
// handleRegisterWebAuthnComplete completes a WebAuthn registration ceremony.
func (s *Server) handleRegisterWebAuthnComplete(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
orgID := getOrgID(r.Context())
var req struct {
CredentialID string `json:"credential_id"` // Our DB record ID
ID string `json:"id"` // WebAuthn credential ID (base64url)
RawID string `json:"rawId"`
Type string `json:"type"`
Response struct {
AttestationObject string `json:"attestationObject"`
ClientDataJSON string `json:"clientDataJSON"`
} `json:"response"`
DeviceName string `json:"device_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Verify the credential exists and is pending
cred, err := orm.Get[db.WebAuthnCredential](s.db.ORM, req.CredentialID)
if err != nil || cred.UserID != userID || cred.OrgID != orgID || cred.Status != "pending_registration" {
writeError(w, http.StatusNotFound, "registration not found or already completed")
return
}
// Verify clientDataJSON and extract the actual P-256 public key
// from the attestation object instead of storing the raw attestation.
clientData, err := base64.URLEncoding.DecodeString(req.Response.ClientDataJSON)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid clientDataJSON")
return
}
var cd struct {
Challenge string `json:"challenge"`
Origin string `json:"origin"`
Type string `json:"type"`
}
if err := json.Unmarshal(clientData, &cd); err != nil {
writeError(w, http.StatusBadRequest, "invalid clientDataJSON structure")
return
}
if cd.Challenge != cred.Challenge {
writeError(w, http.StatusBadRequest, "challenge mismatch")
return
}
// Validate origin matches expected RP
if !s.webauthnOrigins[cd.Origin] {
writeError(w, http.StatusBadRequest, "origin mismatch: "+cd.Origin+" is not an allowed WebAuthn origin")
return
}
if cd.Type != "webauthn.create" {
writeError(w, http.StatusBadRequest, "invalid ceremony type: expected webauthn.create")
return
}
// Extract uncompressed P-256 public key from the attestation object.
// The client MUST send the extracted COSE public key as base64, not the raw attestation.
// If the client sends the raw attestation, this will reject it — forcing proper key extraction.
extractedPubKey := req.Response.AttestationObject
pubBytes, pubErr := base64.StdEncoding.DecodeString(extractedPubKey)
if pubErr != nil || len(pubBytes) < 65 || pubBytes[0] != 0x04 {
writeError(w, http.StatusBadRequest, "attestation must contain base64-encoded uncompressed P-256 public key (65 bytes, 0x04 prefix); extract the COSE key from the attestation object client-side")
return
}
// Verify it's a valid P-256 point
testX := new(big.Int).SetBytes(pubBytes[1:33])
testY := new(big.Int).SetBytes(pubBytes[33:65])
if !elliptic.P256().IsOnCurve(testX, testY) {
writeError(w, http.StatusBadRequest, "public key is not a valid P-256 point")
return
}
// Update credential record with validated P-256 public key
cred.WebAuthnID = req.ID
cred.PublicKey = extractedPubKey // Base64-encoded uncompressed P-256 point (65 bytes)
cred.Status = "active"
if req.DeviceName != "" {
cred.DeviceName = &req.DeviceName
}
if err := cred.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save credential")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "registered",
"id": cred.Id(),
"device": req.DeviceName,
"user_id": userID,
})
}
// handleVerifyWebAuthn verifies a WebAuthn assertion for transaction approval.
// This is called when a user approves a transaction with biometrics.
func (s *Server) handleVerifyWebAuthn(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
orgID := getOrgID(r.Context())
var req struct {
TxID string `json:"tx_id"`
ID string `json:"id"` // WebAuthn credential ID
RawID string `json:"rawId"` // base64url
Type string `json:"type"` // "public-key"
Response struct {
AuthenticatorData string `json:"authenticatorData"`
ClientDataJSON string `json:"clientDataJSON"`
Signature string `json:"signature"`
UserHandle string `json:"userHandle"`
} `json:"response"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.TxID == "" {
writeError(w, http.StatusBadRequest, "tx_id required")
return
}
// Find the user's registered credential
creds, err := orm.TypedQuery[db.WebAuthnCredential](s.db.ORM).
Filter("userId=", userID).
Filter("orgId=", orgID).
Filter("webAuthnId=", req.ID).
Filter("status=", "active").
Limit(1).
GetAll(r.Context())
if err != nil || len(creds) == 0 {
writeError(w, http.StatusUnauthorized, "no matching registered credential")
return
}
// Verify the assertion signature against the stored public key.
// Decode clientDataJSON and verify challenge matches tx_id hash.
clientData, err := base64.URLEncoding.DecodeString(req.Response.ClientDataJSON)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid clientDataJSON")
return
}
var cd struct {
Challenge string `json:"challenge"`
Origin string `json:"origin"`
Type string `json:"type"`
}
if err := json.Unmarshal(clientData, &cd); err != nil {
writeError(w, http.StatusBadRequest, "invalid clientDataJSON structure")
return
}
// Validate origin to prevent relay attacks
if !s.webauthnOrigins[cd.Origin] {
writeError(w, http.StatusBadRequest, "origin mismatch: "+cd.Origin+" is not an allowed WebAuthn origin")
return
}
if cd.Type != "webauthn.get" {
writeError(w, http.StatusBadRequest, "invalid ceremony type: expected webauthn.get")
return
}
// The challenge should be SHA256(tx_id) to bind the biometric to the specific transaction
expectedChallenge := sha256.Sum256([]byte(req.TxID))
expectedChallengeB64 := base64.URLEncoding.EncodeToString(expectedChallenge[:])
if cd.Challenge != expectedChallengeB64 {
writeError(w, http.StatusBadRequest, "challenge does not match transaction")
return
}
// Verify authenticator signature
authData, _ := base64.URLEncoding.DecodeString(req.Response.AuthenticatorData)
sig, _ := base64.URLEncoding.DecodeString(req.Response.Signature)
// Construct the signed message: SHA256(authData || SHA256(clientDataJSON))
clientDataHash := sha256.Sum256(clientData)
signedData := append(authData, clientDataHash[:]...)
signedHash := sha256.Sum256(signedData)
// Always verify ES256 signature. Reject if public key can't be parsed.
pubKeyBytes, decErr := base64.StdEncoding.DecodeString(creds[0].PublicKey)
if decErr != nil || len(pubKeyBytes) < 65 || pubKeyBytes[0] != 0x04 {
writeError(w, http.StatusUnauthorized, "stored credential has invalid public key; re-register the WebAuthn credential")
return
}
x := new(big.Int).SetBytes(pubKeyBytes[1:33])
y := new(big.Int).SetBytes(pubKeyBytes[33:65])
pubKey := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
if !ecdsa.VerifyASN1(pubKey, signedHash[:], sig) {
writeError(w, http.StatusUnauthorized, "invalid biometric signature")
return
}
// Biometric verified — now approve the transaction (same logic as handleApproveTransaction)
tx, err := orm.Get[db.Transaction](s.db.ORM, req.TxID)
if err != nil || tx.OrgID != orgID || tx.Status != "pending_approval" {
writeError(w, http.StatusNotFound, "transaction not found or not pending")
return
}
// Dedup
for _, id := range tx.ApprovedBy {
if id == userID {
writeError(w, http.StatusConflict, "already approved")
return
}
}
// Self-approval check
if tx.InitiatedBy != nil && *tx.InitiatedBy == userID && len(tx.ApprovedBy) == 0 {
writeError(w, http.StatusForbidden, "initiator cannot self-approve")
return
}
tx.ApprovedBy = append(tx.ApprovedBy, userID)
if err := tx.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to approve")
return
}
// Check quorum
policies, _ := s.loadPolicies(r.Context(), orgID, nil)
decision := evaluateTransaction(strFromPtr(tx.Amount), tx.Chain, strFromPtr(tx.ToAddress), policies)
requiredApprovers := decision.RequiredApprovers
if requiredApprovers < 1 {
requiredApprovers = 1
}
signed := false
if len(tx.ApprovedBy) >= requiredApprovers {
tx.Status = "approved"
if err := tx.Update(); err == nil {
go s.signAndBroadcast(tx.Id(), orgID)
signed = true
// Audit log the signing trigger
s.writeSigningAuditLog(r.Context(), orgID, strFromPtr(tx.WalletID), req.TxID, tx.Id(), "webauthn_verify")
}
}
s.fireWebhook(r.Context(), orgID, "tx.approved", map[string]string{
"tx_id": req.TxID,
"approved_by": userID,
"method": "webauthn_biometric",
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "approved",
"biometric": true,
"signing_started": signed,
"approvals": len(tx.ApprovedBy),
"required": requiredApprovers,
})
}
// handleListWebAuthnCredentials lists a user's registered WebAuthn credentials.
func (s *Server) handleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
orgID := getOrgID(r.Context())
creds, err := orm.TypedQuery[db.WebAuthnCredential](s.db.ORM).
Filter("userId=", userID).
Filter("orgId=", orgID).
Filter("status=", "active").
Limit(20).
GetAll(r.Context())
if err != nil || creds == nil {
creds = []*db.WebAuthnCredential{}
}
// Strip sensitive fields
type safeCred struct {
ID string `json:"id"`
DeviceName *string `json:"device_name"`
CreatedAt string `json:"created_at"`
}
result := make([]safeCred, len(creds))
for i, c := range creds {
result[i] = safeCred{
ID: c.Id(),
DeviceName: c.DeviceName,
CreatedAt: c.Id(), // ID encodes timestamp
}
}
writeJSON(w, http.StatusOK, result)
}
// handleDeleteWebAuthnCredential removes a WebAuthn credential.
func (s *Server) handleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
orgID := getOrgID(r.Context())
credID := urlParam(r, "id")
cred, err := orm.Get[db.WebAuthnCredential](s.db.ORM, credID)
if err != nil || cred.UserID != userID || cred.OrgID != orgID {
writeError(w, http.StatusNotFound, "credential not found")
return
}
cred.Status = "revoked"
cred.Update()
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// randRead is a variable for testing.
var randRead = func(b []byte) (int, error) {
return rand.Read(b)
}