fix(multisig-client): block-independent metadata↔tx_summary binding - #410
Draft
WiktorStarczewski wants to merge 6 commits into
Draft
Conversation
Replace the block-height-dependent re-execution in verifyProposalMetadataBinding with a deterministic per-type decode-and-compare against the signed TransactionSummary (assertMetadataMatchesSummary): - p2id: rebuild the output-note recipient from metadata + the signed salt; require the summary's single output note to carry that recipient digest and a fungible asset of exactly the declared faucet + amount (read off the note, not the vault). - consume_notes: input-note id set equality. - add/remove/change_signer: exact [threshold, count] value slot, and no non-target signer commitment in the public-keys map delta. - update_procedure_threshold: procedure root -> [threshold,0,0,0] map entry. - switch_guardian / custom: exempt (bound elsewhere / opaque). None of these read the vault, so the fee (a block-dependent vault delta) never contaminates the comparison — fixing the cross-block liveness bug while keeping the what-you-see-is-what-you-sign guarantee. Requires @miden-sdk/miden-sdk 0.15.10 (AccountStorageDelta.valueDeltas()/maps()); dep bumped to ^0.15.10.
The output-note asset check used find(), which passed as long as the declared asset was present — an attacker could attach extra assets to the note, draining more than the cosigner approved. Require exactly one fungible asset matching the declared faucet+amount.
…nsions The per-type binding was a field allowlist that ignored whole summary dimensions, enabling piggyback fund-theft (e.g. a consume_notes summary that also emits a drain output note, or a p2id that also rewrites the signer set). Now each type asserts the transaction touches ONLY the declared dimensions + the fee: output notes exactly the declared set, input notes exactly the declared set, and storage-slot deltas confined to the allowed slots (with index-bound signer checks). switch_guardian is now bound (guardian pubkey + no other effects) instead of exempt. Residual (documented): SDK exposes only fungible note/vault assets (NFT piggyback on a p2id note is invisible); signer check binds changed indices (a real-summary integration test must confirm delta completeness).
…erge closure Address final review: bind the guardian selector value in switch_guardian (reject a summary that disables the guardian) rather than relying on the MASM re-enable invariant. Reword the p2id non-fungible-asset residual: it is a fund-theft gap closable via output-note-id equality (to be done + integration-tested before this leaves draft), not an unclosable limit.
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replaces the block-height-dependent metadata re-execution in
verifyProposalMetadataBindingwith a deterministic, exhaustive metadata↔tx_summarybinding. Fixes the cross-block liveness bug (a second signer couldn't load/sign a pending proposal) while preserving the documented what-you-see-is-what-you-sign guarantee (docs/MULTISIG_SDK.md,docs/CONCEPTS.md: "a compromised Guardian cannot steal funds").Why
verifyProposalMetadataBindingrebuilt each proposal from metadata and re-executed it (executeForSummary) to compare the resultingTransactionSummarycommitment to the signed one. That re-execution runs against the client's current sync height as the reference block, and the summary commits over the transaction fee, derived from that block'sfee_parameters. So the check only matched on the same client at the same height as the proposer — a second signer at a later block hitmetadata does not match tx_summaryand the account load aborted.Simply deleting the check would fix liveness but drop a documented security control: a compromised/MITM Guardian could serve a cosigner an evil
tx_summaryunder benignmetadata, and the cosigner — reviewing metadata but signing the opaque summary commitment — could be tricked into signing a drain. So the check is made deterministic instead.How
The new
assertMetadataMatchesSummary(src/multisig/summaryBinding.ts) decodes the signed summary and, per type, asserts the transaction's effects are exactly the declared effect + the fee, across every intent-bearing dimension — its output notes, its input notes, and the account-storage slots it changes. It never reads the vault delta (the fee is a block-dependent native-asset vault delta); that's safe because assets can only leave via an output note or the fee, so binding the notes + storage slots exactly makes the vault a consequence. The comparison is therefore deterministic across blocks yet still exact — a mislabel, or a piggybacked undeclared effect in any dimension, is rejected.Per type: p2id — exactly one output note (recipient rebuilt from metadata + the summary's salt; exactly the declared fungible asset), no input notes, no storage change but the per-tx replay marker. consume_notes — input-note-id set equality, no output notes. signer — no notes;
threshold_config = [threshold, count, 0, 0]; thesigner_public_keysmap delta index-bound to the declared set (catches a duplicated/omitted/injected signer). update_procedure_threshold — no notes;procedure_thresholds[PROC_ROOT] = [threshold, 0, 0, 0]. switch_guardian — now bound (was exempt): guardian pubkey map =newGuardianPubkey, storage confined to the guardian slots (+ signer slots if it also rotates signers). custom — exempt (no metadata recipe; WYSIWYS explicitly does not hold — cosigners must verify the rawtx_summary).Reading the config-type map deltas needs
AccountStorageDelta.maps()/valueDeltas(), added in@miden-sdk/miden-sdk@0.15.10(0xMiden/web-sdk#298); this PR bumps the dep to^0.15.10.Tests
35 binding unit tests including a rejecting test for each fund-theft bypass an earlier field-allowlist draft had (consume+drain-note, p2id piggybacked storage write / signer-map write / extra asset, signer duplicate-with-omit, unexpected slot, proc/guardian + drain note, wrong guardian key). Full package suite green (458),
tsc --noEmitclean.Known gaps
NoteAssetsexposes only fungible assets — so an NFT attached to an otherwise-correct p2id note is not bound and could be drained under a "send N tokens" label. Closure: bind the output note by fulloutputNote.id()equality (aNoteIdcommits to all assets incl. NFTs; theconsumepath already binds by note id andp2id.tshas the note-ID reconstruction), sourcing the fungible asset off the note to preserve block-independence. This also binds the note type as a side effect. It has callback-flag/metadata caveats that the pre-merge integration test must validate — so it's tracked below, not shipped in this draft.customproposals get no WYSIWYS — cosigners must verify the rawtx_summary(unavoidable; no metadata recipe).@miden-sdk/miden-sdk@0.15.10published (web-sdk#298 merged + released).outputNote.id()equality (see above).TransactionSummary, so the encodings are cross-verified by reading the builders/MASM/inspector but not against a genuine executed summary. Add a test that runs a realexecuteForSummary → serialize → deserialize → assertMetadataMatchesSummaryper type and confirms: (a) private p2id passes (its own-output note must exposeassets(), else it would false-reject); (b) thethreshold_config/ procedure-root / guardian-pubkey word encodings match a real delta; (c) the signer pubkey-map delta carries every changed index; (d) the NFT-gap closure doesn't false-reject an honest p2id (callback-flag / note-metadata handling).Follow-ups (separate)
prepareCustomExecution(custom execute path) has the same block-dependence; give it the equivalent treatment.crates/miden-multisig-client) still re-executes on list/sign — same bug, wants the equivalent block-independent binding.Closes #409