mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
client: harden Threshold — payload-hash binding, idempotency-key conflict, typed errors, ctx-derived identity
This commit is contained in:
+85
-20
@@ -5,35 +5,88 @@
|
||||
|
||||
package client
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Typed errors. Callers branch via errors.Is.
|
||||
var (
|
||||
// ErrIdempotencyConflict is returned when a Sign call reuses an
|
||||
// IdempotencyKey from a prior session whose canonical request hash
|
||||
// differs from this one. Implementations MUST never return a
|
||||
// cached signature when the request differs — that is a signing
|
||||
// oracle.
|
||||
ErrIdempotencyConflict = errors.New("mpc: idempotency conflict")
|
||||
|
||||
// ErrIdempotencyKeyRequired is returned when Sign is called with
|
||||
// an empty IdempotencyKey.
|
||||
ErrIdempotencyKeyRequired = errors.New("mpc: idempotency key required")
|
||||
|
||||
// ErrComplianceRejected is returned when the backend's compliance
|
||||
// ruleset refuses the signature. The wrapped *ComplianceRejection
|
||||
// names the gate.
|
||||
ErrComplianceRejected = errors.New("mpc: compliance rejected")
|
||||
|
||||
// ErrSessionNotFound is returned by GetSession / Cancel for an
|
||||
// unknown session id, OR for a session that does not belong to the
|
||||
// caller's org (the implementation MUST NOT distinguish the two —
|
||||
// disclosing existence is an enumeration oracle).
|
||||
ErrSessionNotFound = errors.New("mpc: session not found")
|
||||
)
|
||||
|
||||
// Threshold is the compliance-gated threshold-signature surface.
|
||||
// Every custody mutation (mint, burn, transfer, settle) signs through
|
||||
// Threshold.Sign — there is no direct path to the MPC backend.
|
||||
//
|
||||
// Identity: the request's UserID + OrgID MUST be derived from the
|
||||
// validated bearer-auth Claims attached to ctx. Implementations MUST
|
||||
// refuse a call whose request UserID/OrgID disagrees with ctx Claims.
|
||||
type Threshold interface {
|
||||
// Kind reports the backend identifier
|
||||
// (ta-cggmp21 | lit-protocol | fireblocks-mpc | coinbase-custody).
|
||||
Kind() string
|
||||
|
||||
// Sign requests a threshold signature. The implementation applies its
|
||||
// compliance ruleset before signing; rejections return an error whose
|
||||
// kind names the gate (sanctions, velocity, blocklist, ...).
|
||||
// Sign requests a threshold signature.
|
||||
//
|
||||
// IdempotencyKey is required. The implementation must coalesce repeat
|
||||
// calls with the same key to one MPC session, and must verify the
|
||||
// PayloadHash matches the cached session's payload before reusing it.
|
||||
// Idempotency contract:
|
||||
// 1. IdempotencyKey is required. Empty → ErrIdempotencyKeyRequired.
|
||||
// 2. The implementation MUST compute CanonicalHash(SignRequest)
|
||||
// over every request field except IdempotencyKey itself, and
|
||||
// bind the cached session to that hash.
|
||||
// 3. A second call with the same IdempotencyKey and the same
|
||||
// CanonicalHash returns the cached SignResult.
|
||||
// 4. A second call with the same IdempotencyKey but a different
|
||||
// CanonicalHash returns ErrIdempotencyConflict. NO signing.
|
||||
//
|
||||
// Compliance contract:
|
||||
// The implementation MUST re-derive the on-chain payload hash
|
||||
// from (Intent, WalletID, Asset, Amount, ChainID) and compare to
|
||||
// the caller-supplied PayloadHash. Mismatch → reject with
|
||||
// ErrPayloadMismatch. This prevents a caller from describing a
|
||||
// "$10 settle" while signing a "$100M withdraw" hash.
|
||||
Sign(ctx context.Context, req SignRequest) (*SignResult, error)
|
||||
|
||||
// GetSession returns the current state of a signature session.
|
||||
// GetSession returns the current state of a signature session
|
||||
// belonging to the caller's org. Unknown ids and cross-org ids
|
||||
// return ErrSessionNotFound indistinguishably.
|
||||
GetSession(ctx context.Context, sessionID string) (*SignSession, error)
|
||||
|
||||
// Cancel cancels a pending session. Idempotent on completed sessions.
|
||||
// Cancel cancels a pending session belonging to the caller's org.
|
||||
// Idempotent on completed sessions.
|
||||
Cancel(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
// SignRequest is the caller-side payload. Backends map to their protocols.
|
||||
// SignRequest is the caller-side payload.
|
||||
//
|
||||
// UserID and OrgID are RESERVED for the implementation to populate
|
||||
// from ctx Claims. Callers SHOULD leave them empty; if set, the
|
||||
// implementation MUST verify they match ctx Claims and reject on
|
||||
// mismatch.
|
||||
type SignRequest struct {
|
||||
// UserID + OrgID are server-populated from ctx Claims.
|
||||
UserID, OrgID string
|
||||
|
||||
// Intent: settle_trade | swap_dex | mint | burn | withdraw |
|
||||
// redeem | corporate_action.
|
||||
Intent string
|
||||
@@ -41,10 +94,14 @@ type SignRequest struct {
|
||||
WalletID string
|
||||
// Asset is the token symbol (BTC, USDL, ETH, ...).
|
||||
Asset string
|
||||
// Amount is the human-readable string. Backends parse to the asset's
|
||||
// native decimals.
|
||||
// Amount is the human-readable string.
|
||||
Amount string
|
||||
// PayloadHash is the 32-byte hex of the tx payload. Signed verbatim.
|
||||
// ChainID is the EIP-155 chain id (or chain-specific identifier
|
||||
// for non-EVM keys).
|
||||
ChainID int
|
||||
// PayloadHash is the 32-byte hex of the tx payload. The
|
||||
// implementation MUST re-derive from the other request fields and
|
||||
// reject mismatch.
|
||||
PayloadHash string
|
||||
// KeyType: secp256k1 | ed25519 | sr25519 | mldsa65.
|
||||
KeyType string
|
||||
@@ -54,13 +111,23 @@ type SignRequest struct {
|
||||
TradeID string
|
||||
}
|
||||
|
||||
// SignResult is the immediate response. Synchronous backends populate
|
||||
// Signature; asynchronous backends return Status=pending + SessionID for
|
||||
// later polling via GetSession.
|
||||
// ComplianceRejection details which compliance gate refused a Sign call.
|
||||
type ComplianceRejection struct {
|
||||
// Reason: sanctions | velocity | blocklist | manual_review |
|
||||
// key_disabled | tenant_disabled | payload_mismatch.
|
||||
Reason string
|
||||
// ApprovalID is the audit row id; appears in operator dashboards.
|
||||
ApprovalID string
|
||||
}
|
||||
|
||||
func (r *ComplianceRejection) Error() string { return "mpc: " + r.Reason }
|
||||
|
||||
// SignResult is the immediate response.
|
||||
type SignResult struct {
|
||||
SessionID string
|
||||
Signature []byte
|
||||
Status string // pending | signed | rejected
|
||||
// Status: pending | signed.
|
||||
Status string
|
||||
ApprovalID string
|
||||
}
|
||||
|
||||
@@ -70,8 +137,6 @@ type SignSession struct {
|
||||
Status string // pending | signed | rejected | cancelled
|
||||
Signature []byte
|
||||
ApprovalID string
|
||||
// Reason populated when Status=rejected. Values:
|
||||
// sanctions | velocity | blocklist | manual_review |
|
||||
// key_disabled | tenant_disabled.
|
||||
// Reason populated when Status=rejected. See ComplianceRejection.
|
||||
Reason string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user