From 32e7234f02175117c63f5eec9d70c62f7669fbf0 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Fri, 2 Oct 2026 11:43:38 -0400 Subject: [PATCH] feat: move safe keycard box codec from key-card Moves parseSafeKeycardBox, SafeKeycardRoots and SafeRootKeyType into sdk-lib-safes so decryptSafeKeycard can use them without a key-card -> sdk-core -> sdk-lib-safes cycle. key-card re-exports them, so its public API and behaviour are unchanged. Also derives key-card's SAFE_ROOT_ORDER from a satisfies-checked Record so a missing or extra slot is a compile error. TICKET: WCN-2734 --- modules/key-card/package.json | 4 +- modules/key-card/src/parseKeycard.ts | 34 +---------- modules/key-card/src/types.ts | 26 ++++----- modules/key-card/test/unit/types.ts | 8 +++ modules/key-card/tsconfig.json | 3 + modules/sdk-lib-safes/package.json | 3 +- modules/sdk-lib-safes/src/index.ts | 3 +- modules/sdk-lib-safes/src/keycardCodec.ts | 57 +++++++++++++++++++ .../sdk-lib-safes/test/unit/keycardCodec.ts | 36 ++++++++++++ 9 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 modules/key-card/test/unit/types.ts create mode 100644 modules/sdk-lib-safes/src/keycardCodec.ts create mode 100644 modules/sdk-lib-safes/test/unit/keycardCodec.ts diff --git a/modules/key-card/package.json b/modules/key-card/package.json index 1615983dfa1..1d68447c922 100644 --- a/modules/key-card/package.json +++ b/modules/key-card/package.json @@ -35,10 +35,8 @@ "dependencies": { "@bitgo/sdk-api": "^3.0.5", "@bitgo/sdk-core": "^38.21.0", + "@bitgo/sdk-lib-safes": "^1.2.1", "@bitgo/statics": "^59.20.0", - "fp-ts": "^2.16.2", - "io-ts": "npm:@bitgo-forks/io-ts@2.1.4", - "io-ts-types": "^0.5.19", "jspdf": ">=4.2.0", "pdfjs-dist": "^4.0.0", "qrcode": "^1.5.1" diff --git a/modules/key-card/src/parseKeycard.ts b/modules/key-card/src/parseKeycard.ts index 498dc337c44..41b50c560a5 100644 --- a/modules/key-card/src/parseKeycard.ts +++ b/modules/key-card/src/parseKeycard.ts @@ -1,8 +1,4 @@ -import * as t from 'io-ts'; -import { isLeft } from 'fp-ts/Either'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { JsonFromString } from 'io-ts-types'; -import { SafeKeycardRoots } from './types'; +export { parseSafeKeycardBox } from '@bitgo/sdk-lib-safes'; export type PDFTextNode = { text: string; @@ -17,34 +13,6 @@ export type KeycardEntry = { value: string; }; -// A safe keycard box is a JSON object mapping each rootKeyType to a string (per-root -// ciphertext for A/B, public key for C). -const SafeKeycardRootsCodec: t.Type = t.type({ - secp256k1Multisig: t.string, - ecdsaMpc: t.string, - eddsaMpc: t.string, - ed25519Multisig: t.string, -}); - -// Decodes a box's JSON string straight into the validated roots record. -const SafeKeycardBoxFromString = JsonFromString.pipe(SafeKeycardRootsCodec); - -/** - * Parses a safe keycard box value — the JSON packed by `generateSafeQrData`, e.g. - * `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Throws if the value is - * not valid JSON or any root is missing/non-string. Recovery tooling calls this on the A/B/C - * box value returned by {@link parseKeycardFromLines}, 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. - */ -export function parseSafeKeycardBox(data: string): SafeKeycardRoots { - const decoded = SafeKeycardBoxFromString.decode(data); - if (isLeft(decoded)) { - throw new Error(`parseSafeKeycardBox: ${PathReporter.report(decoded).join('; ')}`); - } - return decoded.right; -} - const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i; const dataLineRegex = /^data\s*:\s*(.*)$/i; const faqHeaderRegex = /^BitGo\s+KeyCard\s+FAQ$/i; diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 2b69818f6ff..6f57737f039 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -1,5 +1,9 @@ import { EncryptionVersion, Keychain, KeychainsTriplet } from '@bitgo/sdk-core'; import { BaseCoin, KeyCurve } from '@bitgo/statics'; +import type { SafeKeycardRoots, SafeRootKeyType } from '@bitgo/sdk-lib-safes'; + +// Moved to sdk-lib-safes; re-exported so existing imports from @bitgo/key-card keep working. +export type { SafeKeycardRoots, SafeRootKeyType }; export interface GenerateQrDataBaseParams { activationCode?: string; @@ -57,26 +61,18 @@ export interface GenerateLightningQrDataParams extends GenerateQrDataCoinParams userAuthKeychain: Keychain; } -/** - * Identifier for one of a safe's four roots (one per signing scheme). Each value is the - * root's `rootKeyType`, which is also the key used in the keycard's per-box JSON. - */ -export type SafeRootKeyType = 'secp256k1Multisig' | 'ecdsaMpc' | 'eddsaMpc' | 'ed25519Multisig'; +const slotsInRenderOrder = { + secp256k1Multisig: true, + ecdsaMpc: true, + eddsaMpc: true, + ed25519Multisig: true, +} satisfies Record; /** * Fixed render/scan order of the four roots on the safe keycard. Kept stable so a * generated keycard and a re-scanned one line up slot-for-slot. */ -export const SAFE_ROOT_ORDER: SafeRootKeyType[] = ['secp256k1Multisig', 'ecdsaMpc', 'eddsaMpc', 'ed25519Multisig']; - -/** - * The JSON object encoded in a safe keycard box (A/B/C): the four 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. - */ -export type SafeKeycardRoots = Record; +export const SAFE_ROOT_ORDER = Object.keys(slotsInRenderOrder) as SafeRootKeyType[]; /** The product a keycard belongs to, used for user-facing wording (e.g. Box D copy). */ export type KeycardEntity = 'wallet' | 'safe'; diff --git a/modules/key-card/test/unit/types.ts b/modules/key-card/test/unit/types.ts new file mode 100644 index 00000000000..ffbdc75aad1 --- /dev/null +++ b/modules/key-card/test/unit/types.ts @@ -0,0 +1,8 @@ +import 'should'; +import { SAFE_ROOT_ORDER } from '../../src/types'; + +describe('SAFE_ROOT_ORDER', function () { + it('keeps the render order the printed card layout depends on', function () { + SAFE_ROOT_ORDER.should.deepEqual(['secp256k1Multisig', 'ecdsaMpc', 'eddsaMpc', 'ed25519Multisig']); + }); +}); diff --git a/modules/key-card/tsconfig.json b/modules/key-card/tsconfig.json index edecb0aca00..fe889c935c7 100644 --- a/modules/key-card/tsconfig.json +++ b/modules/key-card/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "../sdk-core" + }, + { + "path": "../sdk-lib-safes" } ] } diff --git a/modules/sdk-lib-safes/package.json b/modules/sdk-lib-safes/package.json index d51a53e13c4..f5a7d1d8f2a 100644 --- a/modules/sdk-lib-safes/package.json +++ b/modules/sdk-lib-safes/package.json @@ -1,7 +1,7 @@ { "name": "@bitgo/sdk-lib-safes", "version": "1.2.1", - "description": "Pure Safe key derivation and slot mapping shared by mint, sign, and recovery", + "description": "Pure Safe key derivation, slot mapping and keycard box parsing shared by mint, sign, and recovery", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { @@ -37,6 +37,7 @@ "create-hmac": "^1.1.7", "fp-ts": "^2.12.2", "io-ts": "npm:@bitgo-forks/io-ts@2.1.4", + "io-ts-types": "^0.5.19", "tweetnacl": "^1.0.3" }, "devDependencies": { diff --git a/modules/sdk-lib-safes/src/index.ts b/modules/sdk-lib-safes/src/index.ts index 469c2d73a06..ba69ee86b48 100644 --- a/modules/sdk-lib-safes/src/index.ts +++ b/modules/sdk-lib-safes/src/index.ts @@ -1,10 +1,11 @@ /** * @prettier * - * @experimental Pure Safe derivation leaf. Never imports sdk-core/BaseCoin/Wallet/Safe/key-card. + * @experimental Pure Safe derivation and keycard box codec leaf. Never imports sdk-core/BaseCoin/Wallet/Safe/key-card. */ export * from './ed25519KeyDeriver'; export * from './ed25519Pub'; +export * from './keycardCodec'; export * from './safeDerivation'; export * from './safeRecovery'; export * from './safeSlot'; diff --git a/modules/sdk-lib-safes/src/keycardCodec.ts b/modules/sdk-lib-safes/src/keycardCodec.ts new file mode 100644 index 00000000000..adace7e490b --- /dev/null +++ b/modules/sdk-lib-safes/src/keycardCodec.ts @@ -0,0 +1,57 @@ +/** + * @prettier + * + * @experimental Safe keycard box codec. + * + * 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. + */ +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'; + +/** + * Identifier for one of a safe's four roots (one per signing scheme). Each value is the + * root's `rootKeyType`, which is also the key used in the keycard's per-box JSON. + */ +export type SafeRootKeyType = RootKeyType; + +/** + * The JSON object encoded in a safe keycard box (A/B/C): the four 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. + */ +export type SafeKeycardRoots = Record; + +// Annotating with SafeKeycardRoots makes the compiler reject a key added to or removed from one side only. +const SafeKeycardRootsCodec: t.Type = t.type({ + secp256k1Multisig: t.string, + ecdsaMpc: t.string, + eddsaMpc: t.string, + ed25519Multisig: t.string, +}); + +// Decodes a box's JSON string straight into the validated roots record. +const SafeKeycardBoxFromString = JsonFromString.pipe(SafeKeycardRootsCodec); + +/** + * 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 missing/non-string. 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. + */ +export function parseSafeKeycardBox(data: string): SafeKeycardRoots { + const decoded = SafeKeycardBoxFromString.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; +} diff --git a/modules/sdk-lib-safes/test/unit/keycardCodec.ts b/modules/sdk-lib-safes/test/unit/keycardCodec.ts new file mode 100644 index 00000000000..661d393734f --- /dev/null +++ b/modules/sdk-lib-safes/test/unit/keycardCodec.ts @@ -0,0 +1,36 @@ +import 'should'; +import { parseSafeKeycardBox, SafeKeycardRoots } from '../../src'; + +describe('keycardCodec', function () { + describe('parseSafeKeycardBox', function () { + const fullBox: SafeKeycardRoots = { + secp256k1Multisig: 'a', + ecdsaMpc: 'b', + eddsaMpc: 'c', + ed25519Multisig: 'd', + }; + + it('decodes a box carrying all four roots', function () { + parseSafeKeycardBox(JSON.stringify(fullBox)).should.deepEqual(fullBox); + }); + + it('rejects a box that is not valid JSON', function () { + (() => parseSafeKeycardBox('not json')).should.throw(/parseSafeKeycardBox/); + }); + + it('rejects JSON that is not an object', 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 a root whose value is not a string', function () { + (() => parseSafeKeycardBox(JSON.stringify({ ...fullBox, ecdsaMpc: 12345 }))).should.throw(/ecdsaMpc/); + }); + }); +});