Skip to content

fix: validate BitGo GPG key and session state in ECDSA MPCv2 offline signing - #10155

Merged
Marzooqa merged 1 commit into
masterfrom
marzooqanaeemakather/wal-2457-high-bitgojs-dkls23-external
Nov 13, 2026
Merged

Marzooqa merged 1 commit into
masterfrom
marzooqanaeemakather/wal-2457-high-bitgojs-dkls23-external

Conversation

@Marzooqa

@Marzooqa Marzooqa commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Aligns the ECDSA MPCv2 (DKLS23) offline signing rounds (MPCv2Round2/MPCv2Round3) with the existing keygen helpers and with the EdDSA MPCv2 rounds hardened in #10127:

  • The BitGo public GPG key is validated against the known BitGo key table in the same environments that already enforce this for keygen.
  • Round session state is tracked so each encrypted session blob is processed at most once — an in-memory digest registry in sdk-core plus the express ConsumedShareRegistry via the same lazy markSessionConsumed hook the EdDSA rounds use — preventing nonce reuse from a replayed blob (CWE-323).
  • Session envelopes are minted with a creation stamp in the adata and validated (pre-decrypt) against the shared retention window, bounding the replay window identically to the EdDSA rounds.
  • The session digest, stamped-adata validator, and single-use registry are hoisted from the EdDSA MPCv2 module into a shared TSS module so both DSG protocols enforce the identical contract; the session max-age aliases OFFLINE_SIGNING_SHARE_MAX_AGE_MS from the GG18 offline-signing helpers so one retention window covers all protocols.

Linear: WAL-2457

Changes

  • EcdsaMPCv2Utils.createOfflineRound1Share/createOfflineRound2Share: mint session envelopes with a Date.now() adata stamp.
  • EcdsaMPCv2Utils.createOfflineRound2Share/createOfflineRound3Share: pin the BitGo GPG key (isBitgoMpcPubKey(..., 'mpcv2') gated by envRequiresBitgoPubGpgKeyConfig), reject an already-consumed session blob via the digest registry before processing, validate the stamped adata before decryption, and mark the blob consumed (durable hook first, then in-memory) immediately before the DSG nonce state is restored.
  • New shared module encryptedSessionSingleUse.ts (moved from the EdDSA MPCv2 module); eddsaMPCv2.ts and the sdk-core index re-point at it.
  • Express: the MPCv2Round2/MPCv2Round3 cases pass the shared markConsumedOfflineSessionClosure into the round helpers; the signer-mode startup warning now mentions both protocols.

Test Plan

  • sdk-core ECDSA MPCv2 unit suite — 23 passing (plus the Base TSS Utils suite — 37 passing — whose round-1 adata assertion now matches the stamped format):
    • replay rejection for encryptedRound1Session/encryptedRound2Session, including JSON-reformatted envelopes;
    • GPG key pin rejection under a pinned env;
    • expiry and future-stamp rejection of rewritten adata (the validator runs pre-decrypt, so a tampered stamp never reaches decryption);
    • cross-session mixing: a genuine round-1 blob restored and driven with a different session's re-wrapped BitGo messages is rejected by the DSG transcript check before the nonce advances;
    • fresh-blob adjustment to the pre-existing cross-round adata test.
  • sdk-core EdDSA MPCv2 unit suite — 125 passing, confirming the hoist is behavior-preserving.
  • Express externalSign suite — 22 passing, including route-level replay rejection in the ECDSA MPCv2 full flow.
  • eslint and prettier clean; full sdk-core tsc build clean.

@linear-code

linear-code Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

WAL-2457

