diff --git a/modules/key-card/src/generateQrData.ts b/modules/key-card/src/generateQrData.ts index 12eebb15b53..f27a0571cea 100644 --- a/modules/key-card/src/generateQrData.ts +++ b/modules/key-card/src/generateQrData.ts @@ -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, @@ -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`); @@ -240,7 +255,7 @@ function selectRootPublicKey(keychain: Keychain, slot: SafeRootKeyType): string */ function buildSafeBoxData( roots: Record, - 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) { @@ -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', diff --git a/modules/key-card/test/unit/safeQrData.ts b/modules/key-card/test/unit/safeQrData.ts index 5fe842cd9e0..7c83ec6ecbb 100644 --- a/modules/key-card/test/unit/safeQrData.ts +++ b/modules/key-card/test/unit/safeQrData.ts @@ -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, @@ -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 }; } @@ -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 () { @@ -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); @@ -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); @@ -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); } }); diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index 5034690f8e4..ad0b382bbab 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -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'; @@ -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 }; @@ -47,7 +52,7 @@ export type SafeRecoverKeyParams = { export type DecryptedSafeRoots = { secp256k1Multisig: OnchainSafeRoot; - ed25519Multisig: OnchainSafeRoot; + ed25519Multisig: Ed25519MultisigSafeRoot; ecdsaMpc: MpcSafeRoot; eddsaMpc: MpcSafeRoot; }; @@ -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), }, }; } diff --git a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts index d0c3c8354fb..7b96a026834 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts @@ -38,7 +38,7 @@ function makeDecryptMap(entries: Record): (params: { password: s function box(values: { secp256k1Multisig: string; - ed25519Multisig: string; + ed25519Multisig: string | { prv: string; chainCode: string }; ecdsaMpc: string; eddsaMpc: string; }): string { @@ -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', }); @@ -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')); @@ -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', @@ -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' }, }; diff --git a/modules/sdk-lib-safes/src/ed25519Pub.ts b/modules/sdk-lib-safes/src/ed25519Pub.ts index 32c91823163..a3e141e7fc8 100644 --- a/modules/sdk-lib-safes/src/ed25519Pub.ts +++ b/modules/sdk-lib-safes/src/ed25519Pub.ts @@ -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'); diff --git a/modules/sdk-lib-safes/src/keycardCodec.ts b/modules/sdk-lib-safes/src/keycardCodec.ts index adace7e490b..2e5825954b3 100644 --- a/modules/sdk-lib-safes/src/keycardCodec.ts +++ b/modules/sdk-lib-safes/src/keycardCodec.ts @@ -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; +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; + +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({ - 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. @@ -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. diff --git a/modules/sdk-lib-safes/test/unit/ed25519Pub.ts b/modules/sdk-lib-safes/test/unit/ed25519Pub.ts index 7ec6422f378..6e976eee56a 100644 --- a/modules/sdk-lib-safes/test/unit/ed25519Pub.ts +++ b/modules/sdk-lib-safes/test/unit/ed25519Pub.ts @@ -6,6 +6,7 @@ import { decodeEd25519StrKeyPublicKey, decodeEd25519StrKeySecretSeed, encodeDerivableEd25519Pub, + encodeEd25519ChainCode, encodeEd25519StrKeyPublicKey, generateEd25519ChainCode, isChecksumValidStrKeyEd25519Pub, @@ -33,6 +34,11 @@ describe('ed25519Pub', function () { generateEd25519ChainCode().length.should.equal(32); }); + it('encodes a 32-byte chain code as 52-char base32 and rejects wrong lengths', function () { + encodeEd25519ChainCode(CHAIN_CODE).should.match(/^[A-Z2-7]{52}$/); + (() => encodeEd25519ChainCode(Buffer.alloc(31))).should.throw(/expected 32 bytes/); + }); + it('rejects malformed composite values', function () { isDerivableEd25519Pub('not-a-root').should.equal(false); (() => decodeDerivableEd25519Pub('not-a-root')).should.throw(/Invalid derivable ed25519 pub/); diff --git a/modules/sdk-lib-safes/test/unit/keycardCodec.ts b/modules/sdk-lib-safes/test/unit/keycardCodec.ts index 661d393734f..1dde073689b 100644 --- a/modules/sdk-lib-safes/test/unit/keycardCodec.ts +++ b/modules/sdk-lib-safes/test/unit/keycardCodec.ts @@ -29,8 +29,14 @@ describe('keycardCodec', function () { }); }); - it('rejects a root whose value is not a string', function () { + it('rejects a root whose value is neither a string nor a derivable entry', function () { (() => parseSafeKeycardBox(JSON.stringify({ ...fullBox, ecdsaMpc: 12345 }))).should.throw(/ecdsaMpc/); }); + + it('accepts a derivable entry object for the ed25519Multisig slot', function () { + const entry = { prv: 'enc-seed', chainCode: 'A'.repeat(52) }; + const mixed = { ...fullBox, ed25519Multisig: entry }; + parseSafeKeycardBox(JSON.stringify(mixed)).ed25519Multisig.should.deepEqual(entry); + }); }); });