Files
mpc/cmd/mpcd/sign_idempotency.go
zeekay 8273105926 The signing curve is derived from the network, not stated by the caller
Solana addresses were correct and receive-only: ConsensusKeyInfoStore.Save was
the only RegisterKey caller and hardcoded "secp256k1", and TriggerSign read the
curve from that field, so no product entry point could reach Ed25519 signing.

The hardcode is not fixed, it is UNREPRESENTABLE. transport.KeyInfo.KeyType is
deleted — the record carries one public key per curve and a PublicKeyFor(curve)
— and RegisterKey lost its keyType parameter, so there is no longer a place to
state a curve wrongly. A scalar KeyType was the wrong shape anyway: one keygen
ceremony produces BOTH a secp256k1 and an Ed25519 key, so "what curve is this
wallet" has no single answer.

The request carries the network instead, and ONE table maps it to a curve, with
NO default arm — KeyTypeForNetwork returns ErrUnknownNetwork rather than
guessing. That is the point: the old default returned secp256k1 for anything
unrecognized, which is how a Solana request would have been signed with the
wrong key.

/sign's 32-byte clamp becomes the SCHEME's rule rather than the endpoint's:
secp256k1 signs a digest and requires exactly 32 bytes, Ed25519 signs the
message at any length. /sign no longer accepts key_type at all — two ways to say
the curve is the misuse being removed — and Network is bound into the
idempotency fingerprint, so the same bytes for SOL and ETH conflict instead of
sharing one cached signature from a different key.

⚠ The trap this nearly shipped with: every pre-existing KeyInfo record has EMPTY
public keys, because the old Save wrote "", "". A naive "does this wallet have
this curve" gate would have refused every existing wallet on every curve. The
check is gated on RecordsCurves() — a record naming no curve denies none, and
the missing share stays the thing that fails closed.

Verified end to end, not by mirroring: a Solana request through
ConsensusMPCBackend.TriggerSign with a real 2-of-3 FROST ring produces a
signature crypto/ed25519.Verify accepts, over a 63-byte message so any
reintroduced clamp or pre-hash fails it; ETH/EVM/BTC/LUX still route to
secp256k1 and must RECOVER to the wallet's key. Four mutations tried, all
caught.

Behavioral risk worth naming: an unrecognized chain string now fails its
transaction closed rather than signing with a default. That is correct, and the
real fix is validating chain at POST /v1/transactions.
2026-08-06 15:59:30 -07:00

249 lines
9.3 KiB
Go

// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package main, file sign_idempotency.go.
//
// Idempotency cache for the internal HTTP POST /sign endpoint — the
// anti-signing-oracle guard backing the wallet-backend custody adapter
// (lux/wallet apps/backend/internal/custody/mpc.go) and the MPC client
// Threshold.Sign contract.
//
// Contract enforced here:
//
// idempotency_key REQUIRED → empty rejected, no signing.
// org_id REQUIRED → empty rejected (tenant isolation).
// same key + same canonical content → return the CACHED result, never re-sign.
// same key + different content → CONFLICT, never sign (oracle defense).
// concurrent duplicates → single-flight; signer runs exactly once.
// signer FAILURE → not cached; a later retry may re-attempt.
//
// In-process by design: each mpcd node owns its cache. The custody adapter
// retries against one endpoint, so a per-node cache is the correct scope — no
// shared store, no persistence. Keep it simple.
package main
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"sync"
mpcapi "github.com/luxfi/mpc/pkg/api"
"github.com/luxfi/mpc/pkg/types"
)
// Boundary errors surfaced by the cache / request validation. The HTTP handler
// maps these to status codes; keeping them as sentinels lets callers (and
// tests) branch with errors.Is rather than string matching.
var (
errIdempotencyKeyRequired = errors.New("idempotency_key required")
errOrgIDRequired = errors.New("org_id is required")
errIdempotencyConflict = errors.New("idempotency conflict")
)
// signFields is the canonical CONTENT of a /sign request — every field that
// binds the signature, EXCLUDING idempotency_key (which keys the cache, not the
// content). The canonical hash over these fields is what a reused idempotency
// key is checked against to detect a conflicting replay.
//
// Network is part of the content because it selects the curve: the same bytes
// under the same key, requested for SOL and then for ETH, are two different
// signatures from two different keys. Reusing one idempotency key across them
// must conflict, not return the first one's result.
type signFields struct {
OrgID string
WalletID string
Network string
ChainID int
PayloadHash string // hex, as received on the wire
}
// canonicalHash returns a stable, field-order-independent sha256 over the
// request content. Each field is length-prefixed (8-byte big-endian) before
// its bytes so no character can migrate across a field boundary and forge a
// collision — e.g. {OrgID:"ab",WalletID:"c"} and {OrgID:"a",WalletID:"bc"}
// hash differently. Returned as lowercase hex.
func (f signFields) canonicalHash() string {
h := sha256.New()
write := func(s string) {
var n [8]byte
binary.BigEndian.PutUint64(n[:], uint64(len(s)))
h.Write(n[:])
h.Write([]byte(s))
}
write(f.OrgID)
write(f.WalletID)
write(f.Network)
write(strconv.Itoa(f.ChainID))
write(f.PayloadHash)
return hex.EncodeToString(h.Sum(nil))
}
// decodeSignPayload decodes the hex bytes to sign and enforces the length rule
// for the network's curve.
//
// The rule belongs to the scheme, not to the endpoint:
//
// - secp256k1 signs a 32-byte DIGEST. Anything else is a caller bug, caught
// here rather than inside the signer.
// - Ed25519 is PureEdDSA: the message is hashed inside the challenge, so what
// gets signed is the message itself, at whatever length the chain uses — a
// serialized Solana transaction message is hundreds of bytes; a TON cell
// hash is 32. Demanding 32 bytes here is what made every Solana signing
// request impossible to express, and pre-hashing to satisfy it would sign a
// digest of a digest, which the chain rejects.
//
// An empty payload is refused for both: signing nothing is never intended, and
// for Ed25519 it is otherwise a legal length.
func decodeSignPayload(network types.NetworkCode, hexPayload string) ([]byte, error) {
keyType, err := types.KeyTypeForNetwork(network)
if err != nil {
return nil, err
}
payload, err := hex.DecodeString(strings.TrimPrefix(hexPayload, "0x"))
if err != nil {
return nil, errors.New("payload_hash must be hex")
}
if len(payload) == 0 {
return nil, errors.New("payload_hash must not be empty")
}
if keyType == types.KeyTypeSecp256k1 && len(payload) != 32 {
return nil, fmt.Errorf("payload_hash must be a 32-byte digest for %s (got %d bytes)", keyType, len(payload))
}
return payload, nil
}
// validateSignRequest enforces the boundary preconditions before any signing
// machinery is touched. Returns a sentinel error the handler maps to 400.
func validateSignRequest(idempotencyKey string, f signFields) error {
if idempotencyKey == "" {
return errIdempotencyKeyRequired
}
if f.OrgID == "" {
return errOrgIDRequired
}
return nil
}
// cachedSign is the committed result for an idempotency key — the body the
// handler returns. SessionID is derived from the canonical hash so it is stable
// across idempotent retries.
type cachedSign struct {
SessionID string
Signature string
Status string
}
// signFunc is the injectable signer the cache drives. The HTTP handler wires
// ConsensusMPCBackend.TriggerSign here; tests inject a counting mock. It
// receives the org/wallet, the network that selects the curve, and the decoded
// bytes to sign.
type signFunc func(orgID, walletID string, network types.NetworkCode, payload []byte) (*mpcapi.SignResult, error)
// signEntry is one in-flight-or-completed idempotency slot. done is closed when
// the single in-flight signer call returns; until then, duplicate callers block
// on it (single-flight). result/err hold the committed outcome.
type signEntry struct {
canonicalHash string
done chan struct{}
result *cachedSign
err error
}
// signIdempotencyCache is a per-node, mutex-guarded idempotency map. It is the
// only place /sign deduplication lives.
type signIdempotencyCache struct {
mu sync.Mutex
entries map[string]*signEntry // keyed by idempotency_key
}
func newSignIdempotencyCache() *signIdempotencyCache {
return &signIdempotencyCache{entries: make(map[string]*signEntry)}
}
// Do executes the signer exactly once per (idempotency_key, content) pair.
//
// - First call for a key: registers an in-flight entry, releases the lock,
// runs signer, records the result, and wakes any waiters.
// - Duplicate call, same key + same canonical content, completed: returns the
// cached result (signer NOT called).
// - Duplicate call, same key + same canonical content, still in flight:
// blocks on the entry's done channel, then returns the shared result
// (signer NOT called — single-flight).
// - Same key + DIFFERENT canonical content: returns errIdempotencyConflict
// WITHOUT signing — the signing-oracle defense.
// - Signer error: the entry is removed so a future retry may re-attempt; the
// failure is never cached and never wakes a "successful" waiter.
//
// payload is the decoded bytes to sign; it is passed through to signer
// untouched and is NOT part of the cache key (the canonical hash of
// f.PayloadHash already binds it). network is likewise already bound, via
// f.Network.
func (c *signIdempotencyCache) Do(idempotencyKey string, f signFields, network types.NetworkCode, payload []byte, signer signFunc) (*cachedSign, error) {
canon := f.canonicalHash()
c.mu.Lock()
if e, ok := c.entries[idempotencyKey]; ok {
// Key already seen. Reject a content mismatch before doing anything
// else — never sign under a reused key with new content.
if e.canonicalHash != canon {
c.mu.Unlock()
return nil, errIdempotencyConflict
}
// Same content: join the in-flight call / take the cached result.
c.mu.Unlock()
<-e.done
return e.result, e.err
}
// First caller for this key: register in-flight and run the signer with the
// lock released so concurrent distinct keys don't serialize.
e := &signEntry{canonicalHash: canon, done: make(chan struct{})}
c.entries[idempotencyKey] = e
c.mu.Unlock()
res, err := signer(f.OrgID, f.WalletID, network, payload)
if err != nil {
// Do not cache failures: drop the entry so a retry can re-attempt.
c.mu.Lock()
// Only delete if it is still our entry (defensive; same key isn't
// re-registered while in flight because duplicates block on done).
if cur, ok := c.entries[idempotencyKey]; ok && cur == e {
delete(c.entries, idempotencyKey)
}
c.mu.Unlock()
e.err = err
close(e.done)
return nil, err
}
e.result = &cachedSign{
// SessionID derived from the canonical hash → stable across retries.
SessionID: "sign-" + canon[:32],
Signature: assembleSignature(res),
Status: "signed",
}
close(e.done)
return e.result, nil
}
// assembleSignature renders the wire `signature` (hex, no 0x prefix) from a
// SignResult. Prefer the combined Signature if the protocol returned one; else
// fall back to R||S (the 64-byte secp256k1 r||s), appending the recovery-bearing
// remainder only if it is already folded into Signature. R/S are already hex
// (TriggerSign hex-encodes them), so concatenating the hex strings yields the
// hex of r||s.
func assembleSignature(res *mpcapi.SignResult) string {
if res == nil {
return ""
}
if res.Signature != "" {
return res.Signature
}
return res.R + res.S
}