Bind authorization scope on the strict-PQ order path

The classical SignedOrder binds ChainID, NetworkID and Expiry so a
captured order cannot replay onto another chain or after it expires.
The strict-PQ order carried none of them, so an ML-DSA-signed order was
good on every chain forever — the exact replay the classical path
already closed. Mirror the three scope fields onto SignedOrderPQ and
fold them into the shared SigningHash, so one digest binds the same
scope under either scheme. Mutating any scope field now breaks the
signature (regression test).

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-21 10:56:48 -07:00
co-authored by Hanzo Dev
parent 33157d5fed
commit 81fe9fa775
2 changed files with 55 additions and 1 deletions
+15 -1
View File
@@ -50,6 +50,14 @@ type SignedOrderPQ struct {
// the signature, so a malicious party cannot substitute their
// own pubkey on someone else's order.
Sender common.Address
// ChainID, NetworkID and Expiry are the authorization scope,
// mirroring SignedOrder so the shared SigningHash binds the
// same chain, network and deadline for a strict-PQ order as for
// a classical one — a captured order does not replay onto
// another chain or after it expires under either scheme.
ChainID [32]byte
NetworkID uint32
Expiry uint64
}
// HasPQEvidence implements pq.PQEvidencer. A non-nil order with a
@@ -100,7 +108,13 @@ func VerifyOrderPQ(order *SignedOrderPQ) error {
// across classical + strict-PQ paths — a client can compute
// one digest and sign it with whichever scheme the chain
// requires.
asClassical := SignedOrder{Order: order.Order, Sender: order.Sender}
asClassical := SignedOrder{
Order: order.Order,
Sender: order.Sender,
ChainID: order.ChainID,
NetworkID: order.NetworkID,
Expiry: order.Expiry,
}
hash, err := asClassical.SigningHash()
if err != nil {
return fmt.Errorf("lx/dex: SigningHash: %w", err)
+40
View File
@@ -138,3 +138,43 @@ func TestVerifyOrderForMode_StrictPQAcceptsPQ(t *testing.T) {
t.Fatalf("VerifyOrderForMode(StrictPQ, *SignedOrderPQ): %v", err)
}
}
// TestVerifyOrderPQ_BindsAuthorizationScope pins that the strict-PQ path binds
// the same authorization scope the classical path does: a captured order does
// not replay onto another chain or network, or after it expires. Mutating any
// scope field breaks the ML-DSA signature because VerifyOrderPQ folds ChainID,
// NetworkID and Expiry into the shared SigningHash.
func TestVerifyOrderPQ_BindsAuthorizationScope(t *testing.T) {
priv, pubBytes, order := signingMaterial(t)
sender := AddressFromMLDSAPubKey(pubBytes)
chain := [32]byte{0x11, 0x22, 0x33}
signed := SignedOrder{Order: order, Sender: sender, ChainID: chain, NetworkID: 7, Expiry: 1000}
hash, err := signed.SigningHash()
if err != nil {
t.Fatalf("SigningHash: %v", err)
}
sig, err := priv.Sign(rand.Reader, hash[:], nil)
if err != nil {
t.Fatalf("mldsa sign: %v", err)
}
base := SignedOrderPQ{
Order: order, Sig: sig, PubKey: pubBytes, Sender: sender,
ChainID: chain, NetworkID: 7, Expiry: 1000,
}
if err := VerifyOrderPQ(&base); err != nil {
t.Fatalf("a scoped PQ order must verify under its own scope: %v", err)
}
for name, mutate := range map[string]func(*SignedOrderPQ){
"chain": func(o *SignedOrderPQ) { o.ChainID[0] ^= 0xff },
"network": func(o *SignedOrderPQ) { o.NetworkID++ },
"expiry": func(o *SignedOrderPQ) { o.Expiry++ },
} {
bad := base
mutate(&bad)
if err := VerifyOrderPQ(&bad); err == nil {
t.Fatalf("mutating the %s scope must break the PQ order signature", name)
}
}
}