mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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'.
387 lines
13 KiB
Go
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)
|
|
}
|