client: harden Threshold — payload-hash binding, idempotency-key conflict, typed errors, ctx-derived identity

This commit is contained in:
2026-05-13 02:18:05 -07:00
parent 472c6e2669
commit 5e92895cb6
+85 -20
View File
@@ -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
}