Files
b774664d17 proofs/tamarin: fix wellformedness (3 specs)
- QuasarConsensus: bound `msg` by taking In(<msg,cert>) in Verify_Triple
  (was unbound/underivable).
- MPCBridge: bound the relayer message fields via In(<action,chainId,token,
  amount,nonce>) in Relayer_Combine (were unbound).
- RNSHybridHandshake: derive the session key K once in the responder's Msg2
  rule and carry it in state, so Msg4 binds K directly instead of recomputing
  the DH product inline (cleared the global derivation-check timeout). All 11
  lemmas and key-agreement semantics unchanged.
All Tamarin wellformedness checks pass.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-10 22:34:27 -07:00

404 lines
13 KiB
Plaintext

/*
* MPC Bridge Signing Protocol
* Formal model in Tamarin Prover
*
* Protocol source:
* ~/work/lux/bridge/mpc/
* ~/work/lux/bridge/contracts/
*
* Cryptographic suite:
* Threshold signatures: 2-of-3 MPC (Shamir secret sharing + additive combination)
* Message format: <action, chainId, token, amount, nonce>
* Group key: derived from individual signer shares
*
* Protocol:
* 1. Relayer observes event on source chain (Lock or Burn)
* 2. Relayer sends signing request to MPC signers
* 3. Each signer verifies the event and produces a partial signature
* 4. Relayer collects t (=2) partial signatures and combines into group signature
* 5. Relayer submits transaction with group signature to destination chain
* 6. Destination contract verifies group signature and processes action
*
* Tamarin abstraction choices:
* - Individual signer keys modeled as fresh values
* - Partial signatures modeled as sign(message, signer_key)
* - Group signature modeled as h(<partial_sig_i, partial_sig_j>)
* - Group key derivation modeled as h(<pk_1, pk_2, pk_3>)
* - Contract verification checks group signature against group key
* - Chain ID binding prevents cross-chain replay
* - Nonce tracking prevents same-chain replay
*
* Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
*/
theory MPCBridge
begin
builtins: signing, hashing
/*
* ==================================================================
* Function symbols
* ==================================================================
*/
functions:
/* Threshold signature combination: two partial sigs -> group sig */
combine_sigs/2, /* combine_sigs(partial_1, partial_2) -> group_sig */
/* Group key derivation from individual public keys */
group_pk/3, /* group_pk(pk_1, pk_2, pk_3) -> gpk */
/* Group signature verification */
group_verify/3, /* group_verify(message, group_sig, gpk) -> true/fail */
/* Partial signature creation */
partial_sign/2, /* partial_sign(message, sk_i) -> partial_sig_i */
/* Partial signature public key extraction (for combining) */
partial_pk/1 /* partial_pk(sk_i) -> pk_i */
/*
* ==================================================================
* Equations
* ==================================================================
*/
equations:
/* Group signature verifies against group key when formed from
* two valid partial signatures from members of the group.
* We model this with a specific equation for 2-of-3 threshold. */
group_verify(m,
combine_sigs(partial_sign(m, sk_i), partial_sign(m, sk_j)),
group_pk(partial_pk(sk_i), partial_pk(sk_j), pk_k)) = true,
group_verify(m,
combine_sigs(partial_sign(m, sk_i), partial_sign(m, sk_j)),
group_pk(partial_pk(sk_i), pk_k, partial_pk(sk_j))) = true,
group_verify(m,
combine_sigs(partial_sign(m, sk_i), partial_sign(m, sk_j)),
group_pk(pk_k, partial_pk(sk_i), partial_pk(sk_j))) = true
/*
* ==================================================================
* Restrictions
* ==================================================================
*/
/* Equality check helper */
restriction Eq:
"All x y #i. Eq(x, y) @ i ==> x = y"
/* Each nonce used at most once per chain */
restriction NonceUniqueness:
"All chain nonce #i #j.
NonceUsed(chain, nonce) @ i & NonceUsed(chain, nonce) @ j
==> #i = #j"
/* Signers are distinct in a signing session */
restriction DistinctSigners:
"All s1 s2 sid #i.
SigningPair(s1, s2, sid) @ i ==> not(s1 = s2)"
/*
* ==================================================================
* Key generation
* ==================================================================
*/
/* Generate MPC signer group (3 signers with threshold 2) */
rule Generate_MPC_Group:
let
pk_1 = partial_pk(~sk_1)
pk_2 = partial_pk(~sk_2)
pk_3 = partial_pk(~sk_3)
gpk = group_pk(pk_1, pk_2, pk_3)
in
[ Fr(~sk_1), Fr(~sk_2), Fr(~sk_3), Fr(~gid) ]
--[ CreateGroup(~gid, gpk)
, GroupMember($S1, ~gid)
, GroupMember($S2, ~gid)
, GroupMember($S3, ~gid)
]->
[ /* Signer key material (persistent) */
!SignerKey($S1, ~gid, ~sk_1)
, !SignerKey($S2, ~gid, ~sk_2)
, !SignerKey($S3, ~gid, ~sk_3)
/* Group public key (persistent, public) */
, !GroupPK(~gid, gpk)
/* Contract stores the group public key */
, !ContractGroupKey(~gid, gpk)
/* Public keys are known */
, Out(pk_1)
, Out(pk_2)
, Out(pk_3)
, Out(gpk)
]
/*
* ==================================================================
* Signer compromise (for security analysis)
* ==================================================================
*/
/* Compromise a single signer's key */
rule Compromise_Signer:
[ !SignerKey($S, gid, sk) ]
--[ CompromisedSigner($S, gid) ]->
[ Out(sk) ]
/*
* ==================================================================
* Bridge signing protocol
* ==================================================================
*
* Relayer -> Signer1: request partial sig for (action, chainId, token, amount, nonce)
* Relayer -> Signer2: request partial sig for (action, chainId, token, amount, nonce)
* Signer1 -> Relayer: partial_sig_1
* Signer2 -> Relayer: partial_sig_2
* Relayer -> Contract: (message, group_sig = combine(partial_1, partial_2))
* Contract: verify(message, group_sig, gpk) and check nonce freshness
*/
/* -- Signer produces a partial signature -- */
rule Signer_Partial_Sign:
let
msg = <action, chainId, token, amount, nonce>
psig = partial_sign(msg, sk)
in
[ !SignerKey($S, gid, sk)
, In(<'SignRequest', gid, action, chainId, token, amount, nonce>)
]
--[ SignerApproved($S, gid, msg)
]->
[ Out(<'PartialSig', $S, gid, psig>) ]
/* -- Relayer collects two partial signatures and combines -- */
rule Relayer_Combine:
let
msg = <action, chainId, token, amount, nonce>
gsig = combine_sigs(psig1, psig2)
in
[ In(<action, chainId, token, amount, nonce>)
, In(<'PartialSig', $S1, gid, psig1>)
, In(<'PartialSig', $S2, gid, psig2>)
, !GroupPK(gid, gpk)
, Fr(~rid)
]
--[ RelayerCombined(~rid, gid, msg, gsig)
, SigningPair($S1, $S2, ~rid)
, Eq(group_verify(msg, gsig, gpk), true)
]->
[ Out(<'BridgeTx', gid, msg, gsig>) ]
/* -- Contract verifies and processes -- */
rule Contract_Process:
let
msg = <action, chainId, token, amount, nonce>
in
[ In(<'BridgeTx', gid, msg, gsig>)
, !ContractGroupKey(gid, gpk)
]
--[ Eq(group_verify(msg, gsig, gpk), true)
, NonceUsed(chainId, nonce)
, BridgeAction(gid, action, chainId, token, amount, nonce)
, ProcessedWithSig(gid, msg, gsig)
]->
[ /* Action processed -- state change on chain */ ]
/*
* ==================================================================
* Signer rotation protocol
* ==================================================================
*
* Rotation requires t (=2) current signers to authorize the new group.
* Old group signs a rotation message, new group is established.
*/
rule Rotation_Authorize:
let
new_gpk = group_pk(partial_pk(~new_sk_1), partial_pk(~new_sk_2), partial_pk(~new_sk_3))
rot_msg = <'Rotate', old_gid, new_gpk>
psig1 = partial_sign(rot_msg, old_sk_1)
psig2 = partial_sign(rot_msg, old_sk_2)
gsig = combine_sigs(psig1, psig2)
in
[ !SignerKey($S1, old_gid, old_sk_1)
, !SignerKey($S2, old_gid, old_sk_2)
, !GroupPK(old_gid, old_gpk)
, Fr(~new_sk_1), Fr(~new_sk_2), Fr(~new_sk_3)
, Fr(~new_gid)
]
--[ Eq(group_verify(rot_msg, gsig, old_gpk), true)
, RotationAuthorized(old_gid, ~new_gid, new_gpk)
, RotationSignedBy($S1, $S2, old_gid)
]->
[ !SignerKey($NS1, ~new_gid, ~new_sk_1)
, !SignerKey($NS2, ~new_gid, ~new_sk_2)
, !SignerKey($NS3, ~new_gid, ~new_sk_3)
, !GroupPK(~new_gid, new_gpk)
, !ContractGroupKey(~new_gid, new_gpk)
, Out(new_gpk)
]
/*
* ==================================================================
* Key rotation overlap period
* ==================================================================
*
* During key rotation there is a transition window where both old
* and new group keys must be accepted. This models that overlap
* and ensures no message is double-processed during the transition.
*/
rule Key_Rotation_Overlap:
[ !ContractGroupKey(old_gid, old_gpk)
, !ContractGroupKey(new_gid, new_gpk)
, Fr(~tid)
, In(<'BeginTransition', old_gid, new_gid>)
]
--[ RotationOverlap(old_gpk, new_gpk, ~tid) ]->
[ !TransitionKey(old_gpk, new_gpk, ~tid) ]
rule Process_During_Transition:
let
msg = <action, chainId, token, amount, nonce>
in
[ !TransitionKey(old_gpk, new_gpk, tid)
, In(<'BridgeTx', gid, msg, gsig>)
]
--[ Eq(group_verify(msg, gsig, old_gpk), true)
, NonceUsed(chainId, nonce)
, ProcessedDuringTransition(tid, msg)
]->
[ /* Accepted under old key during transition */ ]
/*
* ==================================================================
* Message expiry verification (Class 5 replay defense)
* ==================================================================
*
* Bridge messages carry an expiry timestamp. The contract rejects
* messages whose expiry has passed. This prevents stale signed
* messages from being replayed after the intended validity window.
*/
rule Submit_With_Expiry:
let
msg = <action, chainId, token, amount, nonce, expiry>
in
[ In(<'BridgeTx', gid, msg, gsig>)
, !ContractGroupKey(gid, gpk)
, Fr(~current_time)
]
--[ Eq(group_verify(msg, gsig, gpk), true)
, NonceUsed(chainId, nonce)
, VerifiedExpiry(msg, expiry, ~current_time)
, BridgeAction(gid, action, chainId, token, amount, nonce)
]->
[ /* Processed with expiry check */ ]
/*
* ==================================================================
* Security lemmas
* ==================================================================
*/
/*
* -- Lemma 1: Executability --
* The protocol can complete (sanity check).
*/
lemma executability:
exists-trace
"Ex gid action chain token amount nonce #i.
BridgeAction(gid, action, chain, token, amount, nonce) @ i"
/*
* -- Lemma 2: Group key unforgeability --
* An adversary controlling fewer than t (=2) signers cannot produce
* a valid group signature. If a bridge action is processed, then
* either two honest signers approved it, or at least two signers
* in the group were compromised.
*
* Stated contrapositively: if at most one signer is compromised,
* every processed action was approved by at least one honest signer.
*/
lemma group_key_unforgeability:
"All gid msg gsig #i.
ProcessedWithSig(gid, msg, gsig) @ i
==> ( Ex S #j. SignerApproved(S, gid, msg) @ j
& not(Ex #c. CompromisedSigner(S, gid) @ c) )
| ( Ex S1 S2 #c1 #c2.
CompromisedSigner(S1, gid) @ c1
& CompromisedSigner(S2, gid) @ c2
& not(S1 = S2) )"
/*
* -- Lemma 3: Signer rotation safety --
* A rotation can only occur if authorized by t (=2) current signers.
* An adversary controlling fewer than t signers in the old group
* cannot force a rotation.
*/
lemma signer_rotation_safety:
"All old_gid new_gid new_gpk #i.
RotationAuthorized(old_gid, new_gid, new_gpk) @ i
==> ( Ex S1 S2 #j. RotationSignedBy(S1, S2, old_gid) @ j
& not(Ex #c. CompromisedSigner(S1, old_gid) @ c)
& not(Ex #c. CompromisedSigner(S2, old_gid) @ c) )
| ( Ex S1 S2 #c1 #c2.
CompromisedSigner(S1, old_gid) @ c1
& CompromisedSigner(S2, old_gid) @ c2
& not(S1 = S2) )"
/*
* -- Lemma 4: Cross-chain replay resistance --
* A signature that includes chainId is valid on exactly one chain.
* If a bridge action is processed on chain1, the same nonce+signature
* cannot be processed on chain2 (different chainId in the message).
*/
lemma cross_chain_replay_resistance:
"All gid action1 action2 chain1 chain2 token1 token2
amount1 amount2 nonce gsig #i #j.
( ProcessedWithSig(gid, <action1, chain1, token1, amount1, nonce>, gsig) @ i
& ProcessedWithSig(gid, <action2, chain2, token2, amount2, nonce>, gsig) @ j
)
==> ( chain1 = chain2 & action1 = action2
& token1 = token2 & amount1 = amount2 )"
/*
* -- Lemma 5: Nonce freshness --
* Each nonce is used exactly once per chain. No replay of the
* same nonce on the same chain.
*/
lemma nonce_freshness:
"All chain nonce #i #j.
NonceUsed(chain, nonce) @ i & NonceUsed(chain, nonce) @ j
==> #i = #j"
/*
* -- Lemma 6: No double-processing during key rotation --
* During the overlap transition period, a message is processed
* at most once. The nonce restriction still applies.
*/
lemma rotation_no_double_process:
"All tid msg #i #j.
ProcessedDuringTransition(tid, msg) @ i
& ProcessedDuringTransition(tid, msg) @ j
==> #i = #j"
/*
* -- Lemma 7: Stale message rejection --
* A message that has been verified with an expiry was checked
* against a current timestamp. Combined with the contract's
* require(block.timestamp <= expiry), expired messages cannot
* be processed.
*/
lemma expiry_verified:
"All msg expiry current_time #i.
VerifiedExpiry(msg, expiry, current_time) @ i
==> Ex gid action chain token amount nonce #j.
BridgeAction(gid, action, chain, token, amount, nonce) @ j"
end