mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 19:54:54 +00:00
dexvm: bind swap-settle authority+payout to authenticated escrow owner (CRITICAL escrow-theft fix)
The swap rail delegated per-taker refund-liveness + relay authority to the unauthenticated RelayOrderTx sender with NO C-side owner binding, letting any fee-paying tx name a victim's collateral ref and settle the locked collateral to the attacker out of the shared seam reserve (cross-depositor theft). Fix (load-bearing, derives authority from the already-authenticated cross-chain object owner — the platformvm import/export model): - state/state.go: escrow value is now owner(20)|asset(32)|amount(8); the owner leads. PutEscrow/GetEscrow carry it. - atomic.go executeImport: persist the AUTHENTICATED importedOwner (read back from the consumed C->D shared-memory object) into the escrow. - vm.go settleFromFills: a settle is authorized ONLY by the escrow's recorded owner (errSettleUnauthorized otherwise), and BOTH proceeds+refund legs export to that owner, never to the relay tx sender. - txs/tx.go: RelayOrderTx.Verify now does present-gated secp256k1 authentication of From (Sign/SigningBytes/verifyFrom), wiring the dangling ErrInvalidSignature. From bears NO authority (authority is the escrow owner), so an unsigned relay is admissible and cannot escalate; a present-but-invalid signature is rejected. Regression: redteam_escrow_owner_test.go proves the attacker is credited 0 even with a valid own-key signature (defense in depth: escrow-owner bind holds independent of the signature). Fixed a pre-existing off-by-one in TestRED_FractionalFill (export asset offset). All dexvm tests pass (default + -tags redteam, CGO_ENABLED=0 pure-Go). Gated on red re-verify; NOT promoted to testnet/mainnet.
This commit is contained in:
+15
-1
@@ -251,14 +251,28 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
|
||||
// id a relay binds to via RelayOrderTx.CollateralRef. The locked amount is
|
||||
// the credited output total; its asset is the locked asset. An import with no
|
||||
// credited outputs (pure fee burn) locks nothing to refund.
|
||||
//
|
||||
// The escrow's OWNER is the AUTHENTICATED owner of the consumed C->D object —
|
||||
// importedOwner, read back from shared memory and bound to every credited output
|
||||
// above. This is the CRITICAL escrow-theft fix: settleFromFills derives BOTH the
|
||||
// settle authority and the proceeds/refund payout target from this recorded
|
||||
// owner, never from the unauthenticated relay tx sender, so a relay naming a
|
||||
// victim's collateral ref cannot settle it or redirect its value. When there is
|
||||
// no shared memory (single-chain test mode) there is no real cross-chain UTXO to
|
||||
// authenticate against; the structurally-verified credited owner (Outputs[0].Owner,
|
||||
// pinned uniform with the asset/rail axes in Verify) is the escrow owner.
|
||||
if len(tx.Outputs) > 0 {
|
||||
ref := tx.ImportedInputs[0].UTXOID
|
||||
lockedAsset := tx.Outputs[0].Asset
|
||||
escrowOwner := importedOwner
|
||||
if !haveAsset {
|
||||
escrowOwner = tx.Outputs[0].Owner
|
||||
}
|
||||
var locked uint64
|
||||
for _, o := range tx.Outputs {
|
||||
locked += o.Amount
|
||||
}
|
||||
if err := vm.state.PutEscrow(ref, lockedAsset, locked); err != nil {
|
||||
if err := vm.state.PutEscrow(ref, escrowOwner, lockedAsset, locked); err != nil {
|
||||
return fmt.Errorf("import: record escrow: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +222,9 @@ func TestSettleConsumesEscrowOnce(t *testing.T) {
|
||||
lockedAsset := ids.GenerateTestID()
|
||||
ref := deriveUTXOID(ids.GenerateTestID(), 0)
|
||||
|
||||
// Record an escrow of 1000 directly (the import leg's effect).
|
||||
if err := h.vm.state.PutEscrow(ref, lockedAsset, 1000); err != nil {
|
||||
// Record an escrow of 1000 directly (the import leg's effect). The escrow owner
|
||||
// is the taker — the authenticated owner who alone may settle it (CRITICAL bind).
|
||||
if err := h.vm.state.PutEscrow(ref, taker, lockedAsset, 1000); err != nil {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
|
||||
@@ -232,7 +233,7 @@ func TestSettleConsumesEscrowOnce(t *testing.T) {
|
||||
if err := h.vm.settleFromFills(taker, ref, nil, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
t.Fatalf("first settle: %v", err)
|
||||
}
|
||||
if _, _, found, _ := h.vm.state.GetEscrow(ref); found {
|
||||
if _, _, _, found, _ := h.vm.state.GetEscrow(ref); found {
|
||||
t.Fatal("escrow still present after settle — not consumed")
|
||||
}
|
||||
|
||||
@@ -290,7 +291,7 @@ func TestFailedRelayCommitsNothing(t *testing.T) {
|
||||
}
|
||||
// The escrow is consumed exactly once by the refunding settle (it cannot be
|
||||
// refunded again).
|
||||
_, _, found, _ := h.vm.state.GetEscrow(srcUTXOID)
|
||||
_, _, _, found, _ := h.vm.state.GetEscrow(srcUTXOID)
|
||||
if found {
|
||||
t.Fatalf("escrow after refund must be consumed exactly once, but it is still present")
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
|
||||
collateralRef := ids.GenerateTestID()
|
||||
lockedAsset := ids.GenerateTestID()
|
||||
const lockedQuote uint64 = 10
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, lockedQuote); err != nil {
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, lockedQuote); err != nil {
|
||||
t.Fatalf("seed escrow: %v", err)
|
||||
}
|
||||
|
||||
@@ -328,14 +328,16 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
|
||||
}
|
||||
|
||||
// Split the exported value by asset: base proceeds vs the locked-quote refund.
|
||||
// Export wire is rail(1)|owner(20)|asset(32)|amount(8) (encodeExportedOutput), so
|
||||
// asset is [21:53] and amount [53:61].
|
||||
var base, refund uint64
|
||||
for _, reqs := range ar.reqs {
|
||||
for _, e := range reqs.PutRequests {
|
||||
if len(e.Value) < 60 {
|
||||
if len(e.Value) != exportedOutputSize {
|
||||
continue
|
||||
}
|
||||
var a ids.ID
|
||||
copy(a[:], e.Value[20:52])
|
||||
copy(a[:], e.Value[21:53])
|
||||
amt := binary.BigEndian.Uint64(e.Value[53:61])
|
||||
if a == lockedAsset {
|
||||
refund += amt
|
||||
@@ -443,7 +445,7 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
collateralRef := ids.GenerateTestID()
|
||||
lockedAsset := ids.GenerateTestID()
|
||||
const lockedQuote uint64 = 1000 // tiny escrow; an inflated refund would mint
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, lockedQuote); err != nil {
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, lockedQuote); err != nil {
|
||||
t.Fatalf("seed escrow: %v", err)
|
||||
}
|
||||
|
||||
@@ -461,7 +463,7 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
}
|
||||
// The escrow must NOT have been consumed by a refused settle (so a later,
|
||||
// well-formed relay can still legitimately refund it).
|
||||
_, _, haveEscrow, eerr := cvm.inner.state.GetEscrow(collateralRef)
|
||||
_, _, _, haveEscrow, eerr := cvm.inner.state.GetEscrow(collateralRef)
|
||||
if eerr != nil {
|
||||
t.Fatalf("escrow lookup: %v", eerr)
|
||||
}
|
||||
@@ -682,7 +684,7 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
|
||||
const locked uint64 = 100
|
||||
|
||||
// Import recorded escrow of 100 quote under this ref.
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, locked); err != nil {
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, locked); err != nil {
|
||||
t.Fatalf("seed escrow: %v", err)
|
||||
}
|
||||
|
||||
@@ -781,7 +783,7 @@ func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
|
||||
// refund leg something to conserve so the export carries multiple outputs.
|
||||
cvm, _, _, _, _ := newCountingHarness(t, fills)
|
||||
cvm.inner.consensusRuntime.CChainID = sharedCChainID
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, locked); err != nil {
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, locked); err != nil {
|
||||
t.Fatalf("%s: seed escrow: %v", label, err)
|
||||
}
|
||||
ar := newAtomicRequests()
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build redteam
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/ids"
|
||||
|
||||
"github.com/luxfi/chains/dexvm/txs"
|
||||
)
|
||||
|
||||
// swapRelayFromField re-encodes a relay's wire bytes (type(1)|JSON) with ONLY the
|
||||
// "from" field changed to newFrom, leaving the signature and every other field
|
||||
// byte-identical — so a Verify failure isolates the From axis.
|
||||
func swapRelayFromField(t *testing.T, wire []byte, newFrom ids.ShortID) []byte {
|
||||
t.Helper()
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(wire[1:], &m); err != nil {
|
||||
t.Fatalf("unmarshal relay body: %v", err)
|
||||
}
|
||||
fromJSON, err := newFrom.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal from: %v", err)
|
||||
}
|
||||
m["from"] = fromJSON
|
||||
body, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal relay body: %v", err)
|
||||
}
|
||||
return append([]byte{wire[0]}, body...)
|
||||
}
|
||||
|
||||
// redteam_escrow_owner_test.go is the CRITICAL escrow-theft RED suite. It proves
|
||||
// the FIXED ship rule for the swap rail's value-return leg:
|
||||
//
|
||||
// the collateral an import locks is owned by the AUTHENTICATED owner recorded on
|
||||
// the consumed C->D object; ONLY that owner may settle it, and the proceeds +
|
||||
// refund are exported to THAT owner — never to the relay tx's (unauthenticated)
|
||||
// sender.
|
||||
//
|
||||
// The pre-fix exploit (proven CLOSED here): the escrow stored only (asset, amount)
|
||||
// with NO owner, and settleFromFills exported both legs to `taker = r.sender` (the
|
||||
// RelayOrderTx sender). RelayOrderTx.Verify authenticated nothing, so any fee-paying
|
||||
// tx could name a VICTIM's collateral ref with an attacker From; a ~zero fill made
|
||||
// refund == locked, exported to the attacker out of the SHARED seam reserve =
|
||||
// cross-depositor theft. Now the escrow records the importer's owner and settle
|
||||
// binds BOTH authority and payout to it.
|
||||
|
||||
// TestRED_Escrow_SettleBoundToImporterOwner is the END-TO-END theft proof on the
|
||||
// real import -> relay -> settle path. A victim imports collateral; an ATTACKER
|
||||
// submits a RelayOrderTx naming the victim's collateral ref with the attacker as
|
||||
// From. At settle (proposer build+accept), the refund/proceeds MUST go to the
|
||||
// victim (the recorded escrow owner) and the attacker MUST receive nothing.
|
||||
func TestRED_Escrow_SettleBoundToImporterOwner(t *testing.T) {
|
||||
// Zero fills => the whole locked collateral is a refund (the cleanest theft
|
||||
// vector: attacker tries to walk away with the full lock).
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
victim := ids.GenerateTestShortID()
|
||||
attacker := ids.GenerateTestShortID()
|
||||
asset := ids.GenerateTestID()
|
||||
const locked uint64 = 1000
|
||||
|
||||
// The VICTIM imports 1000 of `asset` (the C-Chain exported it to the proxy for
|
||||
// the victim; the import binds the escrow owner = victim from the recorded UTXO).
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, victim, asset, locked)
|
||||
importTx := newImportTxBytes(t, victim, cvm.inner.cChainID(), srcUTXOID, asset, locked)
|
||||
|
||||
// The ATTACKER submits a settling relay (clob_submit) naming the VICTIM's
|
||||
// collateral ref, with the attacker as the tx sender (From). Pre-fix this would
|
||||
// export the refund to the attacker; post-fix the settle is bound to the escrow
|
||||
// owner (victim) and refuses/redirects.
|
||||
attackerRelay := newRelayTxBytes(t, attacker, srcUTXOID, clobSubmitPayload(asset, locked))
|
||||
|
||||
// Proposer builds (relays once; matcher returns ZERO fills) + accepts (settle).
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, attackerRelay}
|
||||
built, err := cvm.BuildBlock(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBlock: %v", err)
|
||||
}
|
||||
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
|
||||
t.Fatalf("SetPreference: %v", err)
|
||||
}
|
||||
if err := built.Verify(ctx); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if err := built.Accept(ctx); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
|
||||
attackerCredited := creditedTo(t, cChainSM, proxyChain, attacker)
|
||||
victimCredited := creditedTo(t, cChainSM, proxyChain, victim)
|
||||
t.Logf("AFTER ATTACKER-RELAY SETTLE: attacker credited=%d victim credited=%d (locked=%d)",
|
||||
attackerCredited, victimCredited, locked)
|
||||
|
||||
// THE THEFT IS CLOSED: the attacker (the relay sender, NOT the escrow owner)
|
||||
// receives NOTHING. The collateral never leaves the victim's ownership.
|
||||
if attackerCredited != 0 {
|
||||
t.Fatalf("ESCROW THEFT: the attacker (relay sender, not the escrow owner) was credited %d "+
|
||||
"out of the victim's locked collateral — settle authority + payout MUST bind to the "+
|
||||
"recorded escrow owner, never the unauthenticated relay sender.", attackerCredited)
|
||||
}
|
||||
// The escrow is left intact for the rightful owner (the unauthorized settle is
|
||||
// refused before it consumes the escrow), so the victim's value is recoverable.
|
||||
_, _, _, found, eerr := cvm.inner.state.GetEscrow(srcUTXOID)
|
||||
if eerr != nil {
|
||||
t.Fatalf("escrow lookup: %v", eerr)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("ESCROW THEFT: the unauthorized attacker settle CONSUMED the victim's escrow — a " +
|
||||
"refused settle must leave the escrow intact and recoverable by the rightful owner.")
|
||||
}
|
||||
if submits, _, _ := matcher.counts(); submits != 1 {
|
||||
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_Escrow_OwnerMaySettleAndIsPaid is the POSITIVE control: when the
|
||||
// RIGHTFUL owner (the importer) submits the settling relay, the settle succeeds and
|
||||
// the owner is paid the full refund. The bind is authority, not a blanket denial.
|
||||
func TestRED_Escrow_OwnerMaySettleAndIsPaid(t *testing.T) {
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, nil) // zero fills => full refund
|
||||
ctx := context.Background()
|
||||
|
||||
owner := ids.GenerateTestShortID()
|
||||
asset := ids.GenerateTestID()
|
||||
const locked uint64 = 1000
|
||||
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, owner, asset, locked)
|
||||
importTx := newImportTxBytes(t, owner, cvm.inner.cChainID(), srcUTXOID, asset, locked)
|
||||
ownerRelay := newRelayTxBytes(t, owner, srcUTXOID, clobSubmitPayload(asset, locked))
|
||||
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, ownerRelay}
|
||||
built, err := cvm.BuildBlock(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBlock: %v", err)
|
||||
}
|
||||
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
|
||||
t.Fatalf("SetPreference: %v", err)
|
||||
}
|
||||
if err := built.Verify(ctx); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if err := built.Accept(ctx); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
|
||||
credited := creditedTo(t, cChainSM, proxyChain, owner)
|
||||
t.Logf("AFTER RIGHTFUL-OWNER SETTLE: owner credited=%d (locked=%d)", credited, locked)
|
||||
if credited != locked {
|
||||
t.Fatalf("the rightful escrow owner's settle must refund the full locked %d, got %d", locked, credited)
|
||||
}
|
||||
// The escrow is consumed exactly once by the legitimate settle.
|
||||
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); found {
|
||||
t.Fatalf("a completed legitimate settle must consume the escrow exactly once (still present)")
|
||||
}
|
||||
if submits, _, _ := matcher.counts(); submits != 1 {
|
||||
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_Escrow_DirectSettleRejectsForeignSender pins the authority bind at the
|
||||
// unit level (independent of the build path): settleFromFills with a sender that is
|
||||
// NOT the recorded escrow owner returns errSettleUnauthorized and moves no value,
|
||||
// leaving the escrow intact.
|
||||
func TestRED_Escrow_DirectSettleRejectsForeignSender(t *testing.T) {
|
||||
cvm, _, _, _, _ := newCountingHarness(t, nil)
|
||||
|
||||
owner := ids.GenerateTestShortID()
|
||||
foreign := ids.GenerateTestShortID()
|
||||
ref := deriveUTXOID(ids.GenerateTestID(), 0)
|
||||
asset := ids.GenerateTestID()
|
||||
const locked uint64 = 500
|
||||
|
||||
if err := cvm.inner.state.PutEscrow(ref, owner, asset, locked); err != nil {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
|
||||
// A foreign sender (not the escrow owner) attempts to settle => refused.
|
||||
ar := newAtomicRequests()
|
||||
err := cvm.inner.settleFromFills(foreign, ref, nil, ids.GenerateTestID(), 0, ar)
|
||||
if err != errSettleUnauthorized {
|
||||
t.Fatalf("a settle by a non-owner sender MUST return errSettleUnauthorized, got: %v", err)
|
||||
}
|
||||
if !ar.empty() {
|
||||
t.Fatalf("a refused (unauthorized) settle must export nothing")
|
||||
}
|
||||
// The escrow is intact — the rightful owner can still settle it.
|
||||
if _, _, _, found, _ := cvm.inner.state.GetEscrow(ref); !found {
|
||||
t.Fatalf("a refused unauthorized settle must leave the escrow intact")
|
||||
}
|
||||
|
||||
// The rightful owner then settles successfully (zero fills => full refund).
|
||||
ar2 := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(owner, ref, nil, ids.GenerateTestID(), 1, ar2); err != nil {
|
||||
t.Fatalf("the rightful owner's settle must succeed, got: %v", err)
|
||||
}
|
||||
var refundToOwner uint64
|
||||
for _, reqs := range ar2.reqs {
|
||||
for _, e := range reqs.PutRequests {
|
||||
if len(e.Value) >= exportedOutputSize {
|
||||
var o ids.ShortID
|
||||
copy(o[:], e.Value[1:21]) // export wire: rail(1)|owner(20)|asset(32)|amount(8)
|
||||
if o == owner {
|
||||
refundToOwner += binary.BigEndian.Uint64(e.Value[53:61])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if refundToOwner != locked {
|
||||
t.Fatalf("the rightful owner must be refunded the full locked %d, got %d", locked, refundToOwner)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Signature-authentication of RelayOrderTx.From (provenance hardening) -------
|
||||
|
||||
// TestRED_Relay_SignatureAuthenticatesFrom proves the present-gated secp256k1 bind
|
||||
// on RelayOrderTx.From: a tx signed by `from`'s key Verifies; the SAME tx with From
|
||||
// swapped to a victim (a spoofed-From relay carrying someone else's signature) is
|
||||
// REFUSED at admission with ErrInvalidSignature; an unsigned tx is permitted (From
|
||||
// carries no authority — the escrow bind is the authority).
|
||||
func TestRED_Relay_SignatureAuthenticatesFrom(t *testing.T) {
|
||||
key, err := secp256k1.NewPrivateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
ref := deriveUTXOID(ids.GenerateTestID(), 0)
|
||||
|
||||
// (a) A signed relay Verifies and stamps From = the signer's EVM address.
|
||||
signed := txs.NewRelayOrderTx(ids.ShortEmpty, 0, ZAPMethodSubmit, []byte{0x01}, ref)
|
||||
if err := signed.Sign(key); err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if ids.ShortID(key.EVMAddress()) != signed.Sender() {
|
||||
t.Fatalf("Sign must set From to the signer's EVM address")
|
||||
}
|
||||
if err := signed.Verify(); err != nil {
|
||||
t.Fatalf("a correctly-signed relay must Verify, got: %v", err)
|
||||
}
|
||||
// The signed tx must survive a wire round-trip and still Verify (the signature
|
||||
// commits to the unsigned image, not to itself).
|
||||
parser := &txs.TxParser{}
|
||||
reparsed, perr := parser.Parse(signed.Bytes())
|
||||
if perr != nil {
|
||||
t.Fatalf("reparse signed relay: %v", perr)
|
||||
}
|
||||
if err := reparsed.Verify(); err != nil {
|
||||
t.Fatalf("a reparsed signed relay must Verify, got: %v", err)
|
||||
}
|
||||
|
||||
// (b) SPOOFED From: re-encode the EXACT signed wire image with ONLY From swapped to
|
||||
// a victim, then reparse + Verify. The signature still recovers to the original
|
||||
// signer, not to the (victim) From, so the bind refuses it => ErrInvalidSignature.
|
||||
// (Mutating the parsed JSON's "from" field isolates the spoof to the From axis.)
|
||||
victim := ids.GenerateTestShortID()
|
||||
spoofWire := swapRelayFromField(t, signed.Bytes(), victim)
|
||||
spoof, perr2 := parser.Parse(spoofWire)
|
||||
if perr2 != nil {
|
||||
t.Fatalf("parse spoof wire: %v", perr2)
|
||||
}
|
||||
if err := spoof.Verify(); err != txs.ErrInvalidSignature {
|
||||
t.Fatalf("a spoofed-From relay (someone else's signature) MUST revert ErrInvalidSignature, got: %v", err)
|
||||
}
|
||||
|
||||
// (c) UNSIGNED relay is permitted at admission (From has no authority; the escrow
|
||||
// owner bind is what governs settlement — see TestRED_Escrow_*).
|
||||
unsigned := txs.NewRelayOrderTx(victim, 0, ZAPMethodSubmit, []byte{0x01}, ref)
|
||||
if err := unsigned.Verify(); err != nil {
|
||||
t.Fatalf("an unsigned relay must be admitted (authority is the escrow bind), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_Escrow_SignedAttackerStillCannotSteal is the defense-in-depth proof: even
|
||||
// a VALID signature (the attacker signs with their OWN key, so From is authentic) does
|
||||
// NOT let the attacker settle a victim's collateral — because the settle authority +
|
||||
// payout bind to the escrow's recorded owner (the victim), not to the authenticated
|
||||
// relay sender. Layer 1 (escrow owner) holds independently of Layer 2 (signature).
|
||||
func TestRED_Escrow_SignedAttackerStillCannotSteal(t *testing.T) {
|
||||
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
attackerKey, err := secp256k1.NewPrivateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
attacker := ids.ShortID(attackerKey.EVMAddress())
|
||||
victim := ids.GenerateTestShortID()
|
||||
asset := ids.GenerateTestID()
|
||||
const locked uint64 = 1000
|
||||
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, victim, asset, locked)
|
||||
importTx := newImportTxBytes(t, victim, cvm.inner.cChainID(), srcUTXOID, asset, locked)
|
||||
|
||||
// The attacker signs a settling relay with their OWN key (so the signature is
|
||||
// VALID and From == attacker), naming the victim's collateral ref.
|
||||
relay := txs.NewRelayOrderTx(ids.ShortEmpty, 0, ZAPMethodSubmit, clobSubmitPayload(asset, locked), srcUTXOID)
|
||||
if err := relay.Sign(attackerKey); err != nil {
|
||||
t.Fatalf("attacker sign: %v", err)
|
||||
}
|
||||
if err := relay.Verify(); err != nil {
|
||||
t.Fatalf("attacker's own-key signature must itself Verify (it is authentic), got: %v", err)
|
||||
}
|
||||
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, relay.Bytes()}
|
||||
built, err := cvm.BuildBlock(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBlock: %v", err)
|
||||
}
|
||||
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
|
||||
t.Fatalf("SetPreference: %v", err)
|
||||
}
|
||||
if err := built.Verify(ctx); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if err := built.Accept(ctx); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
|
||||
attackerCredited := creditedTo(t, cChainSM, proxyChain, attacker)
|
||||
t.Logf("AFTER SIGNED-ATTACKER SETTLE: attacker credited=%d (locked=%d)", attackerCredited, locked)
|
||||
if attackerCredited != 0 {
|
||||
t.Fatalf("DEFENSE-IN-DEPTH BROKEN: an attacker with a VALID signature (authentic From) was "+
|
||||
"credited %d of the victim's collateral — settle authority MUST be the escrow's recorded "+
|
||||
"owner (the victim), independent of who signed the relay.", attackerCredited)
|
||||
}
|
||||
// Escrow intact (unauthorized settle refused before consuming it).
|
||||
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); !found {
|
||||
t.Fatalf("a refused signed-attacker settle must leave the victim's escrow intact")
|
||||
}
|
||||
}
|
||||
+45
-29
@@ -10,19 +10,19 @@
|
||||
//
|
||||
// 1. NONCES — per-account replay protection for proxy txs.
|
||||
// 2. RELAY RECEIPTS — in-flight clob_* relays bound to (blockHash, txIndex),
|
||||
// so a re-execution / reorg / retry maps to exactly one
|
||||
// d-chain match (replay-idempotency).
|
||||
// so a re-execution / reorg / retry maps to exactly one
|
||||
// d-chain match (replay-idempotency).
|
||||
// 3. CONSUMED UTXOs — the atomic-UTXO consumption set: source-chain UTXO ids
|
||||
// already claimed by an Import, so the same exported
|
||||
// value can never be imported twice.
|
||||
// already claimed by an Import, so the same exported
|
||||
// value can never be imported twice.
|
||||
// 4. COLLATERAL ESCROW — the locked-collateral ledger: per collateral ref, the
|
||||
// (asset, amount) an Import locked into the proxy. It is
|
||||
// the value-conservation witness: a settle credits the
|
||||
// realized proceeds and REFUNDS the unfilled remainder of
|
||||
// this locked amount, so value_in == value_out exactly.
|
||||
// This is NOT canonical DEX state — it is the transport
|
||||
// layer's record of value in flight, the exact analogue
|
||||
// of the consumed-UTXO set for the return leg.
|
||||
// (asset, amount) an Import locked into the proxy. It is
|
||||
// the value-conservation witness: a settle credits the
|
||||
// realized proceeds and REFUNDS the unfilled remainder of
|
||||
// this locked amount, so value_in == value_out exactly.
|
||||
// This is NOT canonical DEX state — it is the transport
|
||||
// layer's record of value in flight, the exact analogue
|
||||
// of the consumed-UTXO set for the return leg.
|
||||
package state
|
||||
|
||||
import (
|
||||
@@ -190,37 +190,53 @@ func escrowKey(ref ids.ID) []byte {
|
||||
return append(append([]byte{}, prefixEscrow...), ref[:]...)
|
||||
}
|
||||
|
||||
// PutEscrow records the (asset, amount) an Import locked under a collateral ref.
|
||||
// The stored value is asset(32)||amount(8). Recording is the import leg of the
|
||||
// conservation equation; the matching ConsumeEscrow at settle pays the refund.
|
||||
func (s *State) PutEscrow(ref ids.ID, asset ids.ID, amount uint64) error {
|
||||
// escrowValueSize is the fixed collateral-escrow value width: owner(20) |
|
||||
// asset(32) | amount(8). The OWNER leads the record — it is the AUTHORITATIVE
|
||||
// settle-authority + payout target, the recorded owner of the consumed C->D UTXO
|
||||
// (executeImport reads it back from shared memory and binds the credited outputs to
|
||||
// it). Persisting it here is the CRITICAL escrow-theft fix: the settle leg derives
|
||||
// who may settle and where the proceeds/refund go from THIS recorded owner, never
|
||||
// from the unauthenticated relay tx sender, so an attacker naming a victim's
|
||||
// collateral ref can neither settle it nor redirect its value.
|
||||
const escrowValueSize = 20 + 32 + 8
|
||||
|
||||
// PutEscrow records the (owner, asset, amount) an Import locked under a collateral
|
||||
// ref. The stored value is owner(20)||asset(32)||amount(8). owner is the recorded
|
||||
// owner of the consumed C->D object (the authenticated cross-chain value's owner,
|
||||
// bound in executeImport) — the only account that may later settle this escrow and
|
||||
// the sole payout target for its proceeds + refund. Recording is the import leg of
|
||||
// the conservation equation; the matching ConsumeEscrow at settle pays the refund.
|
||||
func (s *State) PutEscrow(ref ids.ID, owner ids.ShortID, asset ids.ID, amount uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
val := make([]byte, 40)
|
||||
copy(val[0:32], asset[:])
|
||||
binary.BigEndian.PutUint64(val[32:40], amount)
|
||||
val := make([]byte, escrowValueSize)
|
||||
copy(val[0:20], owner[:])
|
||||
copy(val[20:52], asset[:])
|
||||
binary.BigEndian.PutUint64(val[52:60], amount)
|
||||
return s.db.Put(escrowKey(ref), val)
|
||||
}
|
||||
|
||||
// GetEscrow returns the (asset, amount) locked under a collateral ref. found is
|
||||
// false when no escrow exists (e.g. a relay that was not preceded by an import
|
||||
// in this proxy — then there is nothing to refund and nothing to settle).
|
||||
func (s *State) GetEscrow(ref ids.ID) (asset ids.ID, amount uint64, found bool, err error) {
|
||||
// GetEscrow returns the (owner, asset, amount) locked under a collateral ref. found
|
||||
// is false when no escrow exists (e.g. a relay that was not preceded by an import
|
||||
// in this proxy — then there is nothing to refund and nothing to settle). owner is
|
||||
// the recorded owner the settle leg binds settle-authority and the payout target to.
|
||||
func (s *State) GetEscrow(ref ids.ID) (owner ids.ShortID, asset ids.ID, amount uint64, found bool, err error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
data, gerr := s.db.Get(escrowKey(ref))
|
||||
if gerr != nil {
|
||||
if errors.Is(gerr, database.ErrNotFound) {
|
||||
return ids.Empty, 0, false, nil
|
||||
return ids.ShortEmpty, ids.Empty, 0, false, nil
|
||||
}
|
||||
return ids.Empty, 0, false, gerr
|
||||
return ids.ShortEmpty, ids.Empty, 0, false, gerr
|
||||
}
|
||||
if len(data) < 40 {
|
||||
return ids.Empty, 0, false, ErrStateCorrupted
|
||||
if len(data) < escrowValueSize {
|
||||
return ids.ShortEmpty, ids.Empty, 0, false, ErrStateCorrupted
|
||||
}
|
||||
copy(asset[:], data[0:32])
|
||||
amount = binary.BigEndian.Uint64(data[32:40])
|
||||
return asset, amount, true, nil
|
||||
copy(owner[:], data[0:20])
|
||||
copy(asset[:], data[20:52])
|
||||
amount = binary.BigEndian.Uint64(data[52:60])
|
||||
return owner, asset, amount, true, nil
|
||||
}
|
||||
|
||||
// ConsumeEscrow deletes a collateral escrow once it has been settled, so the
|
||||
|
||||
+84
-1
@@ -24,14 +24,20 @@
|
||||
package txs
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidSignature is returned by RelayOrderTx.Verify when a relay carries a
|
||||
// signature that does not recover to its From (a spoofed-From relay). It is the
|
||||
// admission-time provenance gate; settle authority itself derives from the
|
||||
// consumed C->D object's recorded owner (the escrow owner), not from From.
|
||||
ErrInvalidSignature = errors.New("invalid signature")
|
||||
ErrInvalidTxType = errors.New("invalid transaction type")
|
||||
ErrInvalidAmount = errors.New("invalid amount")
|
||||
@@ -334,7 +340,9 @@ type RelayOrderTx struct {
|
||||
CollateralRef ids.ID `json:"collateralRef"`
|
||||
}
|
||||
|
||||
// NewRelayOrderTx creates a new relay-order transaction.
|
||||
// NewRelayOrderTx creates a new relay-order transaction (UNSIGNED). From carries no
|
||||
// settle authority (the escrow owner does), so an unsigned relay is admissible; a
|
||||
// client that wants authenticated From provenance calls Sign afterwards.
|
||||
func NewRelayOrderTx(from ids.ShortID, nonce uint64, method string, payload []byte, collateralRef ids.ID) *RelayOrderTx {
|
||||
tx := &RelayOrderTx{
|
||||
BaseTx: BaseTx{
|
||||
@@ -359,9 +367,84 @@ func (tx *RelayOrderTx) Verify() error {
|
||||
if len(tx.Payload) == 0 {
|
||||
return errors.New("relay: empty payload")
|
||||
}
|
||||
// PROVENANCE AUTHENTICATION of the From field. From carries NO settle authority —
|
||||
// the settle authority + payout target derive from the consumed C->D object's
|
||||
// recorded owner (the escrow owner persisted at import; see chains/dexvm
|
||||
// settleFromFills), exactly as platformvm import/export authority comes from the
|
||||
// consumed UTXO, not a tx-level identity. So an UNSIGNED relay cannot escalate: it
|
||||
// can only settle to whatever escrow its CollateralRef names, and that escrow pays
|
||||
// its recorded owner regardless of From. The signature, WHEN PRESENT, cryptographi-
|
||||
// cally binds From (a secp256k1/EVM-address recovery over the unsigned wire image),
|
||||
// giving the routing/audit layer authenticated provenance and wiring the otherwise-
|
||||
// dangling ErrInvalidSignature. A present-but-invalid signature is rejected at
|
||||
// admission; an absent one is permitted (authority lives in the escrow bind).
|
||||
if len(tx.Signature) > 0 {
|
||||
if err := tx.verifyFrom(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SigningBytes is the canonical message a RelayOrderTx signature commits to: the
|
||||
// wire bytes with the Signature field cleared (so the signature binds From, Method,
|
||||
// Payload, CollateralRef, Nonce — but never itself). Deterministic (the struct has
|
||||
// no maps; encoding/json emits fields in declaration order).
|
||||
func (tx *RelayOrderTx) SigningBytes() ([]byte, error) {
|
||||
unsigned := *tx
|
||||
unsigned.BaseTx.Signature = nil
|
||||
unsigned.BaseTx.bytes = nil
|
||||
unsigned.BaseTx.TxID = ids.Empty
|
||||
return Marshal(&unsigned, TxRelayOrder)
|
||||
}
|
||||
|
||||
// Sign stamps the relay with a secp256k1 signature over SigningBytes by `key`, and
|
||||
// sets From to key's EVM address (the owner format the C->D object carries). After
|
||||
// this, Verify authenticates From cryptographically. Used by clients/keepers that
|
||||
// build relays; the proxy itself never signs (it derives authority from escrow).
|
||||
func (tx *RelayOrderTx) Sign(key *secp256k1.PrivateKey) error {
|
||||
tx.From = key.EVMAddress()
|
||||
msg, err := tx.SigningBytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sig, err := key.SignHash(hashRelaySigningBytes(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Signature = sig
|
||||
// Re-stamp the wire bytes + TxID now that From + Signature are set, so the signed
|
||||
// tx is immediately wire-ready and Parse-round-trippable (same as every New*Tx).
|
||||
finalize(tx, &tx.BaseTx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFrom recovers the signer of the relay from its signature over the unsigned
|
||||
// wire image and requires the recovered EVM address to equal tx.From. A mismatch (or
|
||||
// an unrecoverable signature) is ErrInvalidSignature — a spoofed From is refused at
|
||||
// admission.
|
||||
func (tx *RelayOrderTx) verifyFrom() error {
|
||||
msg, err := tx.SigningBytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, err := secp256k1.RecoverPublicKeyFromHash(hashRelaySigningBytes(msg), tx.Signature)
|
||||
if err != nil {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
if ids.ShortID(pub.EVMAddress()) != tx.From {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashRelaySigningBytes is the single hash the relay sign+recover paths share, so
|
||||
// signing and verification commit to the identical digest of the unsigned wire image.
|
||||
func hashRelaySigningBytes(msg []byte) []byte {
|
||||
h := sha256.Sum256(msg)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// PlaceOrderTx is a thin relay envelope for a CLOB limit-order placement. It
|
||||
// carries the wire fields a clob_place frame needs but NO matching logic — the
|
||||
// VM forwards it to the d-chain and settles from the returned ack/fills.
|
||||
|
||||
+32
-2
@@ -91,6 +91,15 @@ var (
|
||||
// victim's exported UTXO and credit it to their own account.
|
||||
errImportWrongOwner = errors.New("import: credited owner != consumed UTXO owner")
|
||||
|
||||
// errSettleUnauthorized guards the CRITICAL escrow-theft seam: a settle is
|
||||
// authorized ONLY by the escrow's recorded owner (the authenticated owner of the
|
||||
// consumed C->D object, persisted at import). A relay tx whose sender is not that
|
||||
// owner cannot settle the collateral — and the proceeds/refund are exported to the
|
||||
// recorded owner regardless, never to the tx sender. Before this bind, an
|
||||
// unauthenticated RelayOrderTx naming a victim's collateral ref settled to the
|
||||
// attacker out of the shared seam reserve (cross-depositor theft).
|
||||
errSettleUnauthorized = errors.New("settle: relay sender is not the escrow owner (unauthorized settle)")
|
||||
|
||||
_ = errNotBootstrapped
|
||||
_ = errShutdown
|
||||
)
|
||||
@@ -820,14 +829,35 @@ func (vm *VM) settleCarried(result *BlockResult, ar *atomicRequests) {
|
||||
// coordinate that keys the idempotency receipt — never by wall-clock time. A
|
||||
// time.Now() in the export identity would make deriveUTXOID(tx.ID(), i) differ
|
||||
// per node and split the atomic commit on accept.
|
||||
func (vm *VM) settleFromFills(taker ids.ShortID, collateralRef ids.ID, fills []Fill, blockHash ids.ID, txIndex uint32, ar *atomicRequests) error {
|
||||
func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fills []Fill, blockHash ids.ID, txIndex uint32, ar *atomicRequests) error {
|
||||
// Resolve the locked collateral this settle must conserve. Absent escrow =>
|
||||
// nothing locked on the proxy side; fall back to proceeds-only settlement.
|
||||
lockedAsset, locked, haveEscrow, err := vm.state.GetEscrow(collateralRef)
|
||||
//
|
||||
// escrowOwner is the AUTHENTICATED owner recorded at import (the consumed C->D
|
||||
// object's owner). It — not relaySender (the unauthenticated relay tx sender) — is
|
||||
// the authority to settle this collateral AND the sole payout target. This is the
|
||||
// CRITICAL escrow-theft close: a relay naming a victim's collateral ref settles to
|
||||
// the victim (or is refused), never to the attacker.
|
||||
escrowOwner, lockedAsset, locked, haveEscrow, err := vm.state.GetEscrow(collateralRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("settle: escrow lookup: %w", err)
|
||||
}
|
||||
|
||||
// AUTHORITY BIND (CRITICAL): when collateral is escrowed, ONLY its recorded owner
|
||||
// may settle it. A relay whose sender is not the escrow owner is refused before any
|
||||
// value moves — the escrow is left intact for the rightful owner's settle. With no
|
||||
// escrow there is nothing locked to protect (proceeds-only; no shared pot is drawn).
|
||||
taker := relaySender
|
||||
if haveEscrow {
|
||||
if relaySender != escrowOwner {
|
||||
return errSettleUnauthorized
|
||||
}
|
||||
// The payout target is ALWAYS the recorded owner (== relaySender here, post-check)
|
||||
// — proceeds and refund legs are exported to escrowOwner, never to a freely-chosen
|
||||
// tx sender, so even a future caller-spoofing path cannot redirect value.
|
||||
taker = escrowOwner
|
||||
}
|
||||
|
||||
// Taker side: from the fills if any, else (zero-fill) from the locked-asset
|
||||
// identity recorded at import so a fully-unfilled order still refunds. A single
|
||||
// marketable submit takes exactly ONE side, so fills[0].Side governs the whole
|
||||
|
||||
Reference in New Issue
Block a user