Skip to content

feat: safe keycard regeneration SDK support (WCN-3922) - #10145

Draft
yashaanand wants to merge 1 commit into
masterfrom
yashaanand/wcn-3922-safe-keycard-methods
Draft

yashaanand wants to merge 1 commit into
masterfrom
yashaanand/wcn-3922-safe-keycard-methods

Conversation

@yashaanand

Copy link
Copy Markdown
Contributor

Summary

Client-side SDK support for Safe keycard regeneration (WCN-3922, M2 of the Safe Keycard Regeneration TDD). Four module areas:

  • sdk-lib-mpc — pure CBOR reduce helpers reduceDklsKeyShare / reduceMpsKeyShare extracted from the DKG classes, with byte-equality golden tests against Dkg.getReducedKeyShare for both curves.
  • sdk-lib-safes — parseSafeKeycardBox accepts any non-empty declared subset of root slots; unknown keys, non-string/entry values, and empty slot sets are rejected; canonical key order preserved. Ports validateJSONAgainstCodec from the unmerged WCN-2734 branch, adapted to master's {prv, chainCode} ed25519 entry union.
  • key-card — safe keycard generation accepts a slot subset (generateSafeQrData, buildSafeBoxData) and gains a blob-returning variant generateSafeKeycardBlob for mobile; the saving generateSafeKeycard is unchanged.
  • sdk-core — Safe.requestKeycardAccess / Safe.claimKeycardMaterial (WCN-3921 route contract, decoded minimally with passthrough records), buildSafeKeycardRoots (user envelopes re-encrypted under the creation password in one shared KDF session, multisig backup envelopes byte-identical, MPC roots reduced + re-wrapped into the ceremony-shaped reducedEncryptedPrv), decryptSafeKeycardBoxD, generateSafe takes passcodeEncryptionCode (stored as originalPasscodeEncryptionCode on the multisig and MPC user/backup roots) and returns the root triplets, and decryptSafeKeycard / deriveSafeWalletKeys tolerate slot subsets with named missing-root errors.

Verification

  • @bitgo/sdk-lib-mpc: 273 passing, incl. the two golden reduce assertions (DKLS + MPS byte equality).
  • @bitgo/sdk-lib-safes: 96 passing (subset codec + validateJSONAgainstCodec coverage).
  • @bitgo/key-card: 42 passing (2-slot subset round-trip, full-4 regression, empty-roots rejection, blob smoke test).
  • @bitgo/sdk-core safe + keychain suites: 196 passing (buildSafeKeycardRoots same-password/password-change/wrong-password/missing-envelope cases, 2-slot round-trip through parseSafeKeycardBox → decryptSafeKeycard → deriveSafeWalletKeys, Box D decrypt, claim mapping, generateSafe triplet return + passcodeEncryptionCode threading, rotation skip for envelope-less backup roots).
  • @bitgo/sdk-coin-algo safe recovery (consumer of the widened deriveSafeWalletKeys): 242 passing.
  • express generateSafe routes: green in full-suite runs (1987 passing); the only remaining failures are a pre-existing signPayload full-suite isolation flake (4/4 pass standalone) and an occasional supertest socket race, both unrelated to this diff.
  • Full monorepo TypeScript build (yarn at root): all packages compile clean.
  • Lint: 0 errors on all changed files (pre-commit lint-staged + direct eslint). yarn lint-changed additionally fails in @bitgo/passkey-crypto, which is byte-identical to master with 1798 pre-existing prettier errors — unrelated.
  • Ticket acceptance proofs observed in test output: golden reduce byte equality (DKLS + MPS), 2-slot subset round-trip, password-change keycard opened with the creation password, Box D decrypt returning the creation password, rotation test showing the backup root emits no bulk update.

Breaking changes

  • ISafes.generateSafe now returns SafeCreationResult ({ safe, rootKeychains }) and createSafeKeys returns SafeKeys & { rootKeychains } — the root triplets (incl. reducedEncryptedPrv) the keycard needs. This @experimental surface's only caller is retail's createSafeWithSdk, bumped in M3 (WCN-3923). Express handlers keep their documented wire contracts (safes.generate returns SafeData; keys/generate returns only root key ids — no key material over the wire).
  • decryptSafeKeycard returns Partial<DecryptedSafeRoots>; full records remain assignable, and deriveSafeWalletKeys throws a named error for absent slots.