@vibhavgo
vibhavgo force-pushed the vibhavgopalkrishna/wal-2455-critical-bitgojs-eddsa-mpcv2-offline-round-helpers-replay branch 2 times, most recently from 58ab47f to 3b54625 Compare November 11, 2026 11:13
@Marzooqa
Marzooqa force-pushed the marzooqanaeemakather/wal-2457-high-bitgojs-dkls23-external branch from c631edb to 0288a3d Compare November 11, 2026 14:20
@vibhavgo
vibhavgo force-pushed the vibhavgopalkrishna/wal-2455-critical-bitgojs-eddsa-mpcv2-offline-round-helpers-replay branch from 3b54625 to 90596a4 Compare November 12, 2026 07:50
@Marzooqa
Marzooqa force-pushed the marzooqanaeemakather/wal-2457-high-bitgojs-dkls23-external branch from 0288a3d to fb394c5 Compare November 12, 2026 10:38
@Marzooqa
Marzooqa changed the base branch from vibhavgopalkrishna/wal-2455-critical-bitgojs-eddsa-mpcv2-offline-round-helpers-replay to master November 12, 2026 16:29
@Marzooqa
Marzooqa force-pushed the marzooqanaeemakather/wal-2457-high-bitgojs-dkls23-external branch from fb394c5 to c12637c Compare November 12, 2026 16:49
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Compatibility checks are failing on Node.js 27-nightly (experimental, non-blocking). This does not block merge, but it signals an incompatibility to fix before this release becomes supported.

View run

…signing

The DKLS23 offline round helpers accepted the BitGo public GPG key from the
request and processed the same encrypted session blob repeatedly, so a
malicious online relay could replay round-2 or round-3 state, obtain two
partial signatures under one nonce, and recover the signer's key share
(CWE-323).

Round 2 and round 3 now validate the BitGo public GPG key against the known
BitGo key table in the same environments that already enforce this for
keygen, and each encrypted session blob is consumed exactly once: rejected
early through an in-memory digest registry in sdk-core and durably through
the express consumed-shares registry via a lazy markSessionConsumed hook,
mirroring the EdDSA MPCv2 rounds. Session envelopes are minted with a
creation stamp in the adata and validated against the registry retention
window, bounding the replay window the same way. The session digest,
stamped-adata validation, and registry are hoisted from the EdDSA MPCv2
module into a shared TSS module so both DSG protocols enforce an identical
single-use contract.

References: WAL-2457
@Marzooqa
Marzooqa force-pushed the marzooqanaeemakather/wal-2457-high-bitgojs-dkls23-external branch from c12637c to 7495f3b Compare November 12, 2026 18:07
@Marzooqa
Marzooqa marked this pull request as ready for review November 12, 2026 18:58
@Marzooqa
Marzooqa requested review from a team as code owners November 12, 2026 18:58

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@lokesh-bitgo

Copy link
Copy Markdown
Contributor

@claude Please do a thorough review of this PR.

Focus especially on:

  • any bugs or incorrect logic in the changes
  • whether these changes could impact existing flows or cause regressions
  • whether the implementation is aligned with the requirement/design
  • whether the changes are correct and complete
  • missing edge cases, validation gaps, and test coverage gaps

Please call out concrete issues clearly and separate them by severity if possible.

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @lokesh-bitgo's task in 39s —— View job


Review of PR #10155 (ECDSA MPCv2 offline signing: GPG pin and single-use sessions)

I read the full diff, the new shared module and the express wiring. I did not run the test suites. The PR description reports them passing, and I haven't re-verified that.

Verdict: The core design is sound and consistent with the EdDSA MPCv2 hardening. I found no blocking bugs. There are a few compatibility and robustness points worth deciding on before merge.

What looks correct

  • Ordering of the replay guard. The cheap digest check runs first. Then GPG pinning, adata validation (before decrypt), and decrypt. markSessionConsumed (durable) runs first, then the in-memory mark, and setSession (nonce restore) comes last. If the durable registry rejects the blob, the in-memory entry is never set. Transient failures such as a wrong passphrase or a bad peer share stay retryable.
  • Atomic check-then-set. markEncryptedSessionConsumed re-checks and sets synchronously, so concurrent replays in one process cannot both reach setSession.
  • Digest. It is canonical-base64 only, hashes the decoded iv and ct, and truncates the CCM iv to the effective nonce length. JSON reformatting therefore cannot evade it.
  • Tampered stamp. Running the stamp validator before decrypt is safe. The adata is authenticated by the cipher, so a rewritten stamp fails at decrypt anyway. The pre-check just gives a clear error.
  • Hoist. The move of the code into encryptedSessionSingleUse.ts is a straight move. The express route is renamed consistently for both protocols.

