From 6825576b6a9d71c87d8a8854f18774c1c713903c Mon Sep 17 00:00:00 2001 From: yashaanand Date: Mon, 9 Nov 2026 14:22:14 -0500 Subject: [PATCH] feat: safe keycard regeneration SDK support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk-lib-mpc: pure CBOR reduce helpers (reduceDklsKeyShare, reduceMpsKeyShare) extracted from the DKG classes, pinned by golden fixtures whose reduced bytes were produced by master's pre-extraction 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, sharing one draw path with generateSafeKeycard. 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). End-to-end key-card tests drive the real Safes.generateSafe and Safe.claimKeycardMaterial against WP mocks in the WCN-3920 (redacted backup on key add) and WCN-3921 (claim projection) wire shapes, then generate, parse, decrypt and derive all 4 roots and a 2-slot subset with real encryption: the creation-time keycard from the returned triplets, and a post-password-change regeneration that takes the creation password from Box D. These supersede the overlapping sdk-core unit round-trip. Experimental/beta surface; no runtime flag — the routes are gated server-side by wp_safe_keycard_access. Ticket: WCN-3922 --- modules/express/src/clientRoutes.ts | 7 +- .../test/unit/typedRoutes/generateSafe.ts | 12 +- modules/key-card/package.json | 2 + modules/key-card/src/generateQrData.ts | 34 +- modules/key-card/src/index.ts | 31 +- modules/key-card/src/types.ts | 5 +- .../key-card/test/unit/safeKeycardRegen.ts | 337 +++++++++++ modules/key-card/test/unit/safeQrData.ts | 55 +- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 3 + modules/sdk-core/src/bitgo/safe/iSafe.ts | 16 + modules/sdk-core/src/bitgo/safe/iSafes.ts | 18 +- modules/sdk-core/src/bitgo/safe/index.ts | 1 + modules/sdk-core/src/bitgo/safe/keycard.ts | 298 ++++++++++ modules/sdk-core/src/bitgo/safe/recovery.ts | 82 ++- modules/sdk-core/src/bitgo/safe/safe.ts | 69 ++- modules/sdk-core/src/bitgo/safe/safes.ts | 94 +++- .../test/unit/bitgo/keychain/keychains.ts | 29 + .../sdk-core/test/unit/bitgo/safe/keycard.ts | 532 ++++++++++++++++++ .../sdk-core/test/unit/bitgo/safe/recovery.ts | 30 +- .../sdk-core/test/unit/bitgo/safe/safes.ts | 113 +++- modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts | 13 +- .../sdk-lib-mpc/src/tss/ecdsa-dkls/types.ts | 22 +- modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts | 10 +- .../sdk-lib-mpc/src/tss/eddsa-mps/types.ts | 20 +- .../test/unit/tss/ecdsa/reduceKeyShare.ts | 21 + .../test/unit/tss/eddsa/reduceKeyShare.ts | 25 + modules/sdk-lib-safes/src/codecs.ts | 48 ++ modules/sdk-lib-safes/src/index.ts | 1 + modules/sdk-lib-safes/src/keycardCodec.ts | 52 +- modules/sdk-lib-safes/test/unit/codecs.ts | 50 ++ .../sdk-lib-safes/test/unit/keycardCodec.ts | 41 +- 31 files changed, 1897 insertions(+), 174 deletions(-) create mode 100644 modules/key-card/test/unit/safeKeycardRegen.ts create mode 100644 modules/sdk-core/src/bitgo/safe/keycard.ts create mode 100644 modules/sdk-core/test/unit/bitgo/safe/keycard.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/ecdsa/reduceKeyShare.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/eddsa/reduceKeyShare.ts create mode 100644 modules/sdk-lib-safes/test/unit/codecs.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 8f8c6b87918..83065c242f0 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -722,7 +722,7 @@ export async function handleV2GenerateSafe( req: ExpressApiRouteRequest<'express.v2.safes.generate', 'post'> ): Promise { const safes = new Safes(req.bitgo, req.decoded.enterpriseId); - const safe = await safes.generateSafe({ + const { safe } = await safes.generateSafe({ label: req.decoded.label, passphrase: req.decoded.passphrase, }); @@ -751,12 +751,15 @@ export async function handleV2GenerateSafeWallet( */ export async function handleV2GenerateSafeKeys(req: ExpressApiRouteRequest<'express.v2.safes.keys.generate', 'post'>) { const safes = new Safes(req.bitgo, req.decoded.enterpriseId); - return safes.createSafeKeys({ + const created = await safes.createSafeKeys({ label: '', passphrase: req.decoded.passphrase, safeId: req.decoded.safeId, enabledRootSlots: req.decoded.enabledRootSlots, }); + // The root triplets are client-side keycard material (encrypted private keys); the express + // wire contract stays the documented one — only the root key ids. + return { rootKeys: created.rootKeys }; } /** diff --git a/modules/express/test/unit/typedRoutes/generateSafe.ts b/modules/express/test/unit/typedRoutes/generateSafe.ts index e852ef0b339..be0d89d2ebb 100644 --- a/modules/express/test/unit/typedRoutes/generateSafe.ts +++ b/modules/express/test/unit/typedRoutes/generateSafe.ts @@ -48,11 +48,12 @@ describe('Generate Safe Typed Routes Tests', function () { const label = 'Test Safe'; const passphrase = 'mySecurePassphrase123'; const safeJson = mockSafeJson(label); - const generateSafeStub = sinon.stub(Safes.prototype, 'generateSafe').resolves( - stubSafe({ + const generateSafeStub = sinon.stub(Safes.prototype, 'generateSafe').resolves({ + safe: stubSafe({ toJSON: () => safeJson, - }) - ); + }), + rootKeychains: {}, + }); const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({ label, @@ -222,6 +223,7 @@ describe('Generate Safe Typed Routes Tests', function () { secp256k1Multisig: ['user-1', 'backup-1', 'bitgo-1'] as [string, string, string], }, }, + rootKeychains: {}, }; it('should run phase-2 ceremonies and return root key ids', async function () { @@ -235,6 +237,8 @@ describe('Generate Safe Typed Routes Tests', function () { res.status.should.equal(200); res.body.should.have.property('rootKeys'); + // The express contract returns only the ids; the root triplets stay client-side. + res.body.should.not.have.property('rootKeychains'); res.body.rootKeys.hot.secp256k1Multisig.should.deepEqual(['user-1', 'backup-1', 'bitgo-1']); createSafeKeysStub.should.have.been.calledOnce(); createSafeKeysStub.firstCall.args[0].should.have.property('passphrase', passphrase); diff --git a/modules/key-card/package.json b/modules/key-card/package.json index 4f88062b579..aa6e7dd7962 100644 --- a/modules/key-card/package.json +++ b/modules/key-card/package.json @@ -42,6 +42,8 @@ "qrcode": "^1.5.1" }, "devDependencies": { + "@bitgo/sdk-lib-mpc": "^11.6.0", + "@bitgo/utxo-lib": "^11.25.0", "@types/qrcode": "1.5.0" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", diff --git a/modules/key-card/src/generateQrData.ts b/modules/key-card/src/generateQrData.ts index f27a0571cea..46f196f391d 100644 --- a/modules/key-card/src/generateQrData.ts +++ b/modules/key-card/src/generateQrData.ts @@ -247,20 +247,24 @@ function selectRootPublicKey(keychain: Keychain, slot: SafeRootKeyType): string } /** - * Serializes one box's four roots into a single JSON object keyed by rootKeyType, e.g. - * `{"secp256k1Multisig":"","ecdsaMpc":"",...}`. This keeps the safe - * keycard on the existing 4-box layout — one box (= one QR/data block) per role — with the - * four roots packed into each box's data. `splitKeys` fragments the box if it exceeds the QR - * threshold, exactly as for a normal wallet keycard. Reversed by {@link parseSafeKeycardBox}. + * Serializes one box's roots into a single JSON object keyed by rootKeyType, e.g. + * `{"secp256k1Multisig":"","ecdsaMpc":"",...}`. Only the root slots the + * safe actually carries are packed, in canonical order, so partial-slot safes render a + * keycard too. This keeps the safe keycard on the existing 4-box layout — one box (= one + * QR/data block) per role — with the roots packed into each box's data. `splitKeys` fragments + * the box if it exceeds the QR threshold, exactly as for a normal wallet keycard. Reversed + * by {@link parseSafeKeycardBox}. */ function buildSafeBoxData( - roots: Record, + roots: Partial>, select: (root: KeychainsTriplet, slot: SafeRootKeyType) => SafeKeycardRootEntry ): string { const box: SafeKeycardRoots = {} as SafeKeycardRoots; for (const slot of SAFE_ROOT_ORDER) { const root = roots[slot]; - assert.ok(root, `Safe is missing the ${slot} root`); + if (root === undefined) { + continue; + } box[slot] = select(root, slot); } return JSON.stringify(box); @@ -268,25 +272,31 @@ function buildSafeBoxData( /** * Builds safe keycard QR data in the existing wallet {@link QrData} shape: boxes A (user), - * B (backup), C (BitGo), D (passcode). Each of A/B/C carries a JSON object of the four roots, - * so the safe renders and parses through the same {@link drawKeycard} path as a wallet. + * B (backup), C (BitGo), D (passcode). Each of A/B/C carries a JSON object of the safe's + * root slots, so the safe renders and parses through the same {@link drawKeycard} path as + * a wallet. Requires at least one root triplet. */ export async function generateSafeQrData(params: GenerateSafeQrDataParams): Promise { const { roots } = params; + // Count present slots, not keys: `{ ecdsaMpc: undefined }` would otherwise print a keycard + // whose A/B/C boxes are all `{}`, which parseSafeKeycardBox later rejects. + if (SAFE_ROOT_ORDER.every((slot) => roots[slot] === undefined)) { + throw new Error('safe keycard requires at least one root triplet'); + } const qrData: QrData = { user: { title: 'A: User Key', - description: 'Your 4 root private keys, encrypted with your safe password.', + description: 'Your root private keys, encrypted with your safe password.', data: buildSafeBoxData(roots, (root, slot) => selectRootPrivateKey(root.userKeychain, slot, 'user')), }, backup: { title: 'B: Backup Key', - description: 'Your 4 root backup keys, encrypted with your safe password.', + description: 'Your root backup keys, encrypted with your safe password.', data: buildSafeBoxData(roots, (root, slot) => selectBackupRootEntry(root.backupKeychain, slot)), }, bitgo: { title: 'C: BitGo Key', - description: 'The public parts of the 4 root keys held by BitGo.', + description: 'The public parts of the root keys held by BitGo.', data: buildSafeBoxData(roots, (root, slot) => selectRootPublicKey(root.bitgoKeychain, slot)), }, }; diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index f8a47e7a2e7..a6d1702d983 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -51,22 +51,37 @@ export async function generateLightningKeycard( keycard.save(`BitGo Keycard for ${label}.pdf`); } -/** - * Generates a safe keycard using the existing 4-box layout: boxes A/B/C each carry a JSON - * object of the four roots (see {@link generateSafeQrData}), rendered through the same - * {@link drawKeycard} path as a wallet. Generated as part of safe creation, once all four - * root triplets exist. - */ -export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise { +/** Draws the safe keycard PDF; shared by the saving and blob-returning entry points. */ +async function drawSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams) { const questions = generateFaq(params.coin.fullName); const qrData = await generateSafeQrData(params); - const keycard = await drawKeycard({ + return drawKeycard({ ...params, questions, qrData, pageBreakBeforeIndices: [1, 2], useQrPartHeaders: true, }); +} + +/** + * Generates a safe keycard using the existing 4-box layout: boxes A/B/C each carry a JSON + * object of the safe's root slots (see {@link generateSafeQrData}), rendered through the same + * {@link drawKeycard} path as a wallet. Generated as part of safe creation, once the safe's + * root triplets exist. + */ +export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise { + const keycard = await drawSafeKeycard(params); const label = params.walletLabel || params.coin.fullName; keycard.save(`BitGo Keycard for ${label}.pdf`); } + +/** + * Same as {@link generateSafeKeycard} but returns the PDF as a {@link Blob} instead of saving + * it to disk, for callers (e.g. mobile) that stream the bytes themselves. + */ +export async function generateSafeKeycardBlob( + params: GenerateQrDataBaseParams & GenerateSafeQrDataParams +): Promise { + return (await drawSafeKeycard(params)).output('blob'); +} diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 6f57737f039..54ce8c57d24 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -78,8 +78,9 @@ export const SAFE_ROOT_ORDER = Object.keys(slotsInRenderOrder) as SafeRootKeyTyp export type KeycardEntity = 'wallet' | 'safe'; export interface GenerateSafeQrDataParams extends GenerateQrDataCoinParams { - // The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType. - roots: Record; + // The safe's root triplets (user/backup/bitgo keychains), keyed by rootKeyType. Any non-empty + // subset of the four slots is valid; the keycard carries exactly the slots present here. + roots: Partial>; } export type GenerateKeycardParams = GenerateQrDataBaseParams & diff --git a/modules/key-card/test/unit/safeKeycardRegen.ts b/modules/key-card/test/unit/safeKeycardRegen.ts new file mode 100644 index 00000000000..21cfa03e56e --- /dev/null +++ b/modules/key-card/test/unit/safeKeycardRegen.ts @@ -0,0 +1,337 @@ +/** + * End-to-end Safe keycard tests (WCN-3922): real Safes.generateSafe / Safe.claimKeycardMaterial → + * buildSafeKeycardRoots → generateSafeQrData → parseSafeKeycardBox → decryptSafeKeycard → + * deriveSafeWalletKeys, with real encryption and key material. WP is mocked with the wire shapes it + * serves: backup envelopes redacted on key add (WCN-3920) and the keycard claim projection (WCN-3921). + */ +import 'should'; +import * as assert from 'assert'; +import * as crypto from 'crypto'; +import { createEncryptionSession, decrypt, encrypt } from '@bitgo/sdk-api'; +import { coins } from '@bitgo/statics'; +import { bip32 } from '@bitgo/utxo-lib'; +import { DklsTypes, DklsUtils, DklsVrfUtils, MPSUtil, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import * as safesLib from '@bitgo/sdk-lib-safes'; +import { + BitGoBase, + buildSafeKeycardRoots, + buildSafeMpcKeyEnvelopes, + decryptSafeKeycard, + decryptSafeKeycardBoxD, + deriveSafeWalletKeys, + Safe, + SafeKeychainRoots, + Safes, +} from '@bitgo/sdk-core'; +import { generateSafeQrData } from '../../src/generateQrData'; +import { QrData, SAFE_ROOT_ORDER, SafeRootKeyType as Slot } from '../../src/types'; +import { splitKeys } from '../../src/utils'; +import { QRBinaryMaxLength } from '../../src/drawKeycard'; + +const CREATION_PW = 'safe-creation-password'; +const CHANGED_PW = 'safe-password-after-change'; +const CODE = 'Ab3dE'; +const ALL: Slot[] = ['secp256k1Multisig', 'ed25519Multisig', 'ecdsaMpc', 'eddsaMpc']; +// Per slot (in ALL order): the coin derived on recovery, and the testnet root coin generateSafe uses. +const COIN = ['btc', 'algo', 'eth', 'sol']; +const ROOT_COIN: Record = { tbtc: ALL[0], txlm: ALL[1], hteth: ALL[2], tsol: ALL[3] }; + +const cryptoBitGo = { + encrypt: async ({ input, password }: { input: string; password: string }) => encrypt(password, input), + decrypt: async ({ input, password }: { input: string; password: string }) => decrypt(password, input), + createEncryptionSession: async (password: string) => createEncryptionSession(password), +} as unknown as BitGoBase; +const b64 = (buf: Uint8Array): string => Buffer.from(buf).toString('base64'); +const reassemble = (data: string): string => splitKeys(data, QRBinaryMaxLength).join(''); + +type MpcParty = { envelope: Buffer; reducedEnvelope: Buffer; reduced: Buffer; vrf: Buffer }; +type Fixture = + | { + mpc: false; + user: string; + userPub: string; + backup: string; + backupPub: string; + bitgoPub: string; + decryptedBackup: unknown; + } + | { mpc: true; commonKeychain: string; user: MpcParty; backup: MpcParty }; + +/** Real root material for all four slots, as the creation ceremonies hold it. */ +async function buildFixtures(): Promise> { + const xprv = () => bip32.fromSeed(crypto.randomBytes(32)); + const ed = () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + return { + seed: privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32), + pub: publicKey.export({ format: 'der', type: 'spki' }).subarray(-32), + }; + }; + const [secpUser, secpBackup] = [xprv(), xprv()]; + const [edUser, edBackup, edBitgo] = [ed(), ed(), ed()]; + const chainCode = crypto.randomBytes(32); + const party = (full: Buffer, reduced: Buffer, vrf: Buffer): MpcParty => ({ + ...buildSafeMpcKeyEnvelopes(full, reduced, vrf), + reduced, + vrf, + }); + const [dkls, dklsVrf, mps, mpsVrf] = await Promise.all([ + DklsUtils.generateDKGKeyShares(), + DklsVrfUtils.generateVrfDKGKeyShares(), + MPSUtil.generateEdDsaDKGKeyShares(), + MpsVrfUtils.generateVrfDKGKeyShares(), + ]); + const edBackupSeed = safesLib.encodeEd25519StrKeySecretSeed(edBackup.seed); + return { + secp256k1Multisig: { + mpc: false, + user: secpUser.toBase58(), + userPub: secpUser.neutered().toBase58(), + backup: secpBackup.toBase58(), + backupPub: secpBackup.neutered().toBase58(), + bitgoPub: xprv().neutered().toBase58(), + decryptedBackup: secpBackup.toBase58(), + }, + ed25519Multisig: { + mpc: false, + user: safesLib.encodeEd25519StrKeySecretSeed(edUser.seed), + userPub: safesLib.encodeEd25519StrKeyPublicKey(edUser.pub), + backup: edBackupSeed, + backupPub: safesLib.encodeDerivableEd25519Pub(edBackup.pub, chainCode), + bitgoPub: safesLib.encodeDerivableEd25519Pub(edBitgo.pub, crypto.randomBytes(32)), + decryptedBackup: { prv: edBackupSeed, chainCode: safesLib.encodeEd25519ChainCode(chainCode) }, + }, + ecdsaMpc: { + mpc: true, + commonKeychain: DklsTypes.getCommonKeychain(dkls[0].getKeyShare()), + user: party(dkls[0].getKeyShare(), dkls[0].getReducedKeyShare(), dklsVrf[0].getKeyShare()), + backup: party(dkls[1].getKeyShare(), dkls[1].getReducedKeyShare(), dklsVrf[1].getKeyShare()), + }, + eddsaMpc: { + mpc: true, + commonKeychain: mps[0].getCommonKeychain(), + user: party(mps[0].getKeyShare(), mps[0].getReducedKeyShare(), mpsVrf[0].getKeyShare()), + backup: party(mps[1].getKeyShare(), mps[1].getReducedKeyShare(), mpsVrf[1].getKeyShare()), + }, + }; +} + +const safeDataWire = { + id: 'safe-id', + enterpriseId: 'enterprise-id', + label: 'Keycard Safe', + status: 'active', + creator: 'creator-id', + users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], + createdAt: '2026-11-11T00:00:00.000Z', +}; + +/** A key as WP serializes it: id, type, source, MPC commonKeychain, plus `extra`. */ +const keyJson = (slot: Slot, fx: Fixture, role: string, extra: Record) => ({ + id: `${slot}-${role}`, + type: fx.mpc ? 'tss' : 'independent', + source: role, + ...(fx.mpc ? { commonKeychain: fx.commonKeychain } : {}), + ...extra, +}); + +/** A BitGo mock answering POSTs from `routes`, keyed by URL suffix. */ +function httpBitGo(routes: Record unknown>, extra: Record = {}): BitGoBase { + const post = (url: string) => ({ + send: () => ({ + result: async () => { + const suffix = Object.keys(routes).find((s) => url.endsWith(s)); + assert.ok(suffix, `unexpected POST ${url}`); + return routes[suffix](); + }, + }), + }); + return { url: (path: string) => path, post, ...extra } as unknown as BitGoBase; +} + +/** + * Runs the real Safes.generateSafe. Key adds echo the user envelope but never the backup's + * (WCN-3920): multisig backups carry the envelope createBackup merges back locally, MPC backups only + * the ceremony's local reducedEncryptedPrv. + */ +async function generateSafe(f: Record) { + const calls: { originalPasscodeEncryptionCode?: string }[] = []; + const keychains = (slot: Slot) => { + const fx = f[slot]; + const key = (role: string, extra: Record) => keyJson(slot, fx, role, extra); + if (fx.mpc) { + return { + createMpc: async (p: { passphrase: string; originalPasscodeEncryptionCode?: string }) => { + calls.push(p); + const enc = (buf: Buffer) => encrypt(p.passphrase, b64(buf)); + return { + userKeychain: key('user', { + encryptedPrv: await enc(fx.user.envelope), + reducedEncryptedPrv: await enc(fx.user.reducedEnvelope), + }), + backupKeychain: key('backup', { reducedEncryptedPrv: await enc(fx.backup.reducedEnvelope) }), + bitgoKeychain: key('bitgo', {}), + }; + }, + }; + } + return { + create: () => ({ pub: fx.userPub, prv: fx.user }), + add: async (p: { pub: string; encryptedPrv: string; originalPasscodeEncryptionCode?: string }) => { + calls.push(p); + return key('user', p); + }, + createBackup: async (p: { passphrase: string; originalPasscodeEncryptionCode?: string }) => { + calls.push(p); + return key('backup', { + pub: fx.backupPub, + prv: fx.backup, + encryptedPrv: await encrypt(p.passphrase, fx.backup), + }); + }, + createBitGo: async () => key('bitgo', { pub: fx.bitgoPub }), + }; + }; + const bitgo = httpBitGo( + { '/safes': () => ({ id: 'safe-id', status: 'initializing' }), '/finalize': () => safeDataWire }, + { + getEnv: () => 'test', + encrypt: cryptoBitGo.encrypt, + coin: (name: string) => ({ keychains: () => keychains(ROOT_COIN[name]) }), + } + ); + const result = await new Safes(bitgo, 'enterprise-id').generateSafe({ + label: 'Keycard Safe', + passphrase: CREATION_PW, + passcodeEncryptionCode: CODE, + }); + return { result, calls }; +} + +/** + * The WP claim body (`serializeSafeKeycard`) after a password change: user envelopes rotated to + * CHANGED_PW, backups still on the creation password, each root projected to the claim fields. + */ +async function claimWire(f: Record, slots: Slot[]) { + const roots: Record = {}; + for (const slot of slots) { + const fx = f[slot]; + const key = (role: string, extra: Record) => keyJson(slot, fx, role, extra); + const [user, backup] = fx.mpc ? [b64(fx.user.envelope), b64(fx.backup.envelope)] : [fx.user, fx.backup]; + roots[slot] = { + user: key('user', { encryptedPrv: await encrypt(CHANGED_PW, user) }), + backup: key('backup', { + encryptedPrv: await encrypt(CREATION_PW, backup), + ...(fx.mpc ? {} : { pub: fx.backupPub }), + }), + bitgo: key('bitgo', fx.mpc ? {} : { pub: fx.bitgoPub }), + }; + } + return { roots, passcodeEncryptionCode: CODE }; +} + +/** + * Reads a keycard back as recovery does: boxes declare `slots` in canonical order, decrypt under + * `password` to the original material (MPC: the ceremony's reduced + VRF shares), every present + * slot derives, absent slots fail by name, and Box D opens to `password`. + */ +async function assertRecovers(qr: QrData, f: Record, slots: Slot[], password: string) { + assert.ok(qr.backup && qr.bitgo && qr.passcode, 'keycard must carry boxes A-D'); + const [userKeyBox, backupKeyBox, bitgoKeyBox] = [qr.user, qr.backup, qr.bitgo].map((b) => reassemble(b.data)); + for (const box of [userKeyBox, backupKeyBox, bitgoKeyBox]) { + Object.keys(safesLib.parseSafeKeycardBox(box)).should.deepEqual(SAFE_ROOT_ORDER.filter((s) => slots.includes(s))); + } + const roots = await decryptSafeKeycard({ bitgo: cryptoBitGo, userKeyBox, backupKeyBox, bitgoKeyBox, password }); + for (const slot of ALL) { + const fx = f[slot]; + const root = roots[slot]; + const derive = deriveSafeWalletKeys({ coin: COIN[ALL.indexOf(slot)], slot, account: 0, roots }); + if (!slots.includes(slot)) { + assert.strictEqual(root, undefined); + await derive.should.be.rejectedWith(new RegExp(`missing the '${slot}' root`)); + continue; + } + assert.ok(root); + if (fx.mpc) { + const mpcRoot = root as { user: { signing: Buffer; vrf: Buffer }; backup: typeof mpcRoot.user; bitgo: string }; + for (const role of ['user', 'backup'] as const) { + assert.ok(mpcRoot[role].signing.equals(fx[role].reduced), `${slot} ${role} reduced share`); + assert.ok(mpcRoot[role].vrf.equals(fx[role].vrf), `${slot} ${role} VRF share`); + } + mpcRoot.bitgo.should.equal(fx.commonKeychain); + } else { + root.should.deepEqual({ user: fx.user, backup: fx.decryptedBackup, bitgo: fx.bitgoPub }); + } + (await derive).slot.should.equal(slot); + } + ( + await decryptSafeKeycardBoxD({ bitgo: cryptoBitGo, boxD: qr.passcode.data, passcodeEncryptionCode: CODE }) + ).should.equal(password); +} + +const keycard = (roots: SafeKeychainRoots, passphrase: string) => + generateSafeQrData({ coin: coins.get('btc'), roots, passphrase, passcodeEncryptionCode: CODE }); + +describe('safe keycard end to end (generateSafe / claim → keycard → recovery)', function (this: Mocha.Suite) { + // Real DKLS / MPS ceremonies, MPC derivation and several KDF runs. + this.timeout(300000); + + let f: Record; + let creationQr: QrData; + + before(async function () { + f = await buildFixtures(); + }); + + it('builds the creation-time keycard from the generateSafe triplets: all 4 roots + Box D', async function () { + const { result, calls } = await generateSafe(f); + const { rootKeychains } = result; + Object.keys(rootKeychains).should.deepEqual(ALL); + // add/createBackup per multisig root + createMpc per MPC root all carry the passcode code. + calls.map((c) => c.originalPasscodeEncryptionCode).should.deepEqual(Array(6).fill(CODE)); + for (const slot of ALL) { + assert.strictEqual(rootKeychains[slot]!.backupKeychain.prv, undefined, `${slot}: no plaintext backup prv`); + } + creationQr = await keycard(rootKeychains, CREATION_PW); + await assertRecovers(creationQr, f, ALL, CREATION_PW); + }); + + for (const slots of [ALL, ['secp256k1Multisig', 'ecdsaMpc'] as Slot[]]) { + it(`regenerates after a password change via the Box D path: ${slots.join(', ')}`, async function () { + assert.ok(creationQr?.passcode, 'needs the creation-time keycard'); + const wire = await claimWire(f, slots); + const material = await new Safe( + httpBitGo({ '/keycard': () => wire }), + safeDataWire as never + ).claimKeycardMaterial(); + Object.keys(material.roots).should.deepEqual(slots); + + // "Use Box D from my keycard": the creation password comes from the original Box D + claimed code. + const keycardPassphrase = await decryptSafeKeycardBoxD({ + bitgo: cryptoBitGo, + boxD: creationQr.passcode.data, + passcodeEncryptionCode: material.passcodeEncryptionCode!, + }); + keycardPassphrase.should.equal(CREATION_PW); + + const roots = await buildSafeKeycardRoots({ + bitgo: cryptoBitGo, + material, + currentPassphrase: CHANGED_PW, + keycardPassphrase, + }); + for (const slot of slots.filter((s) => s.endsWith('Multisig'))) { + roots[slot]!.backupKeychain.encryptedPrv!.should.equal(material.roots[slot]!.backupKeychain.encryptedPrv); + } + const qr = await keycard(roots, keycardPassphrase); + await assertRecovers(qr, f, slots, CREATION_PW); + await decryptSafeKeycard({ + bitgo: cryptoBitGo, + userKeyBox: reassemble(qr.user.data), + backupKeyBox: reassemble(qr.backup!.data), + bitgoKeyBox: reassemble(qr.bitgo!.data), + password: CHANGED_PW, + }).should.be.rejected(); + }); + } +}); diff --git a/modules/key-card/test/unit/safeQrData.ts b/modules/key-card/test/unit/safeQrData.ts index 7c83ec6ecbb..43aad4267b4 100644 --- a/modules/key-card/test/unit/safeQrData.ts +++ b/modules/key-card/test/unit/safeQrData.ts @@ -12,6 +12,7 @@ import { parseMpcV2KeyShareEnvelope, } from '@bitgo/sdk-core'; import { generateSafeQrData } from '../../src/generateQrData'; +import { generateSafeKeycardBlob } from '../../src'; import { splitKeys } from '../../src/utils'; import { QRBinaryMaxLength } from '../../src/drawKeycard'; import { parseKeycardFromLines, parseSafeKeycardBox } from '../../src/parseKeycard'; @@ -223,10 +224,52 @@ describe('generateSafeQrData', function () { await assert.rejects(() => generateSafeQrData({ coin: coins.get('btc'), roots }), /missing encrypted private key/); }); - it('throws a clear error when an entire root triplet is missing', async function () { + it('round-trips a 2-slot subset: only the declared slots appear in every box', async function () { + const { roots, plaintexts, bitgoPubs } = await buildRoots(); + const subset: Partial> = { + secp256k1Multisig: roots.secp256k1Multisig, + ecdsaMpc: roots.ecdsaMpc, + }; + const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots: subset }); + assert.ok(qrData.backup && qrData.bitgo); + + const expectedKeys = ['secp256k1Multisig', 'ecdsaMpc']; + for (const box of [qrData.user, qrData.backup, qrData.bitgo]) { + assert.ok(box); + Object.keys(parseSafeKeycardBox(box.data)).should.deepEqual(expectedKeys); + } + + const userBox = parseSafeKeycardBox(qrData.user.data); + const backupBox = parseSafeKeycardBox(qrData.backup.data); + const bitgoBox = parseSafeKeycardBox(qrData.bitgo.data); + for (const slot of expectedKeys) { + (await decrypt(passphrase, userBox[slot] as string)).should.equal(plaintexts[slot].user); + (await decrypt(passphrase, backupBox[slot] as string)).should.equal(plaintexts[slot].backup); + bitgoBox[slot].should.equal(bitgoPubs[slot]); + } + }); + + it('throws when roots is empty', async function () { + await assert.rejects(() => generateSafeQrData({ coin: coins.get('btc'), roots: {} }), /at least one root triplet/); + // An explicitly undefined slot is not a present slot. + await assert.rejects( + () => generateSafeQrData({ coin: coins.get('btc'), roots: { ecdsaMpc: undefined } }), + /at least one root triplet/ + ); + }); + + it('generateSafeKeycardBlob returns a PDF blob for a slot subset, with Box D', async function () { const { roots } = await buildRoots(); - delete (roots as Partial>).ecdsaMpc; - await assert.rejects(() => generateSafeQrData({ coin: coins.get('btc'), roots }), /missing the ecdsaMpc root/); + const params = { + coin: coins.get('btc'), + walletLabel: 'Safe', + roots: { secp256k1Multisig: roots.secp256k1Multisig }, + }; + const blob = await generateSafeKeycardBlob(params); + assert.ok(blob instanceof Blob && blob.size > 0, 'expected a non-empty PDF blob'); + // passphrase + passcodeEncryptionCode add Box D (an extra QR block) to the same PDF. + const withBoxD = await generateSafeKeycardBlob({ ...params, passphrase, passcodeEncryptionCode: '12345' }); + assert.ok(withBoxD.size > blob.size, 'expected Box D to add a QR block'); }); it('recovers all 4 roots through the existing PDF-line parser + JSON.parse', async function () { @@ -262,9 +305,11 @@ describe('generateSafeQrData', function () { it('parseSafeKeycardBox rejects malformed / incomplete box data', function () { assert.throws(() => parseSafeKeycardBox('not json'), /parseSafeKeycardBox/); // invalid JSON assert.throws(() => parseSafeKeycardBox('"a string"'), /parseSafeKeycardBox/); // not an object - assert.throws(() => parseSafeKeycardBox('{"secp256k1Multisig":"x"}'), /parseSafeKeycardBox/); // missing roots + assert.throws(() => parseSafeKeycardBox('{}'), /empty slot set/); // declares no roots + assert.throws(() => parseSafeKeycardBox('{"secp256k1Multisig":"a","unknownRoot":"b"}'), /unknownRoot/); + parseSafeKeycardBox('{"secp256k1Multisig":"x"}').should.deepEqual({ secp256k1Multisig: 'x' }); // a subset now parses // A well-formed box with all four roots decodes successfully. const ok = parseSafeKeycardBox('{"secp256k1Multisig":"a","ecdsaMpc":"b","eddsaMpc":"c","ed25519Multisig":"d"}'); - ok.ecdsaMpc.should.equal('b'); + ok.ecdsaMpc!.should.equal('b'); }); }); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 5c8f26491a9..b8b8b8b1ca0 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -298,6 +298,9 @@ export interface CreateBackupOptions { encryptedPrv?: string; passphrase?: string; encryptionVersion?: EncryptionVersion; + /** Code the backup root's encryptedPrv is additionally wrapped under server-side, so a + * regenerated keycard can re-derive it (WCN-3922). Forwarded by `add` on the wire. @experimental */ + originalPasscodeEncryptionCode?: string; /** Public id of the safe this backup key is a root of (Wallet Safes v1). @experimental */ safeId?: string; } diff --git a/modules/sdk-core/src/bitgo/safe/iSafe.ts b/modules/sdk-core/src/bitgo/safe/iSafe.ts index 57598454d17..dac99ddddf1 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafe.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafe.ts @@ -19,6 +19,7 @@ import type { import type { EncryptionVersion } from '../../api/types'; import type { Wallet, WalletShare } from '../wallet'; import type { WebAuthnProvider } from '../webauthn'; +import type { RequestKeycardAccessResult, SafeKeycardMaterial } from './keycard'; /** @experimental */ export interface InitializeSafeOptions { @@ -139,6 +140,21 @@ export interface ISafe { acceptShare(params: AcceptSafeShareOptions): Promise; registerPasskey(params: RegisterSafePasskeyOptions): Promise; removePasskey(params: RemoveSafePasskeyOptions): Promise; + /** + * Request keycard regeneration access for this safe (WCN-3921 route contract). Returns the + * granted access window, the pending approval the request created when another enterprise + * admin must approve, or the id of the approval already pending from an earlier request. + * Gated server-side by the `wp_safe_keycard_access` flag. + * @experimental + */ + requestKeycardAccess(): Promise; + /** + * Claim the safe's keycard material (root triplets + optional passcode encryption code) + * once keycard access has been granted. Wire shapes are decoded minimally; the exact PA + * serialization is owned by the WP routes. + * @experimental + */ + claimKeycardMaterial(): Promise; freeze(params?: FreezeSafeBody): Promise; archive(): Promise; toJSON(): SafeData; diff --git a/modules/sdk-core/src/bitgo/safe/iSafes.ts b/modules/sdk-core/src/bitgo/safe/iSafes.ts index 7e308994ae8..e5b006f7e13 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafes.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafes.ts @@ -6,6 +6,7 @@ */ import type { InitializeSafeResponse } from '@bitgo/public-types'; import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; +import type { SafeKeychainRoots } from './keycard'; import { Safe } from './safe'; /** @@ -20,6 +21,8 @@ import { Safe } from './safe'; export interface CreateSafeOptions { label: string; passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies + /** Generated by the caller and stored on the roots as `originalPasscodeEncryptionCode`; absent ⇒ no Box D. @experimental */ + passcodeEncryptionCode?: string; } /** @@ -35,11 +38,18 @@ export interface SafeCreationHandle { } /** - * The minted root key ids produced by `createSafeKeys`, as ordered [user, backup, bitgo] - * triplets for the enabled slots — exactly the payload `finalizeSafe` consumes. + * The minted root keys produced by `createSafeKeys`: the ordered [user, backup, bitgo] id + * triplets `finalizeSafe` consumes, plus the full keychain triplets (including each root's + * `encryptedPrv` / `reducedEncryptedPrv`) the caller needs to generate the safe keycard. * @experimental */ -export type SafeKeys = FinalizeSafeOptions; +export type SafeKeys = FinalizeSafeOptions & { rootKeychains: SafeKeychainRoots }; + +/** Result of the one-call `generateSafe`: the finalized safe plus its root keychain triplets. */ +export interface SafeCreationResult { + safe: Safe; + rootKeychains: SafeKeychainRoots; +} /** * @experimental @@ -66,7 +76,7 @@ export interface ISafes { * HOT custody only in v1. * @experimental */ - generateSafe(params: CreateSafeOptions): Promise; + generateSafe(params: CreateSafeOptions): Promise; /** * Phase 1 — initialize a safe (metadata only, no key material). The server response is * `{ id, status }` plus optional `enabledRootSlots` (no `label`/`enterpriseId`/`creator`/`users`/ diff --git a/modules/sdk-core/src/bitgo/safe/index.ts b/modules/sdk-core/src/bitgo/safe/index.ts index 9f4c66b366e..a446c6b2c17 100644 --- a/modules/sdk-core/src/bitgo/safe/index.ts +++ b/modules/sdk-core/src/bitgo/safe/index.ts @@ -1,6 +1,7 @@ export * from './derivableEd25519Pub'; export * from './iSafe'; export * from './iSafes'; +export * from './keycard'; export * from './recovery'; export * from './safe'; export * from './safes'; diff --git a/modules/sdk-core/src/bitgo/safe/keycard.ts b/modules/sdk-core/src/bitgo/safe/keycard.ts new file mode 100644 index 00000000000..5de2ecd7d84 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/keycard.ts @@ -0,0 +1,298 @@ +/** + * @prettier + * + * @experimental Safe keycard regeneration (WCN-3922): the keycard access/claim wire codecs, + * the Box D (passcode) decryption helper, and `buildSafeKeycardRoots`, which assembles the + * per-slot keychain triplets a regenerated keycard needs — re-encrypting user material under + * the keycard (creation) password and reducing MPC signing shares to their keycard subset. + */ +import * as t from 'io-ts'; +import type { RootKeyType } from '@bitgo/public-types'; +import { DklsTypes, MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { BitGoBase } from '../bitgoBase'; +import { IncorrectPasswordError } from '../errors'; +import type { IEncryptionSession } from '../../api'; +import type { KeychainsTriplet } from '../baseCoin'; +import type { Keychain } from '../keychain'; +import { buildSafeMpcKeyEnvelopes, parseSafeMpcKeyEnvelopes } from '../utils/tss/keyShareEnvelope'; +import { SAFE_ROOT_SLOTS } from './rootCoin'; + +// The WP serialization is owned by WCN-3921, so claimed keychains decode minimally: a keychain +// is anything with an `id`, plus a passthrough record that preserves every other wire field. +const ClaimedKeychainCodec = t.intersection([t.type({ id: t.string }), t.record(t.string, t.unknown)]); +const ClaimedTripletCodec = t.type({ + user: ClaimedKeychainCodec, + backup: ClaimedKeychainCodec, + bitgo: ClaimedKeychainCodec, +}); + +// t.record over a t.keyof domain would require every slot; safe keycard material declares a +// subset, so the keys decode as strings and claimKeycardMaterial rejects unknown slots itself. +export const SafeKeycardMaterialCodec = t.type({ + roots: t.record(t.string, ClaimedTripletCodec), + // Safes created before passcode codes existed have none; WP may serialize that as null. + passcodeEncryptionCode: t.union([t.string, t.null, t.undefined]), +}); + +export const SafeKeycardAccessCodec = t.intersection([t.type({ expiresAt: t.string }), t.record(t.string, t.unknown)]); +export const SafeKeycardPendingApprovalCodec = t.intersection([ + t.type({ id: t.string, state: t.string }), + t.record(t.string, t.unknown), +]); +export const RequestKeycardAccessResponseCodec = t.union([ + t.type({ keycardAccess: SafeKeycardAccessCodec }), + t.type({ pendingApproval: SafeKeycardPendingApprovalCodec }), +]); + +// 409 body when a request is already pending: the pending approval's id (WCN-3921, tags-flow shape). +export const RequestKeycardAccessConflictCodec = t.type({ pendingApproval: t.string }); + +export type SafeKeycardAccess = { expiresAt: string; [field: string]: unknown }; +export type SafeKeycardPendingApproval = { id: string; state: string; [field: string]: unknown }; + +/** + * Result of `Safe.requestKeycardAccess`: the active grant (200), the pending approval this request + * created (202), or the id of the approval already pending from an earlier request (409). + */ +export type RequestKeycardAccessResult = + | { keycardAccess: SafeKeycardAccess } + | { pendingApproval: SafeKeycardPendingApproval } + | { pendingApprovalId: string }; + +/** The safe's root triplets, keyed by rootKeyType. Any non-empty subset of the four slots. */ +export type SafeKeychainRoots = Partial>; + +/** Material claimed from POST /safes/:safeId/keycard (WCN-3921 route contract). */ +export type SafeKeycardMaterial = { roots: SafeKeychainRoots; passcodeEncryptionCode?: string }; + +/** + * Thrown by {@link buildSafeKeycardRoots} when a backup root opens with the current password but + * not with the given creation password. This happens for safes whose password was changed before + * WCN-3920 write-protected backup envelopes, because that rotation re-encrypted the backups too. + * For these safes the current password is the keycard password: rebuild with + * `keycardPassphrase = currentPassphrase`, and write Box D with that same password. It extends + * `IncorrectPasswordError`, so existing wrong-password handling still catches it. + * @experimental + */ +export class SafeKeycardBackupsOnCurrentPasswordError extends IncorrectPasswordError { + public readonly slot: RootKeyType; + + public constructor(slot: RootKeyType, cause?: unknown) { + super( + `the safe ${slot} backup root is encrypted under the current password, not the creation password ` + + '(a password change rotated it before backups were write-protected); ' + + 'rebuild the keycard with the current password as keycardPassphrase', + cause + ); + this.name = 'SafeKeycardBackupsOnCurrentPasswordError'; + this.slot = slot; + } +} +/** + * Decrypts Box D of a safe keycard — the safe password encrypted under the passcode + * encryption code — back into the creation password. Handles v1 and v2 envelopes. + */ +export async function decryptSafeKeycardBoxD(params: { + bitgo: BitGoBase; + boxD: string; + passcodeEncryptionCode: string; +}): Promise { + try { + return await params.bitgo.decrypt({ input: params.boxD, password: params.passcodeEncryptionCode }); + } catch (e) { + throw new IncorrectPasswordError( + 'unable to decrypt the keycard Box D with the provided passcode encryption code', + e + ); + } +} + +/** + * Assembles the per-slot keychain triplets a regenerated safe keycard carries, from claimed + * material (or the triplets returned by `generateSafe`). Multisig user envelopes are re-encrypted + * under the keycard password; multisig backup envelopes are copied byte-identical. MPC roots are + * decrypted, reduced to the keycard subset (`reduceDklsKeyShare` / `reduceMpsKeyShare`), re-wrapped + * into the versioned signing+VRF reduced envelope and encrypted as `reducedEncryptedPrv` — the + * same form the creation ceremony stores. An MPC backup that already carries `reducedEncryptedPrv` + * (the `generateSafe` shape) is copied as-is instead. Every copied backup is checked against + * `keycardPassphrase` first, so a wrong creation password throws `IncorrectPasswordError` rather + * than producing a mixed-password keycard. A backup that opens only with `currentPassphrase` + * (rotated by a password change before backups were write-protected) throws + * {@link SafeKeycardBackupsOnCurrentPasswordError}: rebuild with the current password instead. One encryption session (one KDF run) is shared across + * every re-encryption and destroyed afterwards; `bitgoKeychain` passes through untouched. + */ +export async function buildSafeKeycardRoots(params: { + bitgo: BitGoBase; + material: SafeKeycardMaterial; + /** Opens user envelopes; same as keycardPassphrase unless the safe password changed. */ + currentPassphrase: string; + /** The creation password: opens backup envelopes; every output is (re-)encrypted under it. */ + keycardPassphrase: string; +}): Promise { + const { bitgo, material, currentPassphrase, keycardPassphrase } = params; + const slots = SAFE_ROOT_SLOTS.filter((slot) => material.roots[slot] !== undefined); + if (slots.length === 0) { + throw new Error('safe keycard material carries no root slots'); + } + + let session: IEncryptionSession | undefined; + try { + const encryptWithKeycardPassword = async (plaintext: string): Promise => { + // One session (one KDF run) is shared across the whole fan-out; created lazily so a + // material set with nothing to re-encrypt pays no KDF. + session ??= await bitgo.createEncryptionSession(keycardPassphrase); + return session.encrypt(plaintext); + }; + + // Classifies a backup envelope that keycardPassphrase failed to open. If the current password + // opens it instead, the backups were rotated by a password change that ran before WCN-3920 + // write-protected them: say so with a dedicated error, so the caller can rebuild under the + // current password. Switching passwords here would silently desync the card from the Box D + // the caller writes with keycardPassphrase. Costs one extra decrypt, on the failure path only. + const backupPasswordError = async ( + envelope: string, + slot: RootKeyType, + cause: unknown + ): Promise => { + if (currentPassphrase !== keycardPassphrase) { + try { + await bitgo.decrypt({ input: envelope, password: currentPassphrase }); + return new SafeKeycardBackupsOnCurrentPasswordError(slot, cause); + } catch (probeError) { + // Neither password opens it: the creation password is simply wrong (reported below). + } + } + return new IncorrectPasswordError( + `the keycard (creation) password does not open the safe ${slot} backup root`, + cause + ); + }; + + // Copied backups (multisig envelopes, already-reduced MPC envelopes) are never opened, so + // nothing else on those paths proves keycardPassphrase is the creation password: a mistyped + // one would yield a keycard whose Box A/D use one password and Box B another. One trial + // decrypt per call settles it, since every backup stays on the creation password; reducing a + // full MPC backup envelope opens it with keycardPassphrase and counts as the check. + let keycardPassphraseVerified = false; + const verifyKeycardPassphrase = async (backupEncryptedPrv: string, slot: RootKeyType): Promise => { + if (keycardPassphraseVerified) { + return; + } + try { + await bitgo.decrypt({ input: backupEncryptedPrv, password: keycardPassphrase }); + } catch (e) { + throw await backupPasswordError(backupEncryptedPrv, slot, e); + } + keycardPassphraseVerified = true; + }; + + const roots: SafeKeychainRoots = {}; + for (const slot of slots) { + const triplet = material.roots[slot]; + if (!triplet) { + continue; + } + const { userKeychain, backupKeychain, bitgoKeychain } = triplet; + + if (slot === 'secp256k1Multisig' || slot === 'ed25519Multisig') { + if (!userKeychain.encryptedPrv || !backupKeychain.encryptedPrv) { + throw new Error(`safe ${slot} user/backup root is missing encryptedPrv`); + } + await verifyKeycardPassphrase(backupKeychain.encryptedPrv, slot); + let userPrv: string; + try { + userPrv = await bitgo.decrypt({ input: userKeychain.encryptedPrv, password: currentPassphrase }); + } catch (e) { + throw new IncorrectPasswordError( + `unable to decrypt the safe ${slot} user root with the provided passphrase`, + e + ); + } + roots[slot] = { + userKeychain: { ...userKeychain, encryptedPrv: await encryptWithKeycardPassword(userPrv) }, + // The backup envelope never leaves the creation password — copy it byte-identical. + backupKeychain, + bitgoKeychain, + }; + continue; + } + + // MPC roots (②③). + if (!userKeychain.encryptedPrv) { + throw new Error(`safe ${slot} user root is missing encryptedPrv`); + } + // A backup that already carries its reduced envelope (the triplets `generateSafe` returns: + // WP redacts the backup encryptedPrv on the key-add response, and the ceremony keeps the + // local reducedEncryptedPrv) is copied as-is, like a multisig backup. Otherwise — claimed + // material, which carries the full backup envelope — it is reduced from encryptedPrv. + const backupReducedEncryptedPrv = backupKeychain.reducedEncryptedPrv; + const backupEncryptedPrv = backupKeychain.encryptedPrv; + const commonKeychain = + userKeychain.commonKeychain ?? backupKeychain.commonKeychain ?? bitgoKeychain.commonKeychain; + if (!commonKeychain) { + throw new Error(`safe ${slot} root is missing commonKeychain`); + } + + const buildReducedEncryptedPrv = async ( + encryptedPrv: string, + password: string, + role: 'user' | 'backup' + ): Promise => { + let full: string; + try { + full = await bitgo.decrypt({ input: encryptedPrv, password }); + } catch (e) { + if (role === 'backup') { + throw await backupPasswordError(encryptedPrv, slot, e); + } + throw new IncorrectPasswordError( + `unable to decrypt the safe ${slot} user root with the provided passphrase`, + e + ); + } + const { signing, vrf } = parseSafeMpcKeyEnvelopes(full); + const reduced = + slot === 'ecdsaMpc' + ? DklsTypes.reduceDklsKeyShare(signing) + : MPSTypes.reduceMpsKeyShare(signing, commonKeychain); + const { reducedEnvelope } = buildSafeMpcKeyEnvelopes(signing, reduced, vrf); + return encryptWithKeycardPassword( + // btoa, not Buffer.toString('base64'): the browser deals with a Buffer as Uint8Array, + // where .toString('base64') would produce a comma-separated string (see the + // creation ceremonies' createParticipantKeychain). + btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedEnvelope)))) + ); + }; + + let outBackupKeychain: Keychain; + // Only a usable string counts: a null or empty value (a passthrough wire field) falls + // through to the full-envelope reduce instead of failing as a wrong password. + if (typeof backupReducedEncryptedPrv === 'string' && backupReducedEncryptedPrv.length > 0) { + // Copied without being opened, so prove keycardPassphrase opens it (once per call). + await verifyKeycardPassphrase(backupReducedEncryptedPrv, slot); + outBackupKeychain = backupKeychain; + } else if (backupEncryptedPrv) { + outBackupKeychain = { + ...backupKeychain, + reducedEncryptedPrv: await buildReducedEncryptedPrv(backupEncryptedPrv, keycardPassphrase, 'backup'), + }; + // Opening the full backup envelope already proved the creation password. + keycardPassphraseVerified = true; + } else { + throw new Error(`safe ${slot} backup root is missing both encryptedPrv and reducedEncryptedPrv`); + } + + roots[slot] = { + userKeychain: { + ...userKeychain, + reducedEncryptedPrv: await buildReducedEncryptedPrv(userKeychain.encryptedPrv, currentPassphrase, 'user'), + }, + backupKeychain: outBackupKeychain, + bitgoKeychain, + }; + } + return roots; + } finally { + session?.destroy(); + } +} diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index ea70ffc030f..694084025ea 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -25,6 +25,7 @@ import { encodeEd25519StrKeyPublicKey, encodeEd25519StrKeySecretSeed, type SafeKeycardRootEntry, + type SafeKeycardRoots, } from '@bitgo/sdk-lib-safes'; import { signEd25519Extended, @@ -189,13 +190,22 @@ export async function decryptSafeKeycard(params: { backupKeyBox: string; bitgoKeyBox: string; password: string; -}): Promise { +}): Promise> { const { bitgo, userKeyBox, backupKeyBox, bitgoKeyBox, password } = params; const userRoots = parseSafeKeycardBox(userKeyBox); const backupRoots = parseSafeKeycardBox(backupKeyBox); const bitgoRoots = parseSafeKeycardBox(bitgoKeyBox); + // A keycard carries a slot only when all three boxes declare it; anything else is a torn + // write, not a subset. + const declaredSlots = new Set(Object.keys(userRoots)); + const sameSet = (other: SafeKeycardRoots) => + declaredSlots.size === Object.keys(other).length && Object.keys(other).every((key) => declaredSlots.has(key)); + if (!sameSet(backupRoots) || !sameSet(bitgoRoots)) { + throw new Error('safe keycard boxes declare inconsistent root slots'); + } + const decryptRoot = async (root: string): Promise => { try { return await bitgo.decrypt({ input: root, password }); @@ -229,28 +239,40 @@ export async function decryptSafeKeycard(params: { return { prv: await decryptRoot(entry.prv), chainCode: entry.chainCode }; }; - return { - secp256k1Multisig: { - user: await decryptRoot(requireBareString(userRoots.secp256k1Multisig)), - backup: await decryptRoot(requireBareString(backupRoots.secp256k1Multisig)), - bitgo: requireBareString(bitgoRoots.secp256k1Multisig), - }, - ed25519Multisig: { - user: await decryptRoot(requireBareString(userRoots.ed25519Multisig)), - backup: await decryptEd25519Backup(backupRoots.ed25519Multisig), - bitgo: requireBareString(bitgoRoots.ed25519Multisig), - }, - ecdsaMpc: { - user: unwrapMpcRoot(await decryptRoot(requireBareString(userRoots.ecdsaMpc))), - backup: unwrapMpcRoot(await decryptRoot(requireBareString(backupRoots.ecdsaMpc))), - bitgo: requireBareString(bitgoRoots.ecdsaMpc), - }, - eddsaMpc: { - user: unwrapMpcRoot(await decryptRoot(requireBareString(userRoots.eddsaMpc))), - backup: unwrapMpcRoot(await decryptRoot(requireBareString(backupRoots.eddsaMpc))), - bitgo: requireBareString(bitgoRoots.eddsaMpc), - }, - }; + // Only the slots the keycard declares are built — a partial-slot safe yields a subset. + const has = (slot: keyof DecryptedSafeRoots): boolean => declaredSlots.has(slot); + const decrypted: Partial = {}; + + if (has('secp256k1Multisig')) { + decrypted.secp256k1Multisig = { + user: await decryptRoot(requireBareString(userRoots.secp256k1Multisig!)), + backup: await decryptRoot(requireBareString(backupRoots.secp256k1Multisig!)), + bitgo: requireBareString(bitgoRoots.secp256k1Multisig!), + }; + } + if (has('ed25519Multisig')) { + decrypted.ed25519Multisig = { + user: await decryptRoot(requireBareString(userRoots.ed25519Multisig!)), + backup: await decryptEd25519Backup(backupRoots.ed25519Multisig!), + bitgo: requireBareString(bitgoRoots.ed25519Multisig!), + }; + } + if (has('ecdsaMpc')) { + decrypted.ecdsaMpc = { + user: unwrapMpcRoot(await decryptRoot(requireBareString(userRoots.ecdsaMpc!))), + backup: unwrapMpcRoot(await decryptRoot(requireBareString(backupRoots.ecdsaMpc!))), + bitgo: requireBareString(bitgoRoots.ecdsaMpc!), + }; + } + if (has('eddsaMpc')) { + decrypted.eddsaMpc = { + user: unwrapMpcRoot(await decryptRoot(requireBareString(userRoots.eddsaMpc!))), + backup: unwrapMpcRoot(await decryptRoot(requireBareString(backupRoots.eddsaMpc!))), + bitgo: requireBareString(bitgoRoots.eddsaMpc!), + }; + } + + return decrypted; } /** @@ -261,7 +283,7 @@ export async function deriveSafeWalletKeys(params: { coin: string; slot: RootKeyType; account: number; - roots: DecryptedSafeRoots; + roots: Partial; }): Promise { if (!Number.isInteger(params.account) || params.account < 0 || params.account > MAX_BIP32_INDEX) { throw new Error(`deriveSafeWalletKeys: invalid account '${params.account}'`); @@ -272,10 +294,16 @@ export async function deriveSafeWalletKeys(params: { const { coinType, slotOrdinal } = parseSafeBip44Path(path); if (params.slot === 'secp256k1Multisig') { + if (!roots.secp256k1Multisig) { + throw new Error("safe keycard is missing the 'secp256k1Multisig' root"); + } return { slot: 'secp256k1Multisig', path, keys: deriveSafeChildTriplet(roots.secp256k1Multisig, path) }; } if (params.slot === 'ecdsaMpc') { + if (!roots.ecdsaMpc) { + throw new Error("safe keycard is missing the 'ecdsaMpc' root"); + } const { commonKeychain, userChild, backupChild } = await DklsVrfUtils.deriveSafeEcdsaMpcChild({ userRoot: roots.ecdsaMpc.user, backupRoot: roots.ecdsaMpc.backup, @@ -291,6 +319,9 @@ export async function deriveSafeWalletKeys(params: { } if (params.slot === 'eddsaMpc') { + if (!roots.eddsaMpc) { + throw new Error("safe keycard is missing the 'eddsaMpc' root"); + } const { commonKeychain, userChild, backupChild } = await MpsVrfUtils.deriveSafeEddsaMpcChild({ userRoot: roots.eddsaMpc.user, backupRoot: roots.eddsaMpc.backup, @@ -306,6 +337,9 @@ export async function deriveSafeWalletKeys(params: { } if (params.slot === 'ed25519Multisig') { + if (!roots.ed25519Multisig) { + throw new Error("safe keycard is missing the 'ed25519Multisig' root"); + } const root = roots.ed25519Multisig; const cosignerPath = toSoftTwinPath(path); diff --git a/modules/sdk-core/src/bitgo/safe/safe.ts b/modules/sdk-core/src/bitgo/safe/safe.ts index f383edbec78..927d6e8a1cc 100644 --- a/modules/sdk-core/src/bitgo/safe/safe.ts +++ b/modules/sdk-core/src/bitgo/safe/safe.ts @@ -8,8 +8,8 @@ import * as t from 'io-ts'; import { FreezeSafeBody, SafeData, SafeShareData, SafeShareState, type RootKeyType } from '@bitgo/public-types'; import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; -import { IncorrectPasswordError } from '../errors'; -import { decryptKeychainPrivateKey } from '../keychain'; +import { ApiResponseError, IncorrectPasswordError } from '../errors'; +import { decryptKeychainPrivateKey, type Keychain } from '../keychain'; import { ECDSAUtils, parseSafeMpcKeyEnvelopes, EDDSAUtils } from '../utils'; import { boundedInt, decodeWithCodec } from '../utils/codecs'; import { postWithCodec } from '../utils/postWithCodec'; @@ -29,6 +29,14 @@ import { WalletShareData, } from './iSafe'; import { coinForRoot, SAFE_ROOT_SLOTS } from './rootCoin'; +import { + RequestKeycardAccessConflictCodec, + RequestKeycardAccessResponseCodec, + SafeKeycardMaterialCodec, + type RequestKeycardAccessResult, + type SafeKeycardMaterial, + type SafeKeychainRoots, +} from './keycard'; import { buildSafeBip44Path, deriveAndSelfCheckSafeChildHardened, @@ -460,4 +468,61 @@ export class Safe implements ISafe { toJSON(): SafeData { return this._safe; } + + /** + * Request keycard regeneration access for this safe (WCN-3921 route contract): returns the + * granted access window (200), the pending approval the request created when another + * enterprise admin must approve (202), or `{ pendingApprovalId }` when an earlier request is + * still pending (409). Gated server-side by the `wp_safe_keycard_access` flag. + * + * Requires a login (short-lived) session that has been unlocked with OTP, and the caller must + * be the safe owner: access tokens are rejected. A flag-off or unknown safe is 404, a frozen + * safe 403, an initializing safe 409; those errors are thrown. + * @experimental + */ + async requestKeycardAccess(): Promise { + let response: unknown; + try { + response = await this.bitgo.post(this.url('/keycard/request')).send().result(); + } catch (e) { + // 409 `{ pendingApproval: '' }`: an earlier request is still awaiting approval. That is + // a state, not a failure. Any other 409 (e.g. SafeNotActive) is an ApiError body and throws. + if (e instanceof ApiResponseError && e.status === 409 && RequestKeycardAccessConflictCodec.is(e.result)) { + return { pendingApprovalId: e.result.pendingApproval }; + } + throw e; + } + return decodeWithCodec(RequestKeycardAccessResponseCodec, response, 'RequestKeycardAccessResponse'); + } + + /** + * Claim the safe's keycard material (root triplets + optional passcode encryption code) + * once keycard access has been granted. Each claimed `{ user, backup, bitgo }` triplet is + * mapped to the `KeychainsTriplet` shape `buildSafeKeycardRoots` and `generateSafeQrData` + * consume; the passthrough record preserves every other wire field. + * + * Same session requirements as {@link requestKeycardAccess}. The first claim starts a 15-minute + * re-claim window; outside it (or with no grant) WP answers 409 `SafeKeycardAccessNotGranted`. + * @experimental + */ + async claimKeycardMaterial(): Promise { + const response = await this.bitgo.post(this.url('/keycard')).send().result(); + const decoded = decodeWithCodec(SafeKeycardMaterialCodec, response, 'SafeKeycardMaterial'); + + const roots: SafeKeychainRoots = {}; + for (const [slot, triplet] of Object.entries(decoded.roots)) { + if (!SAFE_ROOT_SLOTS.includes(slot as RootKeyType)) { + throw new Error(`SafeKeycardMaterial: unknown root slot ${slot}`); + } + roots[slot as RootKeyType] = { + userKeychain: triplet.user as unknown as Keychain, + backupKeychain: triplet.backup as unknown as Keychain, + bitgoKeychain: triplet.bitgo as unknown as Keychain, + }; + } + if (Object.keys(roots).length === 0) { + throw new Error('claim returned no root material'); + } + return { roots, passcodeEncryptionCode: decoded.passcodeEncryptionCode ?? undefined }; + } } diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts index 5d9e0ac9c3b..033653fecb3 100644 --- a/modules/sdk-core/src/bitgo/safe/safes.ts +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -10,7 +10,6 @@ import { InitializeSafeBody, InitializeSafeResponse, RootKeysByType, - RootKeyTriplet, RootKeyType, SafeData, } from '@bitgo/public-types'; @@ -20,9 +19,19 @@ import { decodeWithCodec } from '../utils/codecs'; import { isDerivableEd25519Pub } from '@bitgo/sdk-lib-safes'; import { postWithCodec } from '../utils/postWithCodec'; import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; -import { CreateSafeOptions, GetSafeOptions, ISafes, ListSafesOptions, SafeCreationHandle, SafeKeys } from './iSafes'; +import { + CreateSafeOptions, + GetSafeOptions, + ISafes, + ListSafesOptions, + SafeCreationHandle, + SafeCreationResult, + SafeKeys, +} from './iSafes'; import { coinForRoot, SAFE_ROOT_SLOTS } from './rootCoin'; import { Safe } from './safe'; +import type { KeychainsTriplet } from '../baseCoin'; +import type { SafeKeychainRoots } from './keycard'; /** * Wire shape of the paginated `GET /enterprise/:eId/safes` response. WP paginates with the v2 @@ -62,20 +71,22 @@ export class Safes implements ISafes { /** * One-call convenience wrapper chaining the three creation phases: * initialize (Phase 1) → createSafeKeys (Phase 2, enabled-slot ceremonies) → - * finalize (Phase 3). HOT custody only in v1. + * finalize (Phase 3). HOT custody only in v1. Returns the finalized safe plus + * the root keychain triplets the caller needs to generate the safe keycard. * * If a key ceremony fails, `createSafeKeys` archives the safe before throwing, so a failed run * leaves no half-created safe behind — create a new safe and retry. * @experimental */ - async generateSafe(params: CreateSafeOptions): Promise { + async generateSafe(params: CreateSafeOptions): Promise { const safe = await this.initializeSafe({ label: params.label }); - const rootKeys = await this.createSafeKeys({ + const created = await this.createSafeKeys({ ...params, safeId: safe.id, enabledRootSlots: safe.enabledRootSlots, }); - return await this.finalizeSafe(safe.id, rootKeys); + const finalized = await this.finalizeSafe(safe.id, { rootKeys: created.rootKeys }); + return { safe: finalized, rootKeychains: created.rootKeychains }; } /** @@ -90,8 +101,11 @@ export class Safes implements ISafes { } /** - * Phase 2 — run the enabled root key ceremonies tagged with `safeId`; returns ordered - * [user, backup, bitgo] triplets for those slots. HOT custody only in v1. + * Phase 2 — run the enabled root key ceremonies tagged with `safeId`; returns the ordered + * [user, backup, bitgo] id triplets `finalizeSafe` consumes, plus the full keychain triplets + * for those slots (including `encryptedPrv` / `reducedEncryptedPrv`, and the stored + * `originalPasscodeEncryptionCode` when `params.passcodeEncryptionCode` is set). + * HOT custody only in v1. * * Absent `enabledRootSlots` (older WP, pre-gating) runs all 4 slots. Enabled ceremonies * (2.1 multisig ①④, 2.2 MPC ②③) run in parallel. If any of them fail, the @@ -104,30 +118,34 @@ export class Safes implements ISafes { * @experimental */ async createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise { - const { safeId, passphrase, enabledRootSlots } = params; + const { safeId, passphrase, passcodeEncryptionCode, enabledRootSlots } = params; const enterprise = this.enterpriseId; // Absent `enabledRootSlots` (older WP, pre-gating) ⇒ all 4, preserving current behavior. // Ordered by scheme: the two multisig roots first, then the two MPC roots. const enabled = new Set(enabledRootSlots ?? SAFE_ROOT_SLOTS); const slots = SAFE_ROOT_SLOTS.filter((slot) => enabled.has(slot)); - const ceremonies: Record Promise> = { + const ceremonies: Record Promise> = { // Phase 2.1 — multisig roots (①④): local user/backup keypairs + BitGo key, all safeId-tagged. - secp256k1Multisig: () => this.createMultisigRoot('secp256k1Multisig', safeId, passphrase, enterprise), - ed25519Multisig: () => this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise), + secp256k1Multisig: () => + this.createMultisigRoot('secp256k1Multisig', safeId, passphrase, enterprise, passcodeEncryptionCode), + ed25519Multisig: () => + this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise, passcodeEncryptionCode), // Phase 2.2 — MPC roots (②③): the existing DKLS (②) and EdDSA (③) ceremonies, safeId threaded. - ecdsaMpc: () => this.createMpcRoot('ecdsaMpc', safeId, passphrase, enterprise), - eddsaMpc: () => this.createMpcRoot('eddsaMpc', safeId, passphrase, enterprise), + ecdsaMpc: () => this.createMpcRoot('ecdsaMpc', safeId, passphrase, enterprise, passcodeEncryptionCode), + eddsaMpc: () => this.createMpcRoot('eddsaMpc', safeId, passphrase, enterprise, passcodeEncryptionCode), }; const results = await Promise.allSettled(slots.map((slot) => ceremonies[slot]())); // Single pass over the settled results: `status === 'fulfilled'` narrows `.value` to a - // RootKeyTriplet, and rejections are collected per-slot for the error below. + // KeychainsTriplet, and rejections are collected per-slot for the error below. const hot: RootKeysByType = {}; + const rootKeychains: SafeKeychainRoots = {}; const failures: string[] = []; results.forEach((result, i) => { if (result.status === 'fulfilled') { - hot[slots[i]] = result.value; + hot[slots[i]] = [result.value.userKeychain.id, result.value.backupKeychain.id, result.value.bitgoKeychain.id]; + rootKeychains[slots[i]] = result.value; } else { const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); // ApiResponseError carries the server-echoed `Request-ID` header — include it (when @@ -155,11 +173,11 @@ export class Safes implements ISafes { throw new Error(message); } - return { rootKeys: { hot } }; + return { rootKeys: { hot }, rootKeychains }; } /** - * Archive a safe. Also the abandonment path for a stuck `initializing` safe (used by + * Archive a safe. Also the abandonment path for a stuck 'initializing' safe (used by * `createSafeKeys` to clean up after a failed ceremony). * POST /api/v2/enterprise/:eId/safes/:safeId/archive * @experimental @@ -194,15 +212,16 @@ export class Safes implements ISafes { * Phase 2.1 helper — mint one onchain-multisig root (cold model, as wallet creation does today): * generate user/backup keypairs locally, encrypt with `passphrase`, register via POST /:coin/key * with `safeId`; the BitGo key is created through the same route's `source:'bitgo'` path. - * Returns the [user, backup, bitgo] key id triplet. + * Returns the [user, backup, bitgo] keychain triplet. * @experimental */ private async createMultisigRoot( slot: 'secp256k1Multisig' | 'ed25519Multisig', safeId: string, passphrase: string, - enterprise: string - ): Promise { + enterprise: string, + passcodeEncryptionCode?: string + ): Promise { const keychains = coinForRoot(this.bitgo, slot).keychains(); const userKeyPair = keychains.create(); @@ -211,8 +230,13 @@ export class Safes implements ISafes { encryptedPrv: await this.bitgo.encrypt({ input: userKeyPair.prv, password: passphrase }), source: 'user', safeId, + originalPasscodeEncryptionCode: passcodeEncryptionCode, + }); + const backupKeychainPromise = keychains.createBackup({ + passphrase, + safeId, + originalPasscodeEncryptionCode: passcodeEncryptionCode, }); - const backupKeychainPromise = keychains.createBackup({ passphrase, safeId }); const bitgoKeychainPromise = keychains.createBitGo({ enterprise, safeId }); const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ @@ -234,25 +258,35 @@ export class Safes implements ISafes { throw new Error('Safe ed25519Multisig roots are not derivable: expected 64-byte canonical base32 material'); } - return [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + // createBackup echoes the locally generated plaintext `prv`; the keycard needs only the + // encryptedPrv, so drop the plaintext before it escapes into the returned triplets. + const sanitizedBackupKeychain = { ...backupKeychain }; + delete sanitizedBackupKeychain.prv; + + return { userKeychain, backupKeychain: sanitizedBackupKeychain, bitgoKeychain }; } /** * Phase 2.2 helper — run one MPC ceremony (DKLS for ecdsaMpc, EdDSA for eddsaMpc) via the * existing `createMpc` flow with `safeId` threaded through, so the resulting user/backup/bitgo - * keys land tagged. Returns the [user, backup, bitgo] key id triplet. + * keys land tagged. Returns the [user, backup, bitgo] keychain triplet. * @experimental */ private async createMpcRoot( slot: 'ecdsaMpc' | 'eddsaMpc', safeId: string, passphrase: string, - enterprise: string - ): Promise { - const { userKeychain, backupKeychain, bitgoKeychain } = await coinForRoot(this.bitgo, slot) - .keychains() - .createMpc({ multisigType: 'tss', passphrase, enterprise, safeId }); - return [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + enterprise: string, + passcodeEncryptionCode?: string + ): Promise { + const { userKeychain, backupKeychain, bitgoKeychain } = await coinForRoot(this.bitgo, slot).keychains().createMpc({ + multisigType: 'tss', + passphrase, + enterprise, + safeId, + originalPasscodeEncryptionCode: passcodeEncryptionCode, + }); + return { userKeychain, backupKeychain, bitgoKeychain }; } /** diff --git a/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts b/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts index c81b7397e82..1233919604b 100644 --- a/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts @@ -265,6 +265,35 @@ describe('Keychains.updatePassword (safe mode)', function () { sinon.assert.calledOnce(sessionDestroy); }); + it('skips a safe backup root whose encryptedPrv is redacted (WCN-3920), never touching reducedEncryptedPrv', async function () { + // WCN-3920 redacts a safe backup root's encryptedPrv on every key read (only the keycard + // claim route returns it), so rotation sees the backup with no envelope and must emit no + // update for it — only for the user roots that do carry one. reducedEncryptedPrv is + // client-only and never stored in WP; it is set here solely to prove rotation ignores it. + const backupRoot = key('root-ecdsa-backup', { + type: 'tss', + encryptedPrv: undefined, + reducedEncryptedPrv: 'enc:reduced-root-ecdsa-backup', + }); + stubListPages([[key('root-ecdsa-user'), backupRoot, key('root-eddsa-user')]]); + + const response = await keychains.updatePassword({ + oldPassword: OLD_PASSWORD, + newPassword: NEW_PASSWORD, + safeId: SAFE_ID, + }); + + const bodies = sentBodies(); + bodies.length.should.equal(1); + bodies[0].updates.map((u) => u.keyId).should.deepEqual(['root-ecdsa-user', 'root-eddsa-user']); + response.updates.should.deepEqual([]); + for (const call of decryptStub.getCalls()) { + // sinon records untyped args; decrypt is only ever called with an { input, password } shape. + const decryptParams = call.args[0] as { input: string }; + decryptParams.input.should.not.equal('enc:reduced-root-ecdsa-backup'); + } + }); + it('fails fast on the first undecryptable envelope with zero writes', async function () { stubListPages([[key('good-1'), key('stale', { encryptedPrv: 'corrupt-envelope' }), key('good-2')]]); diff --git a/modules/sdk-core/test/unit/bitgo/safe/keycard.ts b/modules/sdk-core/test/unit/bitgo/safe/keycard.ts new file mode 100644 index 00000000000..523706e12e9 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/keycard.ts @@ -0,0 +1,532 @@ +import 'should'; +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import type { SafeData } from '@bitgo/public-types'; +import { DklsTypes, DklsUtils, DklsVrfUtils, MPSUtil, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import { ApiResponseError, IncorrectPasswordError } from '../../../../src/bitgo/errors'; +import { BitGoBase } from '../../../../src/bitgo/bitgoBase'; +import type { Keychain } from '../../../../src/bitgo/keychain'; +import type { KeychainsTriplet } from '../../../../src/bitgo/baseCoin'; +import { Safe } from '../../../../src/bitgo/safe/safe'; +import { + buildSafeKeycardRoots, + SafeKeycardBackupsOnCurrentPasswordError, + decryptSafeKeycardBoxD, + type SafeKeycardMaterial, +} from '../../../../src/bitgo/safe/keycard'; +import { buildSafeMpcKeyEnvelopes } from '../../../../src/bitgo/utils/tss/keyShareEnvelope'; + +const CURRENT_PW = 'current-password'; +const KEYCARD_PW = 'creation-password'; + +/** The envelope pair `buildSafeMpcKeyEnvelopes` produces (named locally: the leaf exports no type). */ +interface EnvelopePair { + envelope: Buffer; + reducedEnvelope: Buffer; +} + +/** Real per-slot MPC root material, exactly as the creation ceremonies produce it. */ +interface MpcSlotFixtures { + user: EnvelopePair; + backup: EnvelopePair; + commonKeychain: string; +} + +/** Deterministic base64 of a buffer — the browser-safe form the ceremonies use. */ +const btoaBytes = (buf: Uint8Array): string => btoa(String.fromCharCode.apply(null, Array.from(buf))); + +/** Decrypt stub keyed by `${password}:${input}` — unrecognized pairs fail like a real wrong password. */ +function makeMockBitGo(entries: Record): { + bitgo: BitGoBase; + sessionEncrypt: sinon.SinonStub; + createSession: sinon.SinonStub; +} { + const session = { + encrypt: sinon.stub().callsFake(async (p: string) => `SESSION[${p}]`), + decrypt: sinon.stub(), + destroy: sinon.stub(), + }; + const bitgo = { + decrypt: sinon.stub().callsFake(async ({ input, password }: { input: string; password: string }) => { + const plaintext = entries[`${password}:${input}`]; + if (plaintext === undefined) { + throw new Error('decrypt failed'); + } + return plaintext; + }), + createEncryptionSession: sinon.stub().resolves(session), + } as unknown as BitGoBase; + return { + bitgo, + sessionEncrypt: session.encrypt, + createSession: bitgo.createEncryptionSession as unknown as sinon.SinonStub, + }; +} + +function makeKeychain(overrides: Partial): Keychain { + return { id: 'id', type: 'independent', pub: 'pub', ...overrides }; +} + +describe('safe keycard regeneration (keycard.ts)', function (this: Mocha.Suite) { + this.timeout(120000); + + const mpc = {} as { ecdsa: MpcSlotFixtures; eddsa: MpcSlotFixtures }; + + before(async function () { + const [ecdsaUser, ecdsaBackup] = await DklsUtils.generateDKGKeyShares(); + const [ecdsaVrfUser, ecdsaVrfBackup] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const [eddsaUser, eddsaBackup] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [eddsaVrfUser, eddsaVrfBackup] = await MpsVrfUtils.generateVrfDKGKeyShares(); + mpc.ecdsa = { + user: buildSafeMpcKeyEnvelopes( + ecdsaUser.getKeyShare(), + ecdsaUser.getReducedKeyShare(), + ecdsaVrfUser.getKeyShare() + ), + backup: buildSafeMpcKeyEnvelopes( + ecdsaBackup.getKeyShare(), + ecdsaBackup.getReducedKeyShare(), + ecdsaVrfBackup.getKeyShare() + ), + commonKeychain: DklsTypes.getCommonKeychain(ecdsaUser.getKeyShare()), + }; + mpc.eddsa = { + user: buildSafeMpcKeyEnvelopes( + eddsaUser.getKeyShare(), + eddsaUser.getReducedKeyShare(), + eddsaVrfUser.getKeyShare() + ), + backup: buildSafeMpcKeyEnvelopes( + eddsaBackup.getKeyShare(), + eddsaBackup.getReducedKeyShare(), + eddsaVrfBackup.getKeyShare() + ), + commonKeychain: eddsaUser.getCommonKeychain(), + }; + }); + + /** All four roots' material, envelopes decryptable under the given (user, backup) passwords. */ + function makeMaterial( + userPassword: string, + backupPassword: string + ): { material: SafeKeycardMaterial; entries: Record } { + const entries: Record = { + [`${userPassword}:enc-user-secp`]: 'xprv-user', + [`${backupPassword}:enc-backup-secp`]: 'xprv-backup', + [`${userPassword}:enc-user-ed25519`]: 'S-user-seed', + [`${backupPassword}:enc-backup-ed25519`]: 'S-backup-seed', + [`${userPassword}:enc-user-ecdsa`]: mpc.ecdsa.user.envelope.toString('base64'), + [`${backupPassword}:enc-backup-ecdsa`]: mpc.ecdsa.backup.envelope.toString('base64'), + [`${userPassword}:enc-user-eddsa`]: mpc.eddsa.user.envelope.toString('base64'), + [`${backupPassword}:enc-backup-eddsa`]: mpc.eddsa.backup.envelope.toString('base64'), + }; + const root = (userEnc: string, backupEnc: string, commonKeychain?: string): KeychainsTriplet => ({ + userKeychain: makeKeychain({ id: 'user', encryptedPrv: userEnc, commonKeychain }), + backupKeychain: makeKeychain({ id: 'backup', encryptedPrv: backupEnc, commonKeychain }), + bitgoKeychain: makeKeychain({ id: 'bitgo', commonKeychain }), + }); + return { + material: { + roots: { + secp256k1Multisig: root('enc-user-secp', 'enc-backup-secp'), + ed25519Multisig: root('enc-user-ed25519', 'enc-backup-ed25519'), + ecdsaMpc: root('enc-user-ecdsa', 'enc-backup-ecdsa', mpc.ecdsa.commonKeychain), + eddsaMpc: root('enc-user-eddsa', 'enc-backup-eddsa', mpc.eddsa.commonKeychain), + }, + }, + entries, + }; + } + + describe('buildSafeKeycardRoots', function () { + it('re-encrypts user material under the keycard password and copies multisig backups byte-identical', async function () { + const { material, entries } = makeMaterial(KEYCARD_PW, KEYCARD_PW); + const { bitgo, createSession, sessionEncrypt } = makeMockBitGo(entries); + + const roots = await buildSafeKeycardRoots({ + bitgo, + material, + currentPassphrase: KEYCARD_PW, + keycardPassphrase: KEYCARD_PW, + }); + + sinon.assert.calledOnce(createSession); + sinon.assert.calledWithMatch(createSession, KEYCARD_PW); + roots.secp256k1Multisig!.userKeychain.encryptedPrv!.should.equal('SESSION[xprv-user]'); + roots.secp256k1Multisig!.backupKeychain.encryptedPrv!.should.equal('enc-backup-secp'); + roots.secp256k1Multisig!.bitgoKeychain.should.equal(material.roots.secp256k1Multisig!.bitgoKeychain); + roots.ed25519Multisig!.userKeychain.encryptedPrv!.should.equal('SESSION[S-user-seed]'); + // 2 multisig user re-encryptions + 4 MPC reduced envelopes share one KDF session. + sessionEncrypt.callCount.should.equal(6); + }); + + it('reduces MPC roots to the ceremony-shaped reducedEncryptedPrv and never touches encryptedPrv', async function () { + const { material, entries } = makeMaterial(CURRENT_PW, KEYCARD_PW); + const { bitgo } = makeMockBitGo(entries); + + const roots = await buildSafeKeycardRoots({ + bitgo, + material, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }); + + for (const slot of ['ecdsaMpc', 'eddsaMpc'] as const) { + const triplet = roots[slot]!; + assert.ok(triplet.userKeychain.reducedEncryptedPrv, `${slot} user reducedEncryptedPrv`); + assert.ok(triplet.backupKeychain.reducedEncryptedPrv, `${slot} backup reducedEncryptedPrv`); + triplet.userKeychain.encryptedPrv!.should.equal(material.roots[slot]!.userKeychain.encryptedPrv); + triplet.backupKeychain.encryptedPrv!.should.equal(material.roots[slot]!.backupKeychain.encryptedPrv); + } + // Byte-equal to the reduced envelope the creation ceremony stores (DKLS and MPS reduce paths). + roots.ecdsaMpc!.userKeychain.reducedEncryptedPrv!.should.equal( + `SESSION[${btoaBytes(mpc.ecdsa.user.reducedEnvelope)}]` + ); + roots.eddsaMpc!.userKeychain.reducedEncryptedPrv!.should.equal( + `SESSION[${btoaBytes(mpc.eddsa.user.reducedEnvelope)}]` + ); + }); + + it('copies an already-reduced MPC backup (generateSafe shape) as-is after verifying the creation password', async function () { + const { material, entries } = makeMaterial(CURRENT_PW, KEYCARD_PW); + // generateSafe shape: WP redacts the backup encryptedPrv on key-add; the ceremony keeps + // the local reducedEncryptedPrv under the creation password. + const reducedBackup = makeKeychain({ + id: 'backup', + encryptedPrv: undefined, + reducedEncryptedPrv: 'enc-reduced-backup-ecdsa', + commonKeychain: mpc.ecdsa.commonKeychain, + }); + const generateSafeShape: SafeKeycardMaterial = { + roots: { ecdsaMpc: { ...material.roots.ecdsaMpc!, backupKeychain: reducedBackup } }, + }; + const { bitgo } = makeMockBitGo({ + ...entries, + [`${KEYCARD_PW}:enc-reduced-backup-ecdsa`]: btoaBytes(mpc.ecdsa.backup.reducedEnvelope), + }); + + const roots = await buildSafeKeycardRoots({ + bitgo, + material: generateSafeShape, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }); + roots.ecdsaMpc!.backupKeychain.should.equal(reducedBackup); + roots.ecdsaMpc!.userKeychain.reducedEncryptedPrv!.should.equal( + `SESSION[${btoaBytes(mpc.ecdsa.user.reducedEnvelope)}]` + ); + + // The copied reduced envelope is still checked against the creation password. + await assert.rejects( + buildSafeKeycardRoots({ + bitgo, + material: generateSafeShape, + currentPassphrase: CURRENT_PW, + keycardPassphrase: 'mistyped-creation-password', + }), + (e: unknown) => e instanceof IncorrectPasswordError && /keycard \(creation\) password/.test(e.message) + ); + + // Neither envelope present: named error, not a decrypt of undefined. + const noBackupEnvelope: SafeKeycardMaterial = { + roots: { + ecdsaMpc: { + ...material.roots.ecdsaMpc!, + backupKeychain: makeKeychain({ id: 'backup', commonKeychain: mpc.ecdsa.commonKeychain }), + }, + }, + }; + await buildSafeKeycardRoots({ + bitgo, + material: noBackupEnvelope, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }).should.be.rejectedWith(/ecdsaMpc backup root is missing both encryptedPrv and reducedEncryptedPrv/); + }); + + it('names backups rotated by a pre-WCN-3920 password change instead of calling the creation password wrong', async function () { + // A password change that ran before backups were write-protected re-encrypted them too: + // every envelope, user and backup, is under the current password. + const { material, entries } = makeMaterial(CURRENT_PW, CURRENT_PW); + const { bitgo, sessionEncrypt } = makeMockBitGo(entries); + const isRotatedBackups = (slot: string) => (e: unknown) => + e instanceof SafeKeycardBackupsOnCurrentPasswordError && + e instanceof IncorrectPasswordError && // existing wrong-password handlers still catch it + e.slot === slot; + + // Multisig path (backup copied, verified by trial decrypt). + await assert.rejects( + buildSafeKeycardRoots({ + bitgo, + material: { roots: { secp256k1Multisig: material.roots.secp256k1Multisig! } }, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }), + isRotatedBackups('secp256k1Multisig') + ); + // MPC path (full backup envelope reduced). + await assert.rejects( + buildSafeKeycardRoots({ + bitgo, + material: { roots: { ecdsaMpc: material.roots.ecdsaMpc! } }, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }), + isRotatedBackups('ecdsaMpc') + ); + sinon.assert.notCalled(sessionEncrypt); + + // The documented recovery: rebuild with the current password as the keycard password. + const roots = await buildSafeKeycardRoots({ + bitgo, + material, + currentPassphrase: CURRENT_PW, + keycardPassphrase: CURRENT_PW, + }); + Object.keys(roots).should.deepEqual(['secp256k1Multisig', 'ed25519Multisig', 'ecdsaMpc', 'eddsaMpc']); + roots.secp256k1Multisig!.backupKeychain.encryptedPrv!.should.equal('enc-backup-secp'); + }); + + it('falls back to reducing the full envelope when reducedEncryptedPrv is null on the wire', async function () { + const { material, entries } = makeMaterial(CURRENT_PW, KEYCARD_PW); + const { bitgo } = makeMockBitGo(entries); + const ecdsa = material.roots.ecdsaMpc!; + const nullReduced = { + ...ecdsa.backupKeychain, + reducedEncryptedPrv: null as unknown as string, + }; + + const roots = await buildSafeKeycardRoots({ + bitgo, + material: { roots: { ecdsaMpc: { ...ecdsa, backupKeychain: nullReduced } } }, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }); + roots.ecdsaMpc!.backupKeychain.reducedEncryptedPrv!.should.equal( + `SESSION[${btoaBytes(mpc.ecdsa.backup.reducedEnvelope)}]` + ); + }); + + it('throws IncorrectPasswordError when the current password does not open a user envelope', async function () { + const { material } = makeMaterial(CURRENT_PW, KEYCARD_PW); + const { bitgo } = makeMockBitGo({}); // nothing decrypts + + await buildSafeKeycardRoots({ + bitgo, + material, + currentPassphrase: 'wrong', + keycardPassphrase: KEYCARD_PW, + }).should.be.rejectedWith(IncorrectPasswordError); + }); + + it('rejects a wrong creation password on a multisig-only subset instead of emitting a mixed-password keycard', async function () { + const { material, entries } = makeMaterial(CURRENT_PW, KEYCARD_PW); + const { bitgo, sessionEncrypt } = makeMockBitGo(entries); + const multisigOnly: SafeKeycardMaterial = { + roots: { + secp256k1Multisig: material.roots.secp256k1Multisig!, + ed25519Multisig: material.roots.ed25519Multisig!, + }, + }; + const isWrongCreationPassword = (e: unknown): boolean => + e instanceof IncorrectPasswordError && + !(e instanceof SafeKeycardBackupsOnCurrentPasswordError) && + /keycard \(creation\) password/.test(e.message); + + // Mistyped creation password: the user envelope opens, but the backups would not. + await assert.rejects( + buildSafeKeycardRoots({ + bitgo, + material: multisigOnly, + currentPassphrase: CURRENT_PW, + keycardPassphrase: 'mistyped-creation-password', + }), + isWrongCreationPassword + ); + // Password changed but the caller passed the current password for both. + await assert.rejects( + buildSafeKeycardRoots({ + bitgo, + material: multisigOnly, + currentPassphrase: CURRENT_PW, + keycardPassphrase: CURRENT_PW, + }), + isWrongCreationPassword + ); + sinon.assert.notCalled(sessionEncrypt); + + // The right creation password verifies once and builds both slots. + const decryptStub = bitgo.decrypt as unknown as sinon.SinonStub; + decryptStub.resetHistory(); + const roots = await buildSafeKeycardRoots({ + bitgo, + material: multisigOnly, + currentPassphrase: CURRENT_PW, + keycardPassphrase: KEYCARD_PW, + }); + Object.keys(roots).should.deepEqual(['secp256k1Multisig', 'ed25519Multisig']); + // 1 backup verification + 2 user decrypts. + decryptStub.callCount.should.equal(3); + }); + + it('throws a named error when a root is missing its envelope, and when roots is empty', async function () { + const { material, entries } = makeMaterial(KEYCARD_PW, KEYCARD_PW); + const { bitgo } = makeMockBitGo(entries); + const missingEnvelope: SafeKeycardMaterial = { roots: { secp256k1Multisig: material.roots.secp256k1Multisig! } }; + missingEnvelope.roots.secp256k1Multisig!.userKeychain.encryptedPrv = undefined; + + await buildSafeKeycardRoots({ + bitgo, + material: missingEnvelope, + currentPassphrase: KEYCARD_PW, + keycardPassphrase: KEYCARD_PW, + }).should.be.rejectedWith(/secp256k1Multisig user\/backup root is missing encryptedPrv/); + await buildSafeKeycardRoots({ + bitgo, + material: { roots: {} }, + currentPassphrase: KEYCARD_PW, + keycardPassphrase: KEYCARD_PW, + }).should.be.rejectedWith(/carries no root slots/); + }); + }); + + describe('decryptSafeKeycardBoxD', function () { + it('returns the creation password for the right code and IncorrectPasswordError otherwise', async function () { + const good = makeMockBitGo({ '123456:box-d': 'creation-password' }); + ( + await decryptSafeKeycardBoxD({ bitgo: good.bitgo, boxD: 'box-d', passcodeEncryptionCode: '123456' }) + ).should.equal('creation-password'); + const bad = makeMockBitGo({}); + await decryptSafeKeycardBoxD({ + bitgo: bad.bitgo, + boxD: 'box-d', + passcodeEncryptionCode: '000000', + }).should.be.rejectedWith(IncorrectPasswordError); + }); + }); + describe('Safe.requestKeycardAccess / claimKeycardMaterial', function () { + const safeDataWire = { + id: 'safe-1', + enterpriseId: 'ent-1', + label: 'l', + status: 'active', + creator: 'c', + users: [], + createdAt: '2026-01-01T00:00:00.000Z', + } as unknown as SafeData; + + function makeSafe() { + const mockBitGo = { + url: sinon.stub().callsFake((path: string) => path), + post: sinon.stub(), + }; + return { safe: new Safe(mockBitGo as unknown as BitGoBase, safeDataWire), mockBitGo }; + } + + it('decodes a granted keycardAccess window', async function () { + const { safe, mockBitGo } = makeSafe(); + mockBitGo.post.returns({ + send: sinon + .stub() + .returns({ result: sinon.stub().resolves({ keycardAccess: { expiresAt: '2026-11-10T00:00:00Z' } }) }), + }); + + const result = await safe.requestKeycardAccess(); + sinon.assert.calledWithMatch(mockBitGo.post, '/enterprise/ent-1/safes/safe-1/keycard/request'); + if (!('keycardAccess' in result)) { + throw new Error('expected keycardAccess'); + } + result.keycardAccess.expiresAt.should.equal('2026-11-10T00:00:00Z'); + }); + + it('decodes a pending approval instead of access', async function () { + const { safe, mockBitGo } = makeSafe(); + mockBitGo.post.returns({ + send: sinon + .stub() + .returns({ result: sinon.stub().resolves({ pendingApproval: { id: 'pa-1', state: 'pending' } }) }), + }); + + const result = await safe.requestKeycardAccess(); + if (!('pendingApproval' in result)) { + throw new Error('expected pendingApproval'); + } + result.pendingApproval.id.should.equal('pa-1'); + result.pendingApproval.state.should.equal('pending'); + }); + + it('claims material and maps claimed triplets to the KeychainsTriplet shape', async function () { + const { safe, mockBitGo } = makeSafe(); + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves({ + id: 'kc-1', + roots: { + ecdsaMpc: { + user: { id: 'u1', encryptedPrv: 'enc', extra: 'preserved' }, + backup: { id: 'b1' }, + bitgo: { id: 'g1' }, + }, + }, + passcodeEncryptionCode: '123456', + }), + }), + }); + + const material = await safe.claimKeycardMaterial(); + sinon.assert.calledWithMatch(mockBitGo.post, '/enterprise/ent-1/safes/safe-1/keycard'); + material.passcodeEncryptionCode!.should.equal('123456'); + const triplet = material.roots.ecdsaMpc!; + triplet.userKeychain.id.should.equal('u1'); + const claimedUser = triplet.userKeychain as unknown as { extra: unknown }; + assert.strictEqual(claimedUser.extra, 'preserved'); + triplet.backupKeychain.id.should.equal('b1'); + triplet.bitgoKeychain.id.should.equal('g1'); + }); + + it('maps a 409 with a pending approval id to { pendingApprovalId } and rethrows other 409s', async function () { + const { safe, mockBitGo } = makeSafe(); + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().rejects(new ApiResponseError('conflict', 409, { pendingApproval: 'pa-earlier' })), + }), + }); + (await safe.requestKeycardAccess()).should.deepEqual({ pendingApprovalId: 'pa-earlier' }); + + const notActive = new ApiResponseError('SafeNotActive', 409, { error: 'SafeNotActive', name: 'SafeNotActive' }); + mockBitGo.post.returns({ send: sinon.stub().returns({ result: sinon.stub().rejects(notActive) }) }); + await assert.rejects(safe.requestKeycardAccess(), (e: unknown) => e === notActive); + }); + + it('treats a null passcodeEncryptionCode (pre-code safe) as absent', async function () { + const { safe, mockBitGo } = makeSafe(); + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves({ + roots: { ecdsaMpc: { user: { id: 'u1' }, backup: { id: 'b1' }, bitgo: { id: 'g1' } } }, + passcodeEncryptionCode: null, + }), + }), + }); + + const material = await safe.claimKeycardMaterial(); + assert.strictEqual(material.passcodeEncryptionCode, undefined); + material.roots.ecdsaMpc!.userKeychain.id.should.equal('u1'); + }); + + it('rejects an unknown slot key and empty claimed roots', async function () { + const { safe, mockBitGo } = makeSafe(); + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves({ + roots: { bogusSlot: { user: { id: 'u' }, backup: { id: 'b' }, bitgo: { id: 'g' } } }, + }), + }), + }); + await safe.claimKeycardMaterial().should.be.rejectedWith(/SafeKeycardMaterial/); + + mockBitGo.post.returns({ send: sinon.stub().returns({ result: sinon.stub().resolves({ roots: {} }) }) }); + await safe.claimKeycardMaterial().should.be.rejectedWith(/no root material/); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts index 1b45d1936b2..57c70876d30 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts @@ -113,22 +113,22 @@ describe('decryptSafeKeycard', () => { password: PASSWORD, }); - result.secp256k1Multisig.user.should.equal('xprv-user'); - result.secp256k1Multisig.backup.should.equal('xprv-backup'); - result.secp256k1Multisig.bitgo.should.equal('xpub-secp-bitgo'); + result.secp256k1Multisig!.user.should.equal('xprv-user'); + result.secp256k1Multisig!.backup.should.equal('xprv-backup'); + result.secp256k1Multisig!.bitgo.should.equal('xpub-secp-bitgo'); - result.ed25519Multisig.user.should.equal('S-user-seed'); - result.ed25519Multisig.backup.prv.should.equal('S-backup-seed'); - result.ed25519Multisig.backup.chainCode.should.equal('C'.repeat(52)); - result.ed25519Multisig.bitgo.should.equal('pub-ed25519-bitgo'); + result.ed25519Multisig!.user.should.equal('S-user-seed'); + result.ed25519Multisig!.backup.prv.should.equal('S-backup-seed'); + result.ed25519Multisig!.backup.chainCode.should.equal('C'.repeat(52)); + result.ed25519Multisig!.bitgo.should.equal('pub-ed25519-bitgo'); - result.ecdsaMpc.user.signing.toString('hex').should.equal(Buffer.from('user-reduced').toString('hex')); - result.ecdsaMpc.user.vrf.toString('hex').should.equal(Buffer.from('user-vrf').toString('hex')); - result.ecdsaMpc.backup.signing.toString('hex').should.equal(Buffer.from('backup-reduced').toString('hex')); - result.ecdsaMpc.backup.vrf.toString('hex').should.equal(Buffer.from('backup-vrf').toString('hex')); - result.ecdsaMpc.bitgo.should.equal('common-ecdsa-bitgo'); + result.ecdsaMpc!.user.signing.toString('hex').should.equal(Buffer.from('user-reduced').toString('hex')); + result.ecdsaMpc!.user.vrf.toString('hex').should.equal(Buffer.from('user-vrf').toString('hex')); + result.ecdsaMpc!.backup.signing.toString('hex').should.equal(Buffer.from('backup-reduced').toString('hex')); + result.ecdsaMpc!.backup.vrf.toString('hex').should.equal(Buffer.from('backup-vrf').toString('hex')); + result.ecdsaMpc!.bitgo.should.equal('common-ecdsa-bitgo'); - result.eddsaMpc.bitgo.should.equal('common-eddsa-bitgo'); + result.eddsaMpc!.bitgo.should.equal('common-eddsa-bitgo'); // 4 slots × (user + backup) = 8 decrypts; BitGo (Box C) is never decrypted. (bitgo.decrypt as sinon.SinonStub).callCount.should.equal(8); @@ -186,8 +186,8 @@ describe('decryptSafeKeycard', () => { password: PASSWORD, }); - result.ed25519Multisig.backup.prv.should.equal('S-backup-seed'); - result.ed25519Multisig.backup.chainCode.should.equal('A'.repeat(52)); + result.ed25519Multisig!.backup.prv.should.equal('S-backup-seed'); + result.ed25519Multisig!.backup.chainCode.should.equal('A'.repeat(52)); }); it('rejects an ed25519 chain-code entry in a non-backup slot (fail closed)', async () => { diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts index 5eb236ec42b..8e7195490db 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safes.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -100,21 +100,61 @@ describe('Safes', function () { }); }); - it('runs the 4 root ceremonies and returns 12 ids as ordered triplets', async function () { + it('runs the 4 root ceremonies and returns 12 ids plus the keychain triplets', async function () { const result = await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); - result.should.deepEqual({ - rootKeys: { - hot: { - secp256k1Multisig: ['tbtc-user', 'tbtc-backup', 'tbtc-bitgo'], - ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], - eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], - ed25519Multisig: ['txlm-user', 'txlm-backup', 'txlm-bitgo'], - }, + result.rootKeys.should.deepEqual({ + hot: { + secp256k1Multisig: ['tbtc-user', 'tbtc-backup', 'tbtc-bitgo'], + ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], + eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], + ed25519Multisig: ['txlm-user', 'txlm-backup', 'txlm-bitgo'], + }, + }); + result.rootKeychains.should.deepEqual({ + secp256k1Multisig: { + userKeychain: { id: 'tbtc-user' }, + backupKeychain: { id: 'tbtc-backup' }, + bitgoKeychain: { id: 'tbtc-bitgo' }, + }, + ecdsaMpc: { + userKeychain: { id: 'hteth-user' }, + backupKeychain: { id: 'hteth-backup' }, + bitgoKeychain: { id: 'hteth-bitgo' }, + }, + eddsaMpc: { + userKeychain: { id: 'tsol-user' }, + backupKeychain: { id: 'tsol-backup' }, + bitgoKeychain: { id: 'tsol-bitgo' }, + }, + ed25519Multisig: { + userKeychain: { id: 'txlm-user' }, + backupKeychain: { id: 'txlm-backup', pub: COMPOSITE_BACKUP_PUB }, + bitgoKeychain: { id: 'txlm-bitgo', pub: COMPOSITE_BACKUP_PUB }, }, }); }); + it('never returns the plaintext multisig backup prv that createBackup echoes', async function () { + mockBitGo.coin('tbtc'); + keychainsByCoin.tbtc.createBackup.resolves({ + id: 'tbtc-backup', + prv: 'xprv-plaintext-backup', + encryptedPrv: 'encrypted-backup-prv', + }); + + const result = await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + enabledRootSlots: ['secp256k1Multisig'], + }); + + const backup = result.rootKeychains.secp256k1Multisig!.backupKeychain; + backup.should.not.have.property('prv'); + backup.encryptedPrv!.should.equal('encrypted-backup-prv'); + }); + it('rejects an ed25519Multisig backup root that came back non-derivable', async function () { // A bare Stellar StrKey is not the neutral raw root format. keychainsByCoin['txlm'] = makeKeychains('txlm'); @@ -236,18 +276,42 @@ describe('Safes', function () { enabledRootSlots: ['ecdsaMpc', 'eddsaMpc'], }); - result.should.deepEqual({ - rootKeys: { - hot: { - ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], - eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], - }, + result.rootKeys.should.deepEqual({ + hot: { + ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], + eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], }, }); + Object.keys(result.rootKeychains).should.deepEqual(['ecdsaMpc', 'eddsaMpc']); keychainsByCoin.should.not.have.property('tbtc'); keychainsByCoin.should.not.have.property('txlm'); }); + it('threads passcodeEncryptionCode to every root registration, and omits it when absent', async function () { + const registrations = () => + [ + keychainsByCoin['tbtc'].add, + keychainsByCoin['tbtc'].createBackup, + keychainsByCoin['txlm'].createBackup, + keychainsByCoin['hteth'].createMpc, + keychainsByCoin['tsol'].createMpc, + ] as sinon.SinonStub[]; + await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + passcodeEncryptionCode: '123456', + }); + for (const stub of registrations()) { + sinon.assert.calledWithMatch(stub, { safeId: 'safe-1', originalPasscodeEncryptionCode: '123456' }); + stub.resetHistory(); + } + await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); + for (const stub of registrations()) { + sinon.assert.calledWithMatch(stub, { originalPasscodeEncryptionCode: undefined }); + } + }); + it('archives the safe and throws listing every failed ceremony', async function () { // Two ceremonies fail (an MPC and a multisig root). keychainsByCoin['hteth'] = makeKeychains('hteth'); @@ -320,15 +384,22 @@ describe('Safes', function () { }); describe('generateSafe', function () { - it('chains initialize → createSafeKeys → finalize, threading the safeId and enabledRootSlots', async function () { + it('chains initialize → createSafeKeys → finalize, returning the safe and the root triplets', async function () { const initializing: InitializeSafeResponse = { id: 'test-safe-id', status: 'initializing', enabledRootSlots: ['ecdsaMpc', 'eddsaMpc'], }; - const rootKeys: SafeKeys = { rootKeys: { hot: {} } }; + const rootKeychains = { + ecdsaMpc: { + userKeychain: { id: 'hteth-user', type: 'tss' as const, reducedEncryptedPrv: 'reduced-enc' }, + backupKeychain: { id: 'hteth-backup', type: 'tss' as const, reducedEncryptedPrv: 'reduced-enc-b' }, + bitgoKeychain: { id: 'hteth-bitgo', type: 'tss' as const }, + }, + }; + const created: SafeKeys = { rootKeys: { hot: { ecdsaMpc: ['u', 'b', 'g'] } }, rootKeychains }; const initStub = sinon.stub(safes, 'initializeSafe').resolves(initializing); - const keysStub = sinon.stub(safes, 'createSafeKeys').resolves(rootKeys); + const keysStub = sinon.stub(safes, 'createSafeKeys').resolves(created); const finalizeStub = sinon.stub(safes, 'finalizeSafe').resolves(new Safe(mockBitGo, safeDataWire as any)); const result = await safes.generateSafe({ label: 'my safe', passphrase: 'pw' }); @@ -340,9 +411,11 @@ describe('Safes', function () { safeId: 'test-safe-id', enabledRootSlots: ['ecdsaMpc', 'eddsaMpc'], }); - sinon.assert.calledWith(finalizeStub, 'test-safe-id', rootKeys); + sinon.assert.calledWith(finalizeStub, 'test-safe-id', { rootKeys: created.rootKeys }); sinon.assert.callOrder(initStub, keysStub, finalizeStub); - result.status().should.equal('active'); + result.safe.should.be.instanceof(Safe); + result.safe.status().should.equal('active'); + result.rootKeychains.should.deepEqual(rootKeychains); }); }); diff --git a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts index 8066fd89dcb..f962b0d8cd5 100644 --- a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts +++ b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/dkg.ts @@ -7,7 +7,7 @@ import { DeserializedBroadcastMessage, DeserializedMessages, DkgState, - ReducedKeyShare, + reduceDklsKeyShare, RetrofitData, } from './types'; @@ -181,16 +181,7 @@ export class Dkg { if (!this.keyShareBuff) { throw Error('Can not get key share, DKG is not complete yet.'); } - const decodedKeyshare = decode(this.keyShareBuff); - const reducedKeyShare: ReducedKeyShare = { - bigSList: decodedKeyshare.big_s_list, - xList: decodedKeyshare.x_i_list, - rootChainCode: decodedKeyshare.root_chain_code, - prv: decodedKeyshare.s_i, - pub: decodedKeyshare.public_key, - }; - const encodedKeyShare = encode(reducedKeyShare); - return encodedKeyShare; + return reduceDklsKeyShare(this.keyShareBuff); } handleIncomingMessages(messagesForIthRound: DeserializedMessages): DeserializedMessages { diff --git a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/types.ts b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/types.ts index 36c400a3e90..ecc25600e00 100644 --- a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/types.ts +++ b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/types.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import { createHash } from 'crypto'; -import { decode } from 'cbor-x'; +import { decode, encode } from 'cbor-x'; import * as t from 'io-ts'; import { XShare } from '../ecdsa/types'; import { Secp256k1Curve } from '../../curves'; @@ -198,6 +198,26 @@ export function getDecodedReducedKeyShare(reducedKeyShare: Buffer | Uint8Array): return decoded.right; } +/** + * Reduces a full CBOR-encoded DKLS key share to a CBOR-encoded ReducedKeyShare + * (the key-card subset: big S commitments, evaluation points, root chain code, + * the party's private scalar and its public key). The result is private key + * material; the caller encrypts it and stores it as `reducedEncryptedPrv`. + * @param fullKeyShare - CBOR-encoded full DKLS key share + * @returns CBOR-encoded ReducedKeyShare + */ +export function reduceDklsKeyShare(fullKeyShare: Buffer): Buffer { + const decoded = decode(fullKeyShare); + const reducedKeyShare: ReducedKeyShare = { + bigSList: decoded.big_s_list, + xList: decoded.x_i_list, + rootChainCode: decoded.root_chain_code, + prv: decoded.s_i, + pub: decoded.public_key, + }; + return Buffer.from(encode(reducedKeyShare)); +} + /** * Plain-object form of a DKLS keyshare, matching what the wasm `Keyshare` serializes to * (snake_case fields). `buildDklsKeyShare` produces this; it is `encode`d to CBOR and then diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts index ed2f3a3bc4c..fe4dd1cd92b 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts @@ -1,9 +1,8 @@ import type { MsgState, Share } from '@bitgo/wasm-mps'; import type { VrfKeygenSession as EddsaVrfKeygenSession } from '@silencelaboratories/eddsa-wasm-node'; import type { VrfKeygenSession as EddsaVrfWebKeygenSession } from '@silencelaboratories/eddsa-wasm-web'; -import { encode } from 'cbor-x'; import crypto from 'crypto'; -import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare, EddsaRetrofitData } from './types'; +import { DeserializedMessage, DeserializedMessages, DkgState, EddsaRetrofitData, reduceMpsKeyShare } from './types'; type NodeWasmer = typeof import('@bitgo/wasm-mps'); type WebWasmer = typeof import('@bitgo/wasm-mps/web'); @@ -261,12 +260,7 @@ export class DKG { if (!this.keyShare || !this.sharePk || !this.shareChaincode) { throw Error('DKG session not initialized'); } - const reducedKeyShare: EddsaReducedKeyShare = { - keyShare: Array.from(this.keyShare), - pub: Array.from(this.sharePk), - rootChainCode: Array.from(this.shareChaincode), - }; - return Buffer.from(encode(reducedKeyShare)); + return reduceMpsKeyShare(this.keyShare, this.getCommonKeychain()); } /** diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts index aba2126ef2b..0b78c1c9749 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts @@ -1,4 +1,4 @@ -import { decode } from 'cbor-x'; +import { decode, encode } from 'cbor-x'; import { isLeft } from 'fp-ts/Either'; import * as t from 'io-ts'; @@ -24,6 +24,24 @@ export const ReducedKeyShareType = t.type({ export type EddsaReducedKeyShare = t.TypeOf; +/** + * Reduces a full MPS key share to a CBOR-encoded EddsaReducedKeyShare (the + * key-card subset: the party's opaque signing share, the aggregate public key + * and the root chain code, both split from `commonKeychain`). The result is + * private key material; the caller encrypts it and stores it as `reducedEncryptedPrv`. + * @param fullKeyShare - the party's opaque DKG key share + * @param commonKeychain - 128-char hex: 64-char aggregate public key + 64-char root chain code + * @returns CBOR-encoded EddsaReducedKeyShare + */ +export function reduceMpsKeyShare(fullKeyShare: Buffer, commonKeychain: string): Buffer { + const reducedKeyShare: EddsaReducedKeyShare = { + keyShare: Array.from(fullKeyShare), + pub: Array.from(Buffer.from(commonKeychain.slice(0, 64), 'hex')), + rootChainCode: Array.from(Buffer.from(commonKeychain.slice(64), 'hex')), + }; + return Buffer.from(encode(reducedKeyShare)); +} + /** * Represents the state of a DKG (Distributed Key Generation) session */ diff --git a/modules/sdk-lib-mpc/test/unit/tss/ecdsa/reduceKeyShare.ts b/modules/sdk-lib-mpc/test/unit/tss/ecdsa/reduceKeyShare.ts new file mode 100644 index 00000000000..2dd8f4c3c16 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/ecdsa/reduceKeyShare.ts @@ -0,0 +1,21 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import { DklsTypes } from '../../../../src/tss/ecdsa-dkls'; +import { generate2of2KeyShares } from '../../../../src/tss/ecdsa-dkls/util'; + +// Reduced bytes of fixtures/userShare, produced by master's pre-extraction Dkg.getReducedKeyShare. +const REDUCED_USER_SHARE_HEX = + 'b9000568626967534c69737483982103181e1884189b18951866186318851823183e18d7184018e3186c18a2189a18ea181e18741876181d189d1839182618f018de18681824183018f418e410181c98210218620918250a18ac18d5182918f418b218a401186615182f18441884188618a91018c8185818d60d187c182c18991863182e1889183418641818982103186118a2188818ea1118a318a3182218ca182a182718f618710618a718e3189d1118f4185a187a189d18de185f1846187018da18ea18a3187018a0186d65784c697374839820186b188d18af18bb182618621874189a18c918a91874188d18f718b80118e118b918d31895187518f718eb185c18361818186618550318c11878185618629820186918fc188918fd18e0183018fe18ca18c91718f2188a182d18d218ea1859186318dc18bc18270c18fa0d1837186e181f183b18a318c708181a18da982018a518c0184d18f01871185f18a11826189418cc188718d118ef18cb186f188418eb0a18461894184118180618901838182e010b181e188918e30c6d726f6f74436861696e436f64659820182118fa18a4184f18f118c21881181918721843185118d208183118e4183d1818184f18d618f0183e18d7188918c201188e186c0c18ff184a18cb1863637072769820188e1841189c182f188d183918d318441894183218fa1889182d188e18e7181e00061854000d188418f218711895182b185a18ce1892186518d118f163707562982103184a14186b18510e18ce18be1832188d183518b706189d188718bc185c188218e8183118a9185318ed189718e60918a60218f3187d189518d5187e'; + +describe('DKLS reduceKeyShare', function () { + it('reduces the full-share fixture to the golden bytes produced before extraction', async function () { + const fullKeyShare = await fs.promises.readFile(`${__dirname}/fixtures/userShare`); + assert.strictEqual(DklsTypes.reduceDklsKeyShare(fullKeyShare).toString('hex'), REDUCED_USER_SHARE_HEX); + }); + + it('Dkg.getReducedKeyShare delegates to reduceDklsKeyShare for a live DKG', async function () { + for (const party of await generate2of2KeyShares()) { + assert(DklsTypes.reduceDklsKeyShare(party.getKeyShare()).equals(party.getReducedKeyShare())); + } + }); +}); diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/reduceKeyShare.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/reduceKeyShare.ts new file mode 100644 index 00000000000..e7d4d8ee837 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/reduceKeyShare.ts @@ -0,0 +1,25 @@ +import assert from 'assert'; +import { MPSUtil, MPSTypes } from '../../../../src/tss/eddsa-mps'; + +// A share captured from a live DKG session, and the reduced bytes master's pre-extraction +// DKG.getReducedKeyShare produced from that session's state (sharePk / shareChaincode). +const FULL_KEY_SHARE_HEX = + '02030017ed9c0538df207c72685b5a377a5e6cda1ddd73dd660bef6a3c615c321f2f0e922144e8cebdbfebc17e049b18a85a0231f75f2a3a6fb72e4658c37c942698ea600113598b8ea4e3b226dc94731312d3bd0b3fee7af45c2253924c6836bec68523fefe24180ab596fdeb64389bcbd10f4f60ce693bb9627d649093271e7349c15431a0c9d492881d3dd72712a0f74d83ad124da627e18e5bad8ac9c23da519c5a821e62ebb503fd98bb412cac95dc24bd84a9cbd151e6d68ce3d9817f92dc36ec50087fa1229991d38fbb60b718d41dd0a5d99ff5830e623c2895ac8e17cc54e38c1'; +const COMMON_KEYCHAIN = + '922144e8cebdbfebc17e049b18a85a0231f75f2a3a6fb72e4658c37c942698ea87fa1229991d38fbb60b718d41dd0a5d99ff5830e623c2895ac8e17cc54e38c1'; +const REDUCED_KEY_SHARE_HEX = + 'b90003686b6579536861726598e50203001718ed189c05183818df1820187c18721868185b185a1837187a185e186c18da181d18dd187318dd18660b18ef186a183c1861185c1832181f182f0e18921821184418e818ce18bd18bf18eb18c1187e04189b181818a8185a02183118f7185f182a183a186f18b7182e1846185818c3187c18941826189818ea186001131859188b188e18a418e318b2182618dc18941873131218d318bd0b183f18ee187a18f4185c182218531892184c1868183618be18c61885182318fe18fe182418180a18b5189618fd18eb18641838189b18cb18d10f184f186018ce1869183b18b91862187d1864189018931827181e1873184918c11854183118a018c918d418921888181d183d18d718271218a018f7184d188318ad12184d18a6182718e1188e185b18ad188a18c918c2183d18a5181918c518a8182118e6182e18bb1850183f18d9188b18b41218ca18c9185d18c2184b18d8184a189c18bd15181e186d186818ce183d18981718f9182d18c3186e18c500188718fa1218291899181d183818fb18b60b1871188d184118dd0a185d189918ff1858183018e6182318c21889185a18c818e1187c18c5184e183818c163707562982018921821184418e818ce18bd18bf18eb18c1187e04189b181818a8185a02183118f7185f182a183a186f18b7182e1846185818c3187c18941826189818ea6d726f6f74436861696e436f64659820188718fa1218291899181d183818fb18b60b1871188d184118dd0a185d189918ff1858183018e6182318c21889185a18c818e1187c18c5184e183818c1'; + +describe('MPS reduceKeyShare', function () { + it('reduces the full-share fixture to the golden bytes produced before extraction', function () { + const actual = MPSTypes.reduceMpsKeyShare(Buffer.from(FULL_KEY_SHARE_HEX, 'hex'), COMMON_KEYCHAIN); + assert.strictEqual(actual.toString('hex'), REDUCED_KEY_SHARE_HEX); + }); + + it('DKG.getReducedKeyShare delegates to reduceMpsKeyShare for a live DKG', async function () { + for (const party of await MPSUtil.generateEdDsaDKGKeyShares()) { + const actual = MPSTypes.reduceMpsKeyShare(party.getKeyShare(), party.getCommonKeychain()); + assert(actual.equals(party.getReducedKeyShare())); + } + }); +}); diff --git a/modules/sdk-lib-safes/src/codecs.ts b/modules/sdk-lib-safes/src/codecs.ts index 6c4264513b6..fd910b2369b 100644 --- a/modules/sdk-lib-safes/src/codecs.ts +++ b/modules/sdk-lib-safes/src/codecs.ts @@ -3,6 +3,7 @@ */ import * as E from 'fp-ts/Either'; import * as t from 'io-ts'; +import { PathReporter } from 'io-ts/lib/PathReporter'; /** Decode with an io-ts codec, throwing on failure. Mirrors sdk-core's utils/codecs.decodeWithCodec. */ export function decodeWithCodec(codec: t.Type, input: unknown, label: string): A { @@ -13,3 +14,50 @@ export function decodeWithCodec(codec: t.Type, input: un } return result.right; } + +/** + * Validates raw JSON against an io-ts codec plus a closed set of allowed keys. + * + * The input must be a plain object whose keys are all members of `knownKeys` (unknown fields + * are rejected) and must decode successfully against `codec`. The result is a new object whose + * keys are ordered by `knownKeys` (canonical/deterministic order). Intended for "declared-subset" + * payloads where the object self-identifies which fields it carries and any absent field is + * simply omitted. Empty objects are allowed; callers that require at least one field must enforce + * that themselves. + */ +export function validateJSONAgainstCodec, K extends string>( + raw: unknown, + codec: t.Type, + knownKeys: ReadonlyArray, + label: string +): A { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error(`${label}: expected an object`); + } + + const record = raw as Record; + const knownKeysSet = new Set(knownKeys); + for (const key of Object.keys(record)) { + if (!knownKeysSet.has(key)) { + throw new Error(`${label}: unknown field ${key}`); + } + } + + const decoded = codec.decode(record); + if (E.isLeft(decoded)) { + throw new Error(`${label}: ${PathReporter.report(decoded).join('; ')}`); + } + + // Reconstruct in canonical/deterministic order so any direct serialization of the + // returned object preserves the exact key ordering declared by `knownKeys`. + const ordered = {} as A; + const present = decoded.right; + for (const key of knownKeys) { + const prop = key as unknown as keyof A; + if (prop in present) { + ordered[prop] = present[prop]; + } + } + + return ordered; +} diff --git a/modules/sdk-lib-safes/src/index.ts b/modules/sdk-lib-safes/src/index.ts index 9b41befe0ab..7b5313a1315 100644 --- a/modules/sdk-lib-safes/src/index.ts +++ b/modules/sdk-lib-safes/src/index.ts @@ -3,6 +3,7 @@ * * @experimental Pure Safe derivation and keycard box codec leaf. Never imports sdk-core/BaseCoin/Wallet/Safe/key-card. */ +export { validateJSONAgainstCodec } from './codecs'; export * from './ed25519KeyDeriver'; export * from './ed25519Pub'; export * from './keycardCodec'; diff --git a/modules/sdk-lib-safes/src/keycardCodec.ts b/modules/sdk-lib-safes/src/keycardCodec.ts index 2e5825954b3..5c38b8f2c41 100644 --- a/modules/sdk-lib-safes/src/keycardCodec.ts +++ b/modules/sdk-lib-safes/src/keycardCodec.ts @@ -6,12 +6,16 @@ * Each of Boxes A/B/C on a Safe keycard is a JSON object mapping a root slot name to that slot's key * string. It lives in this leaf so sdk-core and WRW can parse a box without depending on key-card, * which pulls in PDF/QR libraries and itself depends on sdk-core. + * + * A box self-identifies which slots it carries via its keys; any non-empty subset of the four root + * slots is accepted — a Safe created with only some root slots enabled can still be read back. */ import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { JsonFromString } from 'io-ts-types'; import type { RootKeyType } from '@bitgo/public-types'; +import { validateJSONAgainstCodec } from './codecs'; /** * Identifier for one of a safe's four roots (one per signing scheme). Each value is the @@ -20,47 +24,59 @@ import type { RootKeyType } from '@bitgo/public-types'; export type SafeRootKeyType = RootKeyType; /** - * The JSON object encoded in a safe keycard box (A/B/C): the four roots keyed by + * The JSON object encoded in a safe keycard box (A/B/C): 0–4 roots keyed by * {@link SafeRootKeyType}. Values are per-root ciphertext for A/B (encryptedPrv or * reducedEncryptedPrv; safe MPC ciphertext decrypts to a versioned signing+VRF envelope) or - * public keys for C. The root-key-type keys are self-identifying, so a - * consumer parses by key rather than by size/offset. Box B's `ed25519Multisig` entry carries a - * `{ prv, chainCode }` object (the chain code is independent randomness not recomputable from the - * seed); every other entry is a bare string. + * public keys for C. Any non-empty subset of the four slots is valid, matching partial-slot + * Safes right after creation (enabledRootSlots). Box B's `ed25519Multisig` entry carries a + * `{ prv, chainCode }` object (the chain code is independent randomness not recomputable from + * the seed); every other entry is a bare string. */ export const Ed25519ChainCodeEntryCodec = t.type({ prv: t.string, chainCode: t.string }); export type Ed25519ChainCodeEntry = t.TypeOf; export type SafeKeycardRootEntry = string | Ed25519ChainCodeEntry; -export type SafeKeycardRoots = Record; +export type SafeKeycardRoots = Partial>; const SafeKeycardRootEntryCodec: t.Type = t.union([t.string, Ed25519ChainCodeEntryCodec]); -// Annotating with SafeKeycardRoots makes the compiler reject a key added to or removed from one side only. -const SafeKeycardRootsCodec: t.Type = t.type({ +const safeRootSlotCodecs = { secp256k1Multisig: SafeKeycardRootEntryCodec, ecdsaMpc: SafeKeycardRootEntryCodec, eddsaMpc: SafeKeycardRootEntryCodec, ed25519Multisig: SafeKeycardRootEntryCodec, -}); +} satisfies Record>; + +// Canonical slot order shared with key-card's SAFE_ROOT_ORDER; boxes written by +// generateSafeQrData are keyed in this order, so byte-for-byte round-trips preserve it. +const SAFE_ROOT_SLOT_ORDER = Object.keys(safeRootSlotCodecs) as SafeRootKeyType[]; -// Decodes a box's JSON string straight into the validated roots record. -const SafeKeycardBoxFromString = JsonFromString.pipe(SafeKeycardRootsCodec); +const SafeKeycardRootsCodec: t.Type = t.partial(safeRootSlotCodecs); /** * Parses a safe keycard box value — the JSON packed by key-card's `generateSafeQrData`, e.g. - * `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Throws if the value is - * not valid JSON or any root is not a bare string or a `{ prv, chainCode }` entry. Recovery tooling calls this on the A/B/C - * box value, then decrypts each root value with the safe password. An MPC root value is an - * opaque versioned envelope; recovery must unwrap its `prvKeyShare` and `vrf` fields instead - * of treating the decrypted bytes as a bare share. + * `{"secp256k1Multisig":"…","ed25519Multisig":"…"}` — into the root slots the box declares. + * Throws if the value is not valid JSON, the box carries no root slots, it names an unknown + * root key, or a root value is not a bare string or a `{ prv, chainCode }` entry. Recovery + * tooling calls this on the A/B/C box value, then decrypts each present root with the safe + * password. An MPC root value is an opaque versioned envelope; recovery must unwrap its + * `prvKeyShare` and `vrf` fields instead of treating the decrypted bytes as a bare share. */ export function parseSafeKeycardBox(data: string): SafeKeycardRoots { - const decoded = SafeKeycardBoxFromString.decode(data); + const decoded = JsonFromString.decode(data); if (E.isLeft(decoded)) { // PathReporter, not decodeWithCodec: io-ts errors carry no message, so decodeWithCodec would report only "unknown". throw new Error(`parseSafeKeycardBox: ${PathReporter.report(decoded).join('; ')}`); } - return decoded.right; + const roots = validateJSONAgainstCodec( + decoded.right, + SafeKeycardRootsCodec, + SAFE_ROOT_SLOT_ORDER, + 'parseSafeKeycardBox' + ); + if (Object.keys(roots).length === 0) { + throw new Error('parseSafeKeycardBox: empty slot set'); + } + return roots; } diff --git a/modules/sdk-lib-safes/test/unit/codecs.ts b/modules/sdk-lib-safes/test/unit/codecs.ts new file mode 100644 index 00000000000..d33327a5468 --- /dev/null +++ b/modules/sdk-lib-safes/test/unit/codecs.ts @@ -0,0 +1,50 @@ +import 'should'; +import * as t from 'io-ts'; +import { validateJSONAgainstCodec } from '../../src'; + +describe('validateJSONAgainstCodec', function () { + type Field = 'alpha' | 'beta' | 'gamma'; + const KNOWN: ReadonlyArray = ['alpha', 'beta', 'gamma']; + const codec: t.Type>, Partial>, unknown> = t.partial({ + alpha: t.string, + beta: t.string, + gamma: t.string, + }); + + it('returns a subset of keys in canonical order, even when input is out of order', function () { + const result = validateJSONAgainstCodec({ gamma: 'g', alpha: 'a' }, codec, KNOWN, 'test'); + Object.keys(result).should.deepEqual(['alpha', 'gamma']); + result.should.deepEqual({ alpha: 'a', gamma: 'g' }); + }); + + it('round-trips a full object byte-for-byte', function () { + const obj = { alpha: 'a', beta: 'b', gamma: 'c' }; + JSON.stringify(validateJSONAgainstCodec(obj, codec, KNOWN, 'test')).should.equal(JSON.stringify(obj)); + }); + + it('allows an empty object (non-empty is enforced by callers)', function () { + validateJSONAgainstCodec({}, codec, KNOWN, 'test').should.deepEqual({}); + }); + + it('rejects an unknown field, naming it', function () { + (() => validateJSONAgainstCodec({ alpha: 'a', bogus: 'x' }, codec, KNOWN, 'test')).should.throw( + /unknown field bogus/ + ); + }); + + it('rejects a prototype-key field', function () { + const raw = JSON.parse('{"__proto__":"x"}'); + (() => validateJSONAgainstCodec(raw, codec, KNOWN, 'test')).should.throw(/unknown field __proto__/); + }); + + it('rejects non-object input', function () { + (() => validateJSONAgainstCodec('str', codec, KNOWN, 'test')).should.throw(/expected an object/); + (() => validateJSONAgainstCodec(42, codec, KNOWN, 'test')).should.throw(/expected an object/); + (() => validateJSONAgainstCodec(null, codec, KNOWN, 'test')).should.throw(/expected an object/); + (() => validateJSONAgainstCodec(['a'], codec, KNOWN, 'test')).should.throw(/expected an object/); + }); + + it('rejects a value that fails the codec', function () { + (() => validateJSONAgainstCodec({ alpha: 123 }, codec, KNOWN, 'test')).should.throw(/alpha/); + }); +}); diff --git a/modules/sdk-lib-safes/test/unit/keycardCodec.ts b/modules/sdk-lib-safes/test/unit/keycardCodec.ts index 1dde073689b..48967a32400 100644 --- a/modules/sdk-lib-safes/test/unit/keycardCodec.ts +++ b/modules/sdk-lib-safes/test/unit/keycardCodec.ts @@ -10,8 +10,29 @@ describe('keycardCodec', function () { ed25519Multisig: 'd', }; - it('decodes a box carrying all four roots', function () { - parseSafeKeycardBox(JSON.stringify(fullBox)).should.deepEqual(fullBox); + // For byte-for-byte round trips the input must be keyed in canonical order (SafeKeycardRoots codec + // order == key-card's SAFE_ROOT_ORDER), which is how generateSafeQrData always writes boxes. + + const SUBSETS: Array<[string, SafeKeycardRoots]> = [ + ['{"secp256k1Multisig":"a"}', { secp256k1Multisig: 'a' }], + ['{"secp256k1Multisig":"a","ecdsaMpc":"b"}', { secp256k1Multisig: 'a', ecdsaMpc: 'b' }], + [ + '{"secp256k1Multisig":"a","ecdsaMpc":"b","eddsaMpc":"c"}', + { secp256k1Multisig: 'a', ecdsaMpc: 'b', eddsaMpc: 'c' }, + ], + ]; + + it('decodes a box carrying all four roots, byte-for-byte', function () { + const box = JSON.stringify(fullBox); + parseSafeKeycardBox(box).should.deepEqual(fullBox); + JSON.stringify(parseSafeKeycardBox(box)).should.equal(box); + }); + + SUBSETS.forEach(([box, expected]) => { + it(`decodes a partial box ${box}, byte-for-byte`, function () { + parseSafeKeycardBox(box).should.deepEqual(expected); + JSON.stringify(parseSafeKeycardBox(box)).should.equal(box); + }); }); it('rejects a box that is not valid JSON', function () { @@ -22,11 +43,12 @@ describe('keycardCodec', function () { (() => parseSafeKeycardBox('"a string"')).should.throw(/parseSafeKeycardBox/); }); - (Object.keys(fullBox) as Array).forEach((slot) => { - it(`rejects a box missing ${slot} and names it in the error`, function () { - const withoutSlot = Object.fromEntries(Object.entries(fullBox).filter(([key]) => key !== slot)); - (() => parseSafeKeycardBox(JSON.stringify(withoutSlot))).should.throw(new RegExp(slot)); - }); + it('rejects an empty slot set', function () { + (() => parseSafeKeycardBox('{}')).should.throw(/empty slot set/); + }); + + it('rejects an unknown field, naming it', function () { + (() => parseSafeKeycardBox('{"secp256k1Multisig":"a","unknownRoot":"b"}')).should.throw(/unknownRoot/); }); it('rejects a root whose value is neither a string nor a derivable entry', function () { @@ -38,5 +60,10 @@ describe('keycardCodec', function () { const mixed = { ...fullBox, ed25519Multisig: entry }; parseSafeKeycardBox(JSON.stringify(mixed)).ed25519Multisig.should.deepEqual(entry); }); + + it('accepts a derivable ed25519 entry inside a partial box', function () { + const entry = { prv: 'enc-seed', chainCode: 'A'.repeat(52) }; + parseSafeKeycardBox(JSON.stringify({ ed25519Multisig: entry })).should.deepEqual({ ed25519Multisig: entry }); + }); }); });