mirror of
https://github.com/luxfi/proofs.git
synced 2026-10-30 15:21:05 +00:00
- 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]>
404 lines
13 KiB
Plaintext
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
|