fix: verify ECDSA signatures from a local buffer before publishing to caller output - #129
fix: verify ECDSA signatures from a local buffer before publishing to caller output#129SashaMIT wants to merge 2 commits into
Conversation
… caller output ecdsa_2p::sign_batch_impl wrote each DER signature straight into the caller's output vector (sigs[i] = sig.to_der()) and only then ran the self-verification. On a verification failure (e.g. a malicious P2 crafting a bad ciphertext mid-batch) the function returns an error but the caller's vector is left holding a mix of valid signatures and one unverified one; the single-sign sign() wrapper avoids this only because it batches into a local vector and copies on success. ecdsa_mp::sign had the same write-then-verify ordering into its output parameter. schnorr_2p already does this correctly: it builds candidate_sigs, verifies each one, and commits to the caller's vector with a single std::move after all checks pass. Both ECDSA paths now mirror that candidate-then-commit pattern: DER is serialized to a local buffer, verified, and only then moved into the caller's output. No protocol-behavior change on the success path. Signed-off-by: SashaMIT <sash@ela.city> Co-authored-by: Cursor <cursoragent@cursor.com>
🟡 Heimdall Review Status
|
Rob1Ham
left a comment
There was a problem hiding this comment.
Thanks for this fix — verifying from a local buffer before publishing into
caller state is the right direction, and the success path is indeed unchanged.
Two line comments inline on the commit mechanics; three general notes below.
1. Contract documentation. #126 added a @notes line to schnorr_2p.h /
schnorr_mp.h ("sigs is cleared before execution and populated only if
every signature verifies successfully."). Suggest the same note on the batch
entry points in include-internal/cbmpc/internal/protocol/ecdsa_2p.h so
callers know what state to expect on error.
2. Regression test. A test that fills sigs with a successful batch
call, then triggers a failure (e.g. a tampered round-2 or round-4 message)
and asserts sigs is empty would pin the contract. With the current patch
that test still fails (see the inline note on clear-on-entry); with the full
#126 shape it passes.
3. Minor narrative nit (reachability framing). The PR description says a
bad ciphertext mid-batch triggers the verification failure at index i. In
default mode the round-4 zk_ecdsa proof is verified at
src/cbmpc/protocol/ecdsa_2p.cpp:380 (inside the !global_abort_mode guard
opened at :372), before signature assembly, so a tampered ciphertext fails
there first; the signature-verify failure at the patched line is
realistically reachable via the internal global-abort mode
(sign_with_global_abort{,_batch}). This doesn't affect the validity of the
fix — it just calibrates the reachability story for the changelog.
None of this blocks the direction — happy to re-review an updated diff, or
these could be tracked as a follow-up if you'd rather land the
unverified-write fix on its own.
This feedback was drafted with AI assistance (Kimi k3); all claims verified
against the public repository.
| else | ||
| return coinbase::error(rv, "signature verification failed"); | ||
| } | ||
| sigs[i] = std::move(der); |
There was a problem hiding this comment.
Two related observations on the publish-into-sigs mechanics:
sigs is still never cleared on entry, so the stale-buffer path remains.
sign_batch_impl still does sigs.resize(n_sigs) at entry (:249).
resize is a no-op on a recycled vector of the same size, so any failure
before or during this loop — e.g. the round-2/round-3 proof checks at
:301/:321, or the round-4 zk_ecdsa verify at :380 — returns with
whatever the caller's buffer already contained. If a caller recycles a buffer
across batches, those stale entries can be valid signatures from a previous
call (over different messages), which would pass a downstream signature
check — arguably a sneakier failure mode than the unverified-bytes case this
PR fixes. After this patch the failure index itself also moves into the
stale/empty category: on a verify failure at index i, sigs[i] no longer
holds the unverified DER (good), but it holds whatever the recycled buffer
had there, and sigs[i+1..] likewise. #126 handled this with sigs.clear()
at entry.
Incremental commit vs. atomic commit. Moving each verified DER into
sigs[i] inside the loop means a mid-batch failure still leaves verified
partial output (sigs[0..i-1]) in caller-visible state on error return.
#126's stated contract was stronger: "On any failure sigs is left cleared
instead of holding partial or unverified signatures" — it builds a local
candidate_sigs and commits with a single sigs = std::move(candidate_sigs)
after all checks pass. Mirroring that here is only a few lines:
sigs.clear(); // at entry, instead of sigs.resize(n_sigs)
...
std::vector<buf_t> candidate_sigs(n_sigs);
// inside the P1 output loop:
candidate_sigs[i] = sig.to_der();
crypto::ecc_pub_key_t ecc_verification_key(key.Q);
if (rv = ecc_verification_key.verify(msgs[i], candidate_sigs[i])) {
// existing global_abort_mode / default error returns
}
...
sigs = std::move(candidate_sigs); // once, after the loop| crypto::ecc_pub_key_t pub(key.Q); | ||
| if (rv = pub.verify(msg, sig)) return rv; | ||
| if (rv = pub.verify(msg, der)) return rv; | ||
| sig = std::move(der); |
There was a problem hiding this comment.
The local-buffer fix is correct for the unverified write. The same
clear-on-entry consideration applies to the single sig output on failure —
a recycled caller buffer keeps its stale bytes — and a matching header note
on sign would document the contract.
Address review feedback: clear caller-visible sig/sigs on entry, verify into a local candidate buffer, and move into the output only after the full batch succeeds (matching schnorr_2p). Document the contract on the batch/sign entry points.
|
Thanks @Rob1Ham. Applied the clear-on-entry + candidate-then-commit shape for I have not added the recycled-buffer regression test yet (needs the existing harness wiring). Happy to add that next if you want it in this PR rather than a follow-up. |
|
Thank you for bringing this issue to our attention and taking the time to submit this PR. We agree with the issue is worth hardening! To address the underlying issue more thoroughly across the project, we've put together a broader fix in PR #131. In line with our contributing guidelines, we wanted to keep you in the loop regarding how we're handling this fix. Thanks again for your contribution and for helping improve the project! |
|
Thanks @hsiuhsiu. Glad this is worth hardening project-wide. #131 looks like the right landing path (clear-on-entry + candidate-then-commit across the sign surfaces, plus the regression coverage). Happy for that to supersede this PR. If you close #129 in favor of #131, a short thanks note or credit line pointing at this report would be appreciated. Either way, thanks for keeping me in the loop. |
|
I'm closing this PR as we have a generalization in #131. Let's continue the discussion there if anything pops up. Thanks! |
Summary
In plain terms: the batch ECDSA signing function handed signatures to the caller before checking them itself. If the check then failed, the caller's buffer was left holding a mix of good signatures and one unchecked one — even though the function reported an error.
Concretely,
ecdsa_2p::sign_batch_impldid:A malicious counterparty (P2) that crafts a bad ciphertext mid-batch triggers the verification failure at index
i— at which point the caller'ssigsvector holds valid signatures at0..i-1plus one unverified signature ati. The API returns an error, but any caller that (incorrectly but plausibly) inspects partial results on failure consumes an unverified signature.ecdsa_mp::signhad the same write-then-verify ordering into its output parameter.The library already does this correctly elsewhere:
schnorr_2pbuildscandidate_sigs, verifies each one, and commits to the caller's vector with a singlestd::moveafter all checks pass; the single-signecdsa_2p::sign()wrapper is safe for the same reason (it batches into a local and copies out only on success).Fix
Both ECDSA paths now mirror the candidate-then-commit pattern: serialize DER to a local buffer, verify, then move into the caller's output. No behavior change on the success path.
Test plan
Made with Cursor
Made with Cursor