Medium

  1. In-flight session break on upgrade.

    • validateDsgSessionAdata requires STATE:<digits>:<adata>. The old validateAdata also accepted a bare adata and STATE:<adata>.
    • A round-1 or round-2 blob minted by a previous SDK or OVC version has no stamp. If it is fed to round 2 or round 3 of the new code, it fails with "Adata does not match cyphertext adata".
    • This is probably acceptable for a security fix. It also affects mixed-version setups, for example an older online or OVC client producing round 1 and a newer express signer consuming it. Please confirm this is intended and mention it in the release notes or changelog.
  2. Durable replay protection exists only on the express path.

    • Direct SDK callers get only the in-memory registry. It is process-local and lost on restart, and the new markSessionConsumed hook is optional.
    • Within the 30-day window, a restarted process or a second signer instance accepts a replayed blob.
    • If that is by design, document it on the JSDoc of createOfflineRound2Share and createOfflineRound3Share. If it is not, consider making the hook mandatory outside express, or at least logging when it is absent.
  3. A failure after the consume point burns the session. The blob is marked consumed before handleIncomingMessages and the later steps. If those throw (malformed BitGo message, DSG transcript mismatch), the blob is consumed and the user must restart signing from round 1. That is fail-closed and probably right for nonce safety. It is worth a test and a comment saying it is intentional. The cross-session mixing test touches this, but a note on user-visible behaviour would help.

Low

  1. decodeURIComponent can throw URIError on a malformed adata in validateDsgSessionAdata (encryptedSessionSingleUse.ts). That gives an unhelpful error rather than "Adata does not match". Wrap it in try/catch, as is done for JSON.parse.

  2. Possible circular import. encryptedSessionSingleUse.ts imports OFFLINE_SIGNING_SHARE_MAX_AGE_MS from ./ecdsa/ecdsa, and eddsaMPCv2.ts and the sdk-core index now depend on it. The constant is a runtime value used at module load, so a cycle could produce undefined and a NaN comparison that disables expiry. Pure expiry checks with NaN evaluate to false, so this would fail open. Safer to move the constant into this module, or a small constants file, and re-export it from ecdsa.ts. This only matters if an import cycle exists. The build is reported clean, so it may be fine today.

  3. Two separate 30-day constants. CONSUMED_OFFLINE_SIGNING_SESSION_MAX_AGE_MS is hard-coded to 30 days instead of aliasing DSG_SESSION_MAX_AGE_MS. The PR's stated goal is a single retention window. Derive one from the other so they cannot drift. The in-memory prune must never be shorter than the accept window.

  4. Stamp semantics. The stamp is local Date.now() with a 60s skew allowance. Round 1 → 2 → 3 each mint a fresh stamp, so the effective total window is up to about 2 × 30 days. Acceptable, but the PR text says "bounding the replay window identically", so it is worth stating.

Test coverage gaps

  • There is no test for a pre-upgrade unstamped blob. That would pin the behaviour in point 1.
  • There is no test that the markSessionConsumed hook throwing leaves the in-memory registry unset. This is the ordering guarantee in the comments.
  • There is no test that a failed round (post-consume) cannot be retried (point 3).
  • There is no test for a malformed adata URI escape (point 4).
  • Round 1 has no GPG-key parameter, so no pin applies there. That looks correct.

@vibhavgo vibhavgo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@Marzooqa
Marzooqa merged commit 182d04d into master Nov 13, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants