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
19 changes: 17 additions & 2 deletions modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseCoin } from '@bitgo/statics';
import { HIGH_ENTROPY_ENCRYPTION_VERSION, Keychain, KeychainsTriplet } from '@bitgo/sdk-core';
import { encrypt } from '@bitgo/sdk-api';
import { decodeDerivableEd25519Pub, encodeEd25519ChainCode, type SafeKeycardRootEntry } from '@bitgo/sdk-lib-safes';
import * as assert from 'assert';
import {
GenerateLightningQrDataParams,
Expand Down Expand Up @@ -225,6 +226,20 @@ function selectRootPrivateKey(keychain: Keychain, slot: SafeRootKeyType, role: '
return data;
}

// Box B carries the backup's ed25519 chain code alongside the encrypted seed for slot ④ only —
// the chain code is independent randomness and cannot be recomputed from the seed, unlike the
// slot-① xprv whose chain code rides inside the ciphertext. Every other slot (and boxes A/C
// everywhere) stays a bare string.
function selectBackupRootEntry(keychain: Keychain, slot: SafeRootKeyType): SafeKeycardRootEntry {
const prv = selectRootPrivateKey(keychain, slot, 'backup');
if (slot !== 'ed25519Multisig') {
return prv;
}
assert.ok(keychain.pub, `Safe backup root ${slot} is missing a public key`);
const { chainCode } = decodeDerivableEd25519Pub(keychain.pub);
return { prv, chainCode: encodeEd25519ChainCode(chainCode) };
}

function selectRootPublicKey(keychain: Keychain, slot: SafeRootKeyType): string {
const pub = getPubFromKey(keychain);
assert.ok(pub, `Safe BitGo root ${slot} is missing a public key`);
Expand All @@ -240,7 +255,7 @@ function selectRootPublicKey(keychain: Keychain, slot: SafeRootKeyType): string
*/
function buildSafeBoxData(
roots: Record<SafeRootKeyType, KeychainsTriplet>,
select: (root: KeychainsTriplet, slot: SafeRootKeyType) => string
select: (root: KeychainsTriplet, slot: SafeRootKeyType) => SafeKeycardRootEntry
): string {
const box: SafeKeycardRoots = {} as SafeKeycardRoots;
for (const slot of SAFE_ROOT_ORDER) {
Expand All @@ -267,7 +282,7 @@ export async function generateSafeQrData(params: GenerateSafeQrDataParams): Prom
backup: {
title: 'B: Backup Key',
description: 'Your 4 root backup keys, encrypted with your safe password.',
data: buildSafeBoxData(roots, (root, slot) => selectRootPrivateKey(root.backupKeychain, slot, 'backup')),
data: buildSafeBoxData(roots, (root, slot) => selectBackupRootEntry(root.backupKeychain, slot)),
},
bitgo: {
title: 'C: BitGo Key',
Expand Down
32 changes: 27 additions & 5 deletions modules/key-card/test/unit/safeQrData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'should';
import * as assert from 'assert';
import { randomBytes } from 'crypto';
import { decrypt, encrypt } from '@bitgo/sdk-api';
import { coins } from '@bitgo/statics';
import { encodeDerivableEd25519Pub, generateEd25519ChainCode } from '@bitgo/sdk-lib-safes';
import {
Keychain,
KeychainsTriplet,
Expand Down Expand Up @@ -82,6 +84,10 @@ async function buildRoots(): Promise<{
};
}

// slot ④'s backup pub is a real derivable composite (publicKey32 ‖ chainCode32) so that
// selectBackupRootEntry can decode the chain code it stores alongside the seed in box B.
roots.ed25519Multisig.backupKeychain.pub = encodeDerivableEd25519Pub(randomBytes(32), generateEd25519ChainCode());

return { roots, plaintexts, bitgoPubs };
}

Expand Down Expand Up @@ -119,10 +125,26 @@ describe('generateSafeQrData', function () {
const bitgoBox = parseSafeKeycardBox(reassemble(qrData.bitgo.data));

for (const slot of SAFE_ROOT_ORDER) {
(await decrypt(passphrase, userBox[slot])).should.equal(plaintexts[slot].user);
(await decrypt(passphrase, backupBox[slot])).should.equal(plaintexts[slot].backup);
(await decrypt(passphrase, userBox[slot] as string)).should.equal(plaintexts[slot].user);
const backupEntry = backupBox[slot];
const backupPrv = typeof backupEntry === 'string' ? backupEntry : backupEntry.prv;
(await decrypt(passphrase, backupPrv)).should.equal(plaintexts[slot].backup);
bitgoBox[slot].should.equal(bitgoPubs[slot]);
}

// Box B's slot-④ entry carries the chain code next to the seed (only slot ④ is an object).
const ed25519Backup = backupBox.ed25519Multisig;
assert.ok(typeof ed25519Backup === 'object');
if (typeof ed25519Backup === 'object') {
ed25519Backup.chainCode.should.match(/^[A-Z2-7]{52}$/);
// The seed is byte-identical to the un-widened encrypted value (chain code NOT appended).
ed25519Backup.prv.should.equal(roots.ed25519Multisig.backupKeychain.encryptedPrv);
}

// The other three box-B slots stay bare strings.
backupBox.secp256k1Multisig.should.be.a.String();
backupBox.ecdsaMpc.should.be.a.String();
backupBox.eddsaMpc.should.be.a.String();
});

it('preserves the versioned VRF envelope in the MPC root card blob', async function () {
Expand All @@ -136,7 +158,7 @@ describe('generateSafeQrData', function () {

const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots });
const userBox = parseSafeKeycardBox(qrData.user.data);
const decoded = parseMpcV2KeyShareEnvelope(await decrypt(passphrase, userBox.ecdsaMpc));
const decoded = parseMpcV2KeyShareEnvelope(await decrypt(passphrase, userBox.ecdsaMpc as string));

decoded.signingKeyShare.toString().should.equal('reduced-signing-share');
assert.ok(decoded.vrfKeyShare);
Expand All @@ -160,7 +182,7 @@ describe('generateSafeQrData', function () {
fragments.every((fragment) => fragment.length <= QRBinaryMaxLength).should.equal(true);

const parsed = parseMpcV2KeyShareEnvelope(
await decrypt(passphrase, parseSafeKeycardBox(reassemble(qrData.user.data)).ecdsaMpc)
await decrypt(passphrase, parseSafeKeycardBox(reassemble(qrData.user.data)).ecdsaMpc as string)
);
parsed.signingKeyShare.length.should.equal(606);
assert.ok(parsed.vrfKeyShare);
Expand Down Expand Up @@ -233,7 +255,7 @@ describe('generateSafeQrData', function () {
assert.ok(userEntry);
const userBox = parseSafeKeycardBox(userEntry.value);
for (const slot of SAFE_ROOT_ORDER) {
(await decrypt(passphrase, userBox[slot])).should.equal(plaintexts[slot].user);
(await decrypt(passphrase, userBox[slot] as string)).should.equal(plaintexts[slot].user);
}
});

Expand Down
50 changes: 36 additions & 14 deletions modules/sdk-core/src/bitgo/safe/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { randomBytes } from 'crypto';
import { bip32 } from '@bitgo/utxo-lib';
import { coins, MAX_BIP32_INDEX, SAFE_ROOT_SLOT_ORDINALS } from '@bitgo/statics';
import type { RootKeyType } from '@bitgo/public-types';
import { parseSafeKeycardBox } from '@bitgo/sdk-lib-safes';
import { Ed25519ChainCodeEntryCodec, parseSafeKeycardBox, type SafeKeycardRootEntry } from '@bitgo/sdk-lib-safes';
import { DklsVrfUtils, MpsVrfUtils } from '@bitgo/sdk-lib-mpc';
import { BitGoBase } from '../bitgoBase';
import { IncorrectPasswordError } from '../errors';
Expand All @@ -18,6 +18,11 @@ export type DecryptedMpcSafeRoot = { signing: Buffer; vrf: Buffer };

export type OnchainSafeRoot = { user: string; backup: string; bitgo: string };
export type MpcSafeRoot = { user: DecryptedMpcSafeRoot; backup: DecryptedMpcSafeRoot; bitgo: string };
export type Ed25519MultisigSafeRoot = {
user: string;
backup: { prv: string; chainCode: string };
bitgo: string;
};

export type Secp256k1MultisigChildKeys = {
user: { prv: string; pub: string };
Expand Down Expand Up @@ -47,7 +52,7 @@ export type SafeRecoverKeyParams = {

export type DecryptedSafeRoots = {
secp256k1Multisig: OnchainSafeRoot;
ed25519Multisig: OnchainSafeRoot;
ed25519Multisig: Ed25519MultisigSafeRoot;
ecdsaMpc: MpcSafeRoot;
eddsaMpc: MpcSafeRoot;
};
Expand Down Expand Up @@ -81,26 +86,43 @@ export async function decryptSafeKeycard(params: {
}
};

// Only box B's ed25519Multisig slot may carry the derivable `{ prv, chainCode }` object; every
// other entry — all of boxes A and C, and every non-ed25519 slot in box B — is a bare string and
// fails closed if it carries the object instead.
const requireBareString = (entry: SafeKeycardRootEntry): string => {
if (Ed25519ChainCodeEntryCodec.is(entry)) {
throw new Error('unexpected ed25519 chain-code entry where a bare string was required');
}
return entry;
};

const decryptEd25519Backup = async (entry: SafeKeycardRootEntry): Promise<{ prv: string; chainCode: string }> => {
if (!Ed25519ChainCodeEntryCodec.is(entry)) {
throw new Error('ed25519 backup root must carry a chain code');
}
return { prv: await decryptRoot(entry.prv), chainCode: entry.chainCode };
};

return {
secp256k1Multisig: {
user: await decryptRoot(userRoots.secp256k1Multisig),
backup: await decryptRoot(backupRoots.secp256k1Multisig),
bitgo: bitgoRoots.secp256k1Multisig,
user: await decryptRoot(requireBareString(userRoots.secp256k1Multisig)),
backup: await decryptRoot(requireBareString(backupRoots.secp256k1Multisig)),
bitgo: requireBareString(bitgoRoots.secp256k1Multisig),
},
ed25519Multisig: {
user: await decryptRoot(userRoots.ed25519Multisig),
backup: await decryptRoot(backupRoots.ed25519Multisig),
bitgo: bitgoRoots.ed25519Multisig,
user: await decryptRoot(requireBareString(userRoots.ed25519Multisig)),
backup: await decryptEd25519Backup(backupRoots.ed25519Multisig),
bitgo: requireBareString(bitgoRoots.ed25519Multisig),
},
ecdsaMpc: {
user: unwrapMpcRoot(await decryptRoot(userRoots.ecdsaMpc)),
backup: unwrapMpcRoot(await decryptRoot(backupRoots.ecdsaMpc)),
bitgo: bitgoRoots.ecdsaMpc,
user: unwrapMpcRoot(await decryptRoot(requireBareString(userRoots.ecdsaMpc))),
backup: unwrapMpcRoot(await decryptRoot(requireBareString(backupRoots.ecdsaMpc))),
bitgo: requireBareString(bitgoRoots.ecdsaMpc),
},
eddsaMpc: {
user: unwrapMpcRoot(await decryptRoot(userRoots.eddsaMpc)),
backup: unwrapMpcRoot(await decryptRoot(backupRoots.eddsaMpc)),
bitgo: bitgoRoots.eddsaMpc,
user: unwrapMpcRoot(await decryptRoot(requireBareString(userRoots.eddsaMpc))),
backup: unwrapMpcRoot(await decryptRoot(requireBareString(backupRoots.eddsaMpc))),
bitgo: requireBareString(bitgoRoots.eddsaMpc),
},
};
}
Expand Down
82 changes: 78 additions & 4 deletions modules/sdk-core/test/unit/bitgo/safe/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function makeDecryptMap(entries: Record<string, string>): (params: { password: s

function box(values: {
secp256k1Multisig: string;
ed25519Multisig: string;
ed25519Multisig: string | { prv: string; chainCode: string };
ecdsaMpc: string;
eddsaMpc: string;
}): string {
Expand All @@ -59,7 +59,7 @@ describe('decryptSafeKeycard', () => {
});
const backupBox = box({
secp256k1Multisig: 'enc-backup-secp',
ed25519Multisig: 'enc-backup-ed25519',
ed25519Multisig: { prv: 'enc-backup-ed25519', chainCode: 'C'.repeat(52) },
ecdsaMpc: 'enc-backup-ecdsa',
eddsaMpc: 'enc-backup-eddsa',
});
Expand Down Expand Up @@ -107,7 +107,8 @@ describe('decryptSafeKeycard', () => {
result.secp256k1Multisig.bitgo.should.equal('xpub-secp-bitgo');

result.ed25519Multisig.user.should.equal('S-user-seed');
result.ed25519Multisig.backup.should.equal('S-backup-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'));
Expand All @@ -122,6 +123,79 @@ describe('decryptSafeKeycard', () => {
(bitgo.decrypt as sinon.SinonStub).callCount.should.equal(8);
});

it('decrypts the seed from an object-form ed25519Multisig backup entry', async () => {
const userBox = box({
secp256k1Multisig: 'enc-user-secp',
ed25519Multisig: 'enc-user-ed25519',
ecdsaMpc: 'enc-user-ecdsa',
eddsaMpc: 'enc-user-eddsa',
});
const backupBox = JSON.stringify({
secp256k1Multisig: 'enc-backup-secp',
ed25519Multisig: { prv: 'enc-backup-ed25519', chainCode: 'A'.repeat(52) },
ecdsaMpc: 'enc-backup-ecdsa',
eddsaMpc: 'enc-backup-eddsa',
});
const bitgoBox = box({
secp256k1Multisig: 'xpub-secp-bitgo',
ed25519Multisig: 'pub-ed25519-bitgo',
ecdsaMpc: 'common-ecdsa-bitgo',
eddsaMpc: 'common-eddsa-bitgo',
});

const mpcUser = buildSafeMpcKeyEnvelopes(
Buffer.from('user-signing'),
Buffer.from('user-reduced'),
Buffer.from('user-vrf')
);
const mpcBackup = buildSafeMpcKeyEnvelopes(
Buffer.from('backup-signing'),
Buffer.from('backup-reduced'),
Buffer.from('backup-vrf')
);

const bitgo = makeMockBitGo(
makeDecryptMap({
'enc-user-secp': 'xprv-user',
'enc-backup-secp': 'xprv-backup',
'enc-user-ed25519': 'S-user-seed',
'enc-backup-ed25519': 'S-backup-seed',
'enc-user-ecdsa': mpcUser.reducedEnvelope.toString('base64'),
'enc-backup-ecdsa': mpcBackup.reducedEnvelope.toString('base64'),
'enc-user-eddsa': mpcUser.reducedEnvelope.toString('base64'),
'enc-backup-eddsa': mpcBackup.reducedEnvelope.toString('base64'),
})
);

const result = await decryptSafeKeycard({
bitgo,
userKeyBox: userBox,
backupKeyBox: backupBox,
bitgoKeyBox: bitgoBox,
password: PASSWORD,
});

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 () => {
const userBox = JSON.stringify({
secp256k1Multisig: { prv: 'enc-user-secp', chainCode: 'A'.repeat(52) },
ed25519Multisig: 'enc-user-ed25519',
ecdsaMpc: 'enc-user-ecdsa',
eddsaMpc: 'enc-user-eddsa',
});

await decryptSafeKeycard({
bitgo: makeMockBitGo(makeDecryptMap({})),
userKeyBox: userBox,
backupKeyBox: userBox,
bitgoKeyBox: userBox,
password: PASSWORD,
}).should.be.rejectedWith(/unexpected ed25519 chain-code entry/);
});

it('throws IncorrectPasswordError when decryption fails (wrong password)', async () => {
const userBox = box({
secp256k1Multisig: 'enc-user-secp',
Expand All @@ -148,7 +222,7 @@ const MPC_ROOT = { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) };
function makeRoots(secp: { user: string; backup: string; bitgo: string }): DecryptedSafeRoots {
return {
secp256k1Multisig: secp,
ed25519Multisig: { user: 'u', backup: 'b', bitgo: 'g' },
ed25519Multisig: { user: 'u', backup: { prv: 'b', chainCode: 'c' }, bitgo: 'g' },
ecdsaMpc: { user: MPC_ROOT, backup: MPC_ROOT, bitgo: 'g' },
eddsaMpc: { user: MPC_ROOT, backup: MPC_ROOT, bitgo: 'g' },
};
Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-lib-safes/src/ed25519Pub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ export function generateEd25519ChainCode(): Buffer {
return randomBytes(DERIVABLE_ED25519_CHAIN_CODE_BYTES);
}

/** Encode a raw 32-byte chain code as canonical unpadded RFC 4648 base32 (52 characters). */
export function encodeEd25519ChainCode(chainCode: Buffer): string {
if (chainCode.length !== DERIVABLE_ED25519_CHAIN_CODE_BYTES) {
throw new Error('Invalid ed25519 chain code: expected 32 bytes');
}
return base32Encode(chainCode);
}

export function decodeEd25519StrKeyPublicKey(pub: string): Buffer {
if (!STRKEY_PUBLIC_KEY_REGEX.test(pub)) {
throw new Error('Invalid ed25519 StrKey public key');
Expand Down
23 changes: 16 additions & 7 deletions modules/sdk-lib-safes/src/keycardCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,25 @@ export type SafeRootKeyType = RootKeyType;
* {@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.
* 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.
*/
export type SafeKeycardRoots = Record<SafeRootKeyType, string>;
export const Ed25519ChainCodeEntryCodec = t.type({ prv: t.string, chainCode: t.string });
export type Ed25519ChainCodeEntry = t.TypeOf<typeof Ed25519ChainCodeEntryCodec>;

export type SafeKeycardRootEntry = string | Ed25519ChainCodeEntry;

export type SafeKeycardRoots = Record<SafeRootKeyType, SafeKeycardRootEntry>;

const SafeKeycardRootEntryCodec: t.Type<SafeKeycardRootEntry> = 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<SafeKeycardRoots> = t.type({
secp256k1Multisig: t.string,
ecdsaMpc: t.string,
eddsaMpc: t.string,
ed25519Multisig: t.string,
secp256k1Multisig: SafeKeycardRootEntryCodec,
ecdsaMpc: SafeKeycardRootEntryCodec,
eddsaMpc: SafeKeycardRootEntryCodec,
ed25519Multisig: SafeKeycardRootEntryCodec,
});

// Decodes a box's JSON string straight into the validated roots record.
Expand All @@ -42,7 +51,7 @@ 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
* 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.
Expand Down
Loading
Loading