Coordination & scope notes

  • WCN-2734: the subset codec commit 031757ec9d is unmerged on recovery/WCN-2734 and predates master's Ed25519ChainCodeEntry; this PR ports its approach adapted to current master (union entry codec kept). If WCN-2734 merges first, rebase and prefer this superset version.
  • To keep the PR under the requested ~1500-line budget, the full-4 round-trip integration test was folded out; all four slots are still covered by the per-slot buildSafeKeycardRoots unit tests and the existing recovery.ts suite (full-4 decrypt + per-slot derive).
  • The beta publish remains blocked by the sdk-coin-eth E403 republish failure (BitGoJS fix(scripts): reconcile publish conflicts and wait for visibility (SI-2040) #9997) — out of scope; release tooling owns publishing.
  • No client-side runtime flag: the methods are additive on an @experimental surface and the WP routes are gated server-side by wp_safe_keycard_access (WCN-3919/3921); subset/blob keycard generation is pure client-side formatting.

Ticket: WCN-3922

@linear-code

linear-code Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

WCN-3922

@github-actions

github-actions Bot commented Nov 9, 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

@yashaanand
yashaanand force-pushed the yashaanand/wcn-3922-safe-keycard-methods branch from e5bcfaa to 9b3dbe8 Compare November 9, 2026 19:58
@yashaanand

Copy link
Copy Markdown
Contributor Author

@claude review

@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.

Besides the inline findings, I checked whether the new rootKeychains (encrypted prvs and reducedEncryptedPrv) could leak through express. Neither handler sends them: handleV2GenerateSafe returns only safe.toJSON(), and handleV2GenerateSafeKeys returns only { rootKeys }.

Extended reasoning...

The PR adds safe keycard regeneration: it re-encrypts and reduces MPC key shares in sdk-core, widens the safe keycard codecs and generation to slot subsets, and changes the return type of generateSafe. The inline comments already cover the open findings, so this note only records an extra check on the express handlers in modules/express/src/clientRoutes.ts. Both handlers destructure or project the result, so no encrypted key material is added to the express responses.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 modules/sdk-lib-safes/src/codecs.ts — nit: the new exported helper validateJSONAgainstCodec uses any twice, which CLAUDE.md asks to avoid. Its signature uses A extends Record<string, any> and codec: t.Type<A, any, unknown>. Fix: use Record<string, unknown> and t.Type<A, unknown, unknown>, which is the form the sibling decodeWithCodec in the same file already uses.

    Why this was flagged

    CLAUDE.md line 88 says "Avoid any type... prefer unknown". The new exported validateJSONAgainstCodec in modules/sdk-lib-safes/src/codecs.ts declares A extends Record<string, any> and codec: t.Type<A, any, unknown>. It is part of the package's public API through modules/sdk-lib-safes/src/index.ts. decodeWithCodec, a few lines above in the same file, already types its codec as t.Type<A, unknown, unknown>. No runtime failure. This is a convention slip in new public code.

    Verification: nit. The diff adds two any uses to new exported code. In modules/sdk-lib-safes/src/codecs.ts, lines 28 and 30 (not line 1108), the helper is exported publicly through modules/sdk-lib-safes/src/index.ts. CLAUDE.md line 88 says to avoid the any type. The sibling decodeWithCodec at line 9 already uses t.Type<A, unknown, unknown>. Nothing fails at runtime.

Comment thread modules/key-card/src/generateQrData.ts Resolved
sdk-lib-mpc: pure CBOR reduce helpers (reduceDklsKeyShare, reduceMpsKeyShare) extracted from
the DKG classes, with byte-equality goldens against Dkg.getReducedKeyShare. sdk-lib-safes:
parseSafeKeycardBox accepts any non-empty declared subset of root slots (aligned with the
unmerged WCN-2734 branch, adapted for the ed25519 chain-code entry). key-card: safe keycard
generation accepts a slot subset and gains a blob-returning variant (generateSafeKeycardBlob)
for mobile. sdk-core: Safe.requestKeycardAccess / claimKeycardMaterial (WCN-3921 route
contract), buildSafeKeycardRoots (Box A re-encrypt under the creation password, MPC reduce +
re-envelope in one encryption session, backup envelopes byte-identical), Box D decrypt helper,
generateSafe takes passcodeEncryptionCode (stored as originalPasscodeEncryptionCode on the
multisig and MPC user/backup roots) and returns the root triplets incl. reducedEncryptedPrv,
decrypt/derive tolerate slot subsets. Express handlers keep their documented wire contracts
(root key ids only; no key material over the wire).

Experimental/beta surface; no runtime flag — the routes are gated server-side by
wp_safe_keycard_access.

Ticket: WCN-3922
@yashaanand
yashaanand force-pushed the yashaanand/wcn-3922-safe-keycard-methods branch from 9b3dbe8 to 72dba15 Compare November 9, 2026 21:50
@yashaanand

Copy link
Copy Markdown
Contributor Author

@claude review

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 modules/sdk-core/src/bitgo/safe/keycard.ts — Keycard regeneration fails with a false wrong-password error if WP returns reducedEncryptedPrv: null on an MPC backup. The code never falls back to the backup's full encryptedPrv. The guard backupReducedEncryptedPrv !== undefined at keycard.ts:513 lets null through, so verifyKeycardPassphrase(null) runs and throws IncorrectPasswordError. Fix: test for a usable value (typeof backupReducedEncryptedPrv === 'string' && backupReducedEncryptedPrv.length > 0), so null and empty values fall through to the encryptedPrv reduce branch.

    Why this was flagged

    The trigger is claimed material whose MPC backup record serializes the absent field as reducedEncryptedPrv: null. This code already expects WP to use null for missing fields: SafeKeycardMaterialCodec accepts passcodeEncryptionCode: t.union([t.string, t.null, t.undefined]). The passthrough ClaimedKeychainCodec (keycard.ts:327) lets the null through, and Safe.claimKeycardMaterial casts it to Keychain unchanged (safe.ts). In buildSafeKeycardRoots, the null passes the !== undefined check at keycard.ts:513, and bitgo.decrypt({ input: null, ... }) throws. The error is then wrapped as the keycard (creation) password does not open the safe ${slot} backup root. The user has typed the correct creation password, is told it is wrong, and cannot regenerate the keycard. The else if (backupEncryptedPrv) branch would have reduced the full backup envelope successfully. This is a new function, so the base branch has no regeneration path to compare against.

    Verification: Severity: normal. ClaimedKeychainCodec (keycard.ts:22) accepts any value for reducedEncryptedPrv, including null. keycard.ts:208 checks if (backupReducedEncryptedPrv !== undefined), which lets null through. keycard.ts:210 then calls verifyKeycardPassphrase(null, slot), which throws IncorrectPasswordError, so the fallback at keycard.ts:212 (else if (backupEncryptedPrv)) never runs.

This branch has not been deployed

No deployments
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.

1 participant