Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions modules/key-card/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 1 addition & 33 deletions modules/key-card/src/parseKeycard.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<SafeKeycardRoots> = 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;
Expand Down
26 changes: 11 additions & 15 deletions modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<SafeRootKeyType, true>;

/**
* 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<SafeRootKeyType, string>;
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';
Expand Down
8 changes: 8 additions & 0 deletions modules/key-card/test/unit/types.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
3 changes: 3 additions & 0 deletions modules/key-card/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
},
{
"path": "../sdk-core"
},
{
"path": "../sdk-lib-safes"
}
]
}
3 changes: 2 additions & 1 deletion modules/sdk-lib-safes/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion modules/sdk-lib-safes/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
57 changes: 57 additions & 0 deletions modules/sdk-lib-safes/src/keycardCodec.ts
Original file line number Diff line number Diff line change
@@ -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<SafeRootKeyType, string>;

// Annotating with SafeKeycardRoots makes the compiler reject a key added to or removed from one side only.
const SafeKeycardRootsCodec: t.Type<SafeKeycardRoots> = 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;
}
36 changes: 36 additions & 0 deletions modules/sdk-lib-safes/test/unit/keycardCodec.ts
Original file line number Diff line number Diff line change
@@ -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<keyof SafeKeycardRoots>).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/);
});
});
});
Loading