diff --git a/modules/express/package.json b/modules/express/package.json index e45f25abc80..c79adf404e4 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -41,7 +41,7 @@ "@api-ts/typed-express-router": "2.0.0", "@bitgo/abstract-lightning": "^8.6.3", "@bitgo/logger": "^1.4.0", - "@bitgo/public-types": "6.90.0", + "@bitgo/public-types": "7.1.0", "@bitgo/sdk-core": "^40.3.0", "@bitgo/utxo-lib": "^11.25.0", "@types/proxyquire": "^1.3.31", diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f460faad450..95890755c8a 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -724,6 +724,7 @@ export async function handleV2GenerateSafe( const safe = await safes.generateSafe({ label: req.decoded.label, passphrase: req.decoded.passphrase, + ...(req.decoded.multisigRoots === undefined ? {} : { multisigRoots: req.decoded.multisigRoots }), }); return safe.toJSON(); } @@ -755,6 +756,8 @@ export async function handleV2GenerateSafeKeys(req: ExpressApiRouteRequest<'expr passphrase: req.decoded.passphrase, safeId: req.decoded.safeId, enabledRootSlots: req.decoded.enabledRootSlots, + multisigRoots: req.decoded.multisigRoots, + externalBackupSlots: req.decoded.externalBackupSlots, }); } diff --git a/modules/express/src/typedRoutes/api/v2/generateSafe.ts b/modules/express/src/typedRoutes/api/v2/generateSafe.ts index c9194cec8cf..b9de732fee7 100644 --- a/modules/express/src/typedRoutes/api/v2/generateSafe.ts +++ b/modules/express/src/typedRoutes/api/v2/generateSafe.ts @@ -1,7 +1,25 @@ import * as t from 'io-ts'; -import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; import { BitgoExpressError } from '../../schemas/error'; +/** + * A caller-supplied multisig backup root (bring your own key). The backup private key never reaches + * BitGo Express. + */ +export const SafeMultisigRootKeyOptions = t.type({ + /** Slot ①: the backup root xpub. Slot ④: an ed25519 StrKey, or a 103-character derivable public key */ + backupXpub: t.string, +}); + +/** + * Caller-supplied backup roots, keyed by multisig slot. Only `secp256k1Multisig` and `ed25519Multisig` + * are accepted; any other slot is rejected before a ceremony runs. + */ +export const SafeMultisigRoots = t.partial({ + secp256k1Multisig: SafeMultisigRootKeyOptions, + ed25519Multisig: SafeMultisigRootKeyOptions, +}); + /** * Path parameters for one-shot Safe generation. */ @@ -20,6 +38,12 @@ export const GenerateSafeBody = { label: t.string, /** Passphrase used to encrypt locally generated root user/backup keys and to run MPC ceremonies */ passphrase: t.string, + /** + * Bring-your-own backup key per multisig slot. A listed slot gets this backup root public key instead + * of a locally generated backup key; the SDK never sees its private key. Declared to BitGo at + * initialize, so it also needs the external backup root feature enabled for the enterprise. + */ + multisigRoots: optional(SafeMultisigRoots), } as const; /** diff --git a/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts b/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts index 064dbb94c57..5572f006d37 100644 --- a/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts +++ b/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts @@ -1,7 +1,8 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; -import { RootKeyType } from '@bitgo/public-types'; +import { MultisigRootKeyType, RootKeyType } from '@bitgo/public-types'; import { BitgoExpressError } from '../../schemas/error'; +import { SafeMultisigRoots } from './generateSafe'; /** * Path parameters for Phase-2-only Safe key ceremonies. @@ -23,6 +24,10 @@ export const GenerateSafeKeysBody = { passphrase: t.string, /** Slots to run. Omit to run all four (older WP / retry of a full ceremony set). */ enabledRootSlots: optional(t.array(RootKeyType)), + /** Bring-your-own backup key per multisig slot. Must match `externalBackupSlots` exactly. */ + multisigRoots: optional(SafeMultisigRoots), + /** The safe's external backup slots as returned by initialize. Omit when every backup root is local. */ + externalBackupSlots: optional(t.array(MultisigRootKeyType)), } as const; /** diff --git a/modules/express/test/unit/typedRoutes/generateSafe.ts b/modules/express/test/unit/typedRoutes/generateSafe.ts index e852ef0b339..9d4b94d9274 100644 --- a/modules/express/test/unit/typedRoutes/generateSafe.ts +++ b/modules/express/test/unit/typedRoutes/generateSafe.ts @@ -67,6 +67,48 @@ describe('Generate Safe Typed Routes Tests', function () { generateSafeStub.firstCall.args[0].should.deepEqual({ label, passphrase }); }); + it('should forward multisigRoots (bring-your-own backup key) to the SDK', async function () { + const label = 'Test Safe'; + const passphrase = 'mySecurePassphrase123'; + const multisigRoots = { + secp256k1Multisig: { backupXpub: 'xpub-backup' }, + ed25519Multisig: { backupXpub: 'GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH' }, + }; + const generateSafeStub = sinon + .stub(Safes.prototype, 'generateSafe') + .resolves(stubSafe({ toJSON: () => mockSafeJson(label) })); + + const res = await agent + .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`) + .send({ label, passphrase, multisigRoots }); + + res.status.should.equal(200); + generateSafeStub.should.have.been.calledOnce(); + generateSafeStub.firstCall.args[0].should.deepEqual({ label, passphrase, multisigRoots }); + }); + + it('should return 400 when a multisigRoots backupXpub is not a string', async function () { + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({ + label: 'Test Safe', + passphrase: 'password', + multisigRoots: { secp256k1Multisig: { backupXpub: 12345 } }, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should return 400 when multisigRoots omits backupXpub', async function () { + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({ + label: 'Test Safe', + passphrase: 'password', + multisigRoots: { ed25519Multisig: {} }, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + it('should return 400 when label is missing', async function () { const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({ passphrase: 'password', @@ -246,6 +288,36 @@ describe('Generate Safe Typed Routes Tests', function () { enabledRootSlots.should.deepEqual(['secp256k1Multisig']); }); + it('should forward multisigRoots and the declared externalBackupSlots to the SDK', async function () { + const multisigRoots = { secp256k1Multisig: { backupXpub: 'xpub-backup' } }; + const createSafeKeysStub = sinon + .stub(Safes.prototype, 'createSafeKeys') + .resolves({ rootKeys: { hot: { secp256k1Multisig: ['user-1', 'backup-1', 'bitgo-1'] } } }); + + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({ + passphrase: 'password', + enabledRootSlots: ['secp256k1Multisig'], + multisigRoots, + externalBackupSlots: ['secp256k1Multisig'], + }); + + res.status.should.equal(200); + createSafeKeysStub.should.have.been.calledOnce(); + createSafeKeysStub.firstCall.args[0].should.have.property('multisigRoots', multisigRoots); + createSafeKeysStub.firstCall.args[0].should.have.property('externalBackupSlots'); + createSafeKeysStub.firstCall.args[0].externalBackupSlots?.should.deepEqual(['secp256k1Multisig']); + }); + + it('should return 400 when externalBackupSlots contains an MPC slot', async function () { + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({ + passphrase: 'password', + externalBackupSlots: ['ecdsaMpc'], + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + it('should return 400 when passphrase is missing', async function () { const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({}); diff --git a/modules/key-card/src/generateQrData.ts b/modules/key-card/src/generateQrData.ts index f27a0571cea..0f9508cc09f 100644 --- a/modules/key-card/src/generateQrData.ts +++ b/modules/key-card/src/generateQrData.ts @@ -229,8 +229,15 @@ function selectRootPrivateKey(keychain: Keychain, slot: SafeRootKeyType, role: ' // 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 { +// everywhere) stays a bare string. A slot declared external carries the root public key instead. +function selectBackupRootEntry(keychain: Keychain, slot: SafeRootKeyType, external: boolean): SafeKeycardRootEntry { + if (external) { + // The caller (BYOK) holds the backup private key, so the card carries only the root public key. + // For slot ④ the pub is already the derivable composite, so the chain code survives. + assert.ok(keychain.pub, `Safe external backup root ${slot} is missing a public key`); + assert.ok(!keychain.encryptedPrv, `Safe external backup root ${slot} must not carry an encrypted private key`); + return { kind: 'external', pub: keychain.pub }; + } const prv = selectRootPrivateKey(keychain, slot, 'backup'); if (slot !== 'ed25519Multisig') { return prv; @@ -273,6 +280,7 @@ function buildSafeBoxData( */ export async function generateSafeQrData(params: GenerateSafeQrDataParams): Promise { const { roots } = params; + const externalBackupSlots = new Set(params.externalBackupSlots ?? []); const qrData: QrData = { user: { title: 'A: User Key', @@ -281,8 +289,14 @@ 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) => selectBackupRootEntry(root.backupKeychain, slot)), + description: + externalBackupSlots.size === 0 + ? 'Your 4 root backup keys, encrypted with your safe password.' + : 'Your 4 root backup keys, encrypted with your safe password. Slots marked external carry only the ' + + 'public key of a backup key you supplied; its private key is not on this card and you must keep it safe.', + data: buildSafeBoxData(roots, (root, slot) => + selectBackupRootEntry(root.backupKeychain, slot, externalBackupSlots.has(slot)) + ), }, bitgo: { title: 'C: BitGo Key', diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 6f57737f039..e9dd3c409ca 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -80,6 +80,10 @@ 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 declared external backup slots (`SafeData.externalBackupSlots`). For these slots Box B + // carries the backup root public key instead of an encrypted private key. Omit for a safe whose + // backup roots are all local. + externalBackupSlots?: SafeRootKeyType[]; } export type GenerateKeycardParams = GenerateQrDataBaseParams & diff --git a/modules/key-card/test/unit/safeQrData.ts b/modules/key-card/test/unit/safeQrData.ts index 7c83ec6ecbb..812e8178b76 100644 --- a/modules/key-card/test/unit/safeQrData.ts +++ b/modules/key-card/test/unit/safeQrData.ts @@ -127,19 +127,17 @@ describe('generateSafeQrData', function () { for (const slot of SAFE_ROOT_ORDER) { (await decrypt(passphrase, userBox[slot] as string)).should.equal(plaintexts[slot].user); const backupEntry = backupBox[slot]; - const backupPrv = typeof backupEntry === 'string' ? backupEntry : backupEntry.prv; + const backupPrv = typeof backupEntry === 'string' ? backupEntry : (backupEntry as { prv: string }).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; + const ed25519Backup = backupBox.ed25519Multisig as { prv: string; chainCode: string }; 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); - } + 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(); @@ -147,6 +145,71 @@ describe('generateSafeQrData', function () { backupBox.eddsaMpc.should.be.a.String(); }); + describe('external backup slots', function () { + // A BYOK backup keychain is pub-only: the caller holds the private key. + function makeExternal(roots: Record, slot: SafeRootKeyType, pub: string) { + roots[slot].backupKeychain = makeKeychain({ type: 'independent', pub }); + } + + it('emits an external entry for a declared slot instead of an encrypted backup prv', async function () { + const { roots, plaintexts } = await buildRoots(); + makeExternal(roots, 'secp256k1Multisig', 'xpub-external-backup'); + const qrData = await generateSafeQrData({ + coin: coins.get('btc'), + roots, + externalBackupSlots: ['secp256k1Multisig'], + }); + assert.ok(qrData.backup); + + const backupBox = parseSafeKeycardBox(reassemble(qrData.backup.data)); + backupBox.secp256k1Multisig.should.deepEqual({ kind: 'external', pub: 'xpub-external-backup' }); + // The other slots stay local: encrypted prv, and the slot-④ chain-code object. + (await decrypt(passphrase, backupBox.ecdsaMpc as string)).should.equal(plaintexts.ecdsaMpc.backup); + (backupBox.ed25519Multisig as { chainCode: string }).chainCode.should.match(/^[A-Z2-7]{52}$/); + qrData.backup.description.should.match(/external/); + }); + + it('keeps the slot-④ derivable composite pub, chain code included', async function () { + const { roots } = await buildRoots(); + const composite = roots.ed25519Multisig.backupKeychain.pub as string; + makeExternal(roots, 'ed25519Multisig', composite); + const qrData = await generateSafeQrData({ + coin: coins.get('btc'), + roots, + externalBackupSlots: ['ed25519Multisig'], + }); + assert.ok(qrData.backup); + + parseSafeKeycardBox(qrData.backup.data).ed25519Multisig.should.deepEqual({ kind: 'external', pub: composite }); + }); + + it('leaves boxes A and C unchanged and the backup description plain without external slots', async function () { + const { roots } = await buildRoots(); + const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots, externalBackupSlots: [] }); + assert.ok(qrData.backup); + qrData.backup.description.should.not.match(/external/); + }); + + it('rejects a declared external slot whose backup keychain still carries an encrypted prv', async function () { + const { roots } = await buildRoots(); + await generateSafeQrData({ + coin: coins.get('btc'), + roots, + externalBackupSlots: ['secp256k1Multisig'], + }).should.be.rejectedWith(/must not carry an encrypted private key/); + }); + + it('rejects a declared external slot whose backup keychain has no pub', async function () { + const { roots } = await buildRoots(); + roots.secp256k1Multisig.backupKeychain = makeKeychain({ type: 'independent', pub: undefined }); + await generateSafeQrData({ + coin: coins.get('btc'), + roots, + externalBackupSlots: ['secp256k1Multisig'], + }).should.be.rejectedWith(/missing a public key/); + }); + }); + it('preserves the versioned VRF envelope in the MPC root card blob', async function () { const { roots } = await buildRoots(); const { reducedEnvelope } = buildSafeMpcKeyEnvelopes( diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 113dce78d6a..9e3403dbf57 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.91.0", + "@bitgo/public-types": "7.1.0", "@bitgo/sdk-lib-mpc": "^11.6.0", "@bitgo/sdk-lib-safes": "^1.8.1", "@bitgo/secp256k1": "^1.11.1", diff --git a/modules/sdk-core/src/bitgo/safe/iSafe.ts b/modules/sdk-core/src/bitgo/safe/iSafe.ts index 57598454d17..b17348b1976 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafe.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafe.ts @@ -8,6 +8,7 @@ // re-exporting from sdk-core. import type { FreezeSafeBody, + MultisigRootKeyType, SafeData, SafePermission, SafeRootKeys, @@ -23,6 +24,11 @@ import type { WebAuthnProvider } from '../webauthn'; /** @experimental */ export interface InitializeSafeOptions { label: string; + /** + * Multisig slots whose backup root is external, i.e. not generated and encrypted by the SDK. + * Omit when every backup root is local. + */ + externalBackupSlots?: MultisigRootKeyType[]; } /** @experimental */ diff --git a/modules/sdk-core/src/bitgo/safe/iSafes.ts b/modules/sdk-core/src/bitgo/safe/iSafes.ts index 7e308994ae8..b24eb084a63 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafes.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafes.ts @@ -4,7 +4,7 @@ * @experimental The safe client surface is experimental and may change (including breaking * changes) before the public release. */ -import type { InitializeSafeResponse } from '@bitgo/public-types'; +import type { InitializeSafeResponse, MultisigRootKeyType } from '@bitgo/public-types'; import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; import { Safe } from './safe'; @@ -20,18 +20,42 @@ import { Safe } from './safe'; export interface CreateSafeOptions { label: string; passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies + /** + * Bring-your-own backup key (BYOK), per multisig slot. A slot listed here gets a caller-supplied + * backup root public key instead of a locally generated one; the SDK never sees its private key. + * Only the multisig slots are accepted; MPC slots are rejected. + */ + multisigRoots?: SafeMultisigRootKeys; } +/** + * Options for a caller-supplied multisig backup root. + * @experimental + */ +export interface SafeMultisigRootKeyOptions { + /** Slot ①: the backup root xpub. Slot ④: an ed25519 StrKey, or a 103-character derivable pub. */ + backupXpub: string; +} + +/** + * Caller-supplied backup roots, keyed by multisig slot. + * @experimental + */ +export type SafeMultisigRootKeys = Partial>; + /** * Handle returned by `initializeSafe`, threaded into the key ceremonies and finalize. * * `enabledRootSlots` is the server-decided (Flipt, evaluated once at initialize) set of root-key - * slots to generate; absent (older WP) falls back to all 4 slots. + * slots to generate; absent (older WP) falls back to all 4 slots. `externalBackupSlots` is the + * server's record of which multisig backup roots the caller supplies; absent means none. * @experimental */ export interface SafeCreationHandle { safeId: string; enabledRootSlots?: InitializeSafeResponse['enabledRootSlots']; + /** The slots the server recorded as having an external backup root (echoed by initialize). */ + externalBackupSlots?: InitializeSafeResponse['externalBackupSlots']; } /** diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index 6dd94404054..2cd93400a2e 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -14,6 +14,7 @@ import { deriveSafeChildEd25519Hardened, deriveSafeChildTriplet, Ed25519ChainCodeEntryCodec, + ExternalBackupEntryCodec, getSafeBip44CoinType, parseSafeBip44Path, parseSafeKeycardBox, @@ -22,9 +23,11 @@ import { encodeAlgoKey, encodeHbarPublicKeyDer, encodeHbarPrivateKeyDer, + type ExternalBackupRoot, type SafeKeycardRootEntry, } from '@bitgo/sdk-lib-safes'; import { + assertValidExternalEd25519Signer, signEd25519Extended, softDeriveChildPubEd25519AtPath, softDeriveChildPrvEd25519AtPath, @@ -39,12 +42,22 @@ import { parseSafeMpcKeyEnvelopes } from '../utils/tss/keyShareEnvelope'; export type DecryptedMpcSafeRoot = { signing: Buffer; vrf: Buffer }; export type OnchainSafeRoot = { user: string; backup: string; bitgo: string }; +/** + * `backup` is the decrypted xprv for a local backup root, or {@link ExternalBackupRoot} (public key + * only) when the backup root is external: caller-supplied (BYOK) or provider-held. + */ +export type OnchainSafeRootWithExternal = { user: string; backup: string | ExternalBackupRoot; 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 Ed25519MultisigSafeRootWithExternal = { + user: string; + backup: { prv: string; chainCode: string } | ExternalBackupRoot; + bitgo: string; +}; export type Secp256k1MultisigChildKeys = { user: { prv: string; pub: string }; @@ -52,6 +65,13 @@ export type Secp256k1MultisigChildKeys = { bitgo: { pub: string }; }; +/** `backup.prv` is absent for an external backup root unless `externalBackupRootPrv` was supplied. */ +export type Secp256k1ExternalBackupChildKeys = { + user: { prv: string; pub: string }; + backup: { prv?: string; pub: string }; + bitgo: { pub: string }; +}; + export type EcdsaMpcChildKeys = { commonKeychain: string; user: string; @@ -76,6 +96,12 @@ export type SafeEd25519SeedKey = { */ export type SafeEd25519ExtendedKey = { readonly kind: 'extended' } & Ed25519ExtendedKeyParts; // hex +/** + * An external ed25519 backup child: only the soft-derived public key is known. The private key + * stays with its holder, who signs through an {@link ExternalEd25519Signer}. + */ +export type SafeEd25519ExternalKey = { readonly kind: 'external'; readonly pub: string }; // hex + /** The three ed25519 co-signer children. BitGo is pub-only: its private key stays in the HSM. */ export type Ed25519MultisigChildKeys = { user: SafeEd25519SeedKey; @@ -83,12 +109,28 @@ export type Ed25519MultisigChildKeys = { bitgo: { readonly pub: string }; }; +/** As {@link Ed25519MultisigChildKeys}, for a safe whose backup root may be external. */ +export type Ed25519ExternalBackupChildKeys = { + user: SafeEd25519SeedKey; + backup: SafeEd25519ExtendedKey | SafeEd25519ExternalKey; + bitgo: { readonly pub: string }; +}; + export type DerivedSafeWalletKeys = | { slot: 'secp256k1Multisig'; path: string; keys: Secp256k1MultisigChildKeys } | { slot: 'ecdsaMpc'; path: string; keys: EcdsaMpcChildKeys } | { slot: 'eddsaMpc'; path: string; keys: EddsaMpcChildKeys } | { slot: 'ed25519Multisig'; path: string; keys: Ed25519MultisigChildKeys }; +/** + * Derived keys for a safe whose backup root may be external. Slots other than the two multisig + * ones are unchanged. + */ +export type DerivedSafeWalletKeysWithExternal = + | Exclude + | { slot: 'secp256k1Multisig'; path: string; keys: Secp256k1ExternalBackupChildKeys } + | { slot: 'ed25519Multisig'; path: string; keys: Ed25519ExternalBackupChildKeys }; + export type SecpSafeRecoverKeyParams = { slot: 'secp256k1Multisig'; userKey: string; @@ -173,13 +215,24 @@ export type DecryptedSafeRoots = { eddsaMpc: MpcSafeRoot; }; +/** + * The decrypted roots of a safe keycard whose backup roots may be external (public key only). + * Assignable from {@link DecryptedSafeRoots}. + */ +export type DecryptedSafeRootsWithExternal = { + secp256k1Multisig: OnchainSafeRootWithExternal; + ed25519Multisig: Ed25519MultisigSafeRootWithExternal; + ecdsaMpc: MpcSafeRoot; + eddsaMpc: MpcSafeRoot; +}; + export async function decryptSafeKeycard(params: { bitgo: BitGoBase; userKeyBox: string; backupKeyBox: string; bitgoKeyBox: string; password: string; -}): Promise { +}): Promise { const { bitgo, userKeyBox, backupKeyBox, bitgoKeyBox, password } = params; const userRoots = parseSafeKeycardBox(userKeyBox); @@ -209,10 +262,31 @@ export async function decryptSafeKeycard(params: { if (Ed25519ChainCodeEntryCodec.is(entry)) { throw new Error('unexpected ed25519 chain-code entry where a bare string was required'); } + if (typeof entry !== 'string') { + throw new Error('unexpected external backup entry where a bare string was required'); + } return entry; }; - const decryptEd25519Backup = async (entry: SafeKeycardRootEntry): Promise<{ prv: string; chainCode: string }> => { + // An external backup entry carries only the root public key: nothing to decrypt, and no password + // is needed to read it. Only box B's multisig slots may carry one. + const toExternalBackupRoot = (entry: SafeKeycardRootEntry): ExternalBackupRoot | undefined => { + if (!ExternalBackupEntryCodec.is(entry)) { + return undefined; + } + return { external: true, pub: entry.pub, ...(entry.provider === undefined ? {} : { provider: entry.provider }) }; + }; + + const decryptSecp256k1Backup = async (entry: SafeKeycardRootEntry): Promise => + toExternalBackupRoot(entry) ?? (await decryptRoot(requireBareString(entry))); + + const decryptEd25519Backup = async ( + entry: SafeKeycardRootEntry + ): Promise<{ prv: string; chainCode: string } | ExternalBackupRoot> => { + const external = toExternalBackupRoot(entry); + if (external) { + return external; + } if (!Ed25519ChainCodeEntryCodec.is(entry)) { throw new Error('ed25519 backup root must carry a chain code'); } @@ -222,7 +296,7 @@ export async function decryptSafeKeycard(params: { return { secp256k1Multisig: { user: await decryptRoot(requireBareString(userRoots.secp256k1Multisig)), - backup: await decryptRoot(requireBareString(backupRoots.secp256k1Multisig)), + backup: await decryptSecp256k1Backup(backupRoots.secp256k1Multisig), bitgo: requireBareString(bitgoRoots.secp256k1Multisig), }, ed25519Multisig: { @@ -246,13 +320,33 @@ export async function decryptSafeKeycard(params: { /** * Derives a Safe wallet's child keys at the BIP44 path for a given slot and account. * Supports all four slots: `secp256k1Multisig`, `ed25519Multisig`, `ecdsaMpc`, and `eddsaMpc`. + * + * With an external backup root the backup child is derived from the public key alone (relative soft + * derivation, so a non-master root works). For `secp256k1Multisig`, `externalBackupRootPrv` — the + * caller's own backup xprv — adds the backup child private key (needed for signed recovery) and is + * checked against the external root pub. For `ed25519Multisig` the holder signs through an + * `ExternalEd25519Signer` at recovery instead (see `buildSafeRecoverKeyParams`). */ export async function deriveSafeWalletKeys(params: { coin: string; slot: RootKeyType; account: number; roots: DecryptedSafeRoots; -}): Promise { +}): Promise; +export async function deriveSafeWalletKeys(params: { + coin: string; + slot: RootKeyType; + account: number; + roots: DecryptedSafeRootsWithExternal; + externalBackupRootPrv?: string; +}): Promise; +export async function deriveSafeWalletKeys(params: { + coin: string; + slot: RootKeyType; + account: number; + roots: DecryptedSafeRootsWithExternal; + externalBackupRootPrv?: string; +}): Promise { if (!Number.isInteger(params.account) || params.account < 0 || params.account > MAX_BIP32_INDEX) { throw new Error(`deriveSafeWalletKeys: invalid account '${params.account}'`); } @@ -261,8 +355,29 @@ export async function deriveSafeWalletKeys(params: { const path = buildSafeBip44Path(getSafeBip44CoinType(params.coin), params.slot, account); const { coinType, slotOrdinal } = parseSafeBip44Path(path); + if (params.externalBackupRootPrv !== undefined && params.slot !== 'secp256k1Multisig') { + throw new Error(`deriveSafeWalletKeys: externalBackupRootPrv only applies to the secp256k1Multisig slot`); + } + if (params.slot === 'secp256k1Multisig') { - return { slot: 'secp256k1Multisig', path, keys: deriveSafeChildTriplet(roots.secp256k1Multisig, path) }; + const root = roots.secp256k1Multisig; + if (typeof root.backup === 'string') { + if (params.externalBackupRootPrv !== undefined) { + throw new Error( + 'deriveSafeWalletKeys: externalBackupRootPrv was given but the safe backup root is not external' + ); + } + return { + slot: 'secp256k1Multisig', + path, + keys: deriveSafeChildTriplet({ user: root.user, backup: root.backup, bitgo: root.bitgo }, path), + }; + } + return { + slot: 'secp256k1Multisig', + path, + keys: deriveSafeChildTriplet(root, path, { externalBackupRootPrv: params.externalBackupRootPrv }), + }; } if (params.slot === 'ecdsaMpc') { @@ -300,18 +415,38 @@ export async function deriveSafeWalletKeys(params: { const cosignerPath = toSoftTwinPath(path); const user = deriveSafeChildEd25519Hardened(root.user, path); + const bitgo = await softDeriveChildPubEd25519AtPath(root.bitgo, cosignerPath); + const userKey: SafeEd25519SeedKey = { + kind: 'seed', + seed: user.prv, + pub: decodeEd25519StrKeyPublicKey(user.pub).toString('hex'), + }; + + // External backup root: only its derivable composite pub is known, so derive the child pub alone. + if ('external' in root.backup) { + const backupPub = await softDeriveChildPubEd25519AtPath(root.backup.pub, cosignerPath); + return { + slot: 'ed25519Multisig', + path, + keys: { + user: userKey, + backup: { kind: 'external', pub: backupPub.toString('hex') }, + bitgo: { pub: bitgo.toString('hex') }, + }, + }; + } + const backup = await softDeriveChildPrvEd25519AtPath({ seedStrKey: root.backup.prv, chainCode: root.backup.chainCode, path: cosignerPath, }); - const bitgo = await softDeriveChildPubEd25519AtPath(root.bitgo, cosignerPath); return { slot: 'ed25519Multisig', path, keys: { - user: { kind: 'seed', seed: user.prv, pub: decodeEd25519StrKeyPublicKey(user.pub).toString('hex') }, + user: userKey, backup: { kind: 'extended', kL: backup.kL.toString('hex'), @@ -363,8 +498,16 @@ export type SafeRecoverKeyParamsRequest = { coin: string; mode: 'signed' | 'unsigned'; } & ( - | { slot: 'secp256k1Multisig'; keys: Secp256k1MultisigChildKeys } - | { slot: 'ed25519Multisig'; keys: Ed25519MultisigChildKeys } + | { slot: 'secp256k1Multisig'; keys: Secp256k1ExternalBackupChildKeys } + | { + slot: 'ed25519Multisig'; + keys: Ed25519ExternalBackupChildKeys; + /** + * Required for signed recovery when the backup root is external: the holder's own signer for + * the soft-derived backup child. Its `publicKey` must equal `keys.backup.pub`. + */ + externalBackupSigner?: ExternalEd25519Signer; + } | { slot: 'ecdsaMpc'; keys: EcdsaMpcChildKeys } | { slot: 'eddsaMpc'; keys: EddsaMpcChildKeys } ); @@ -392,7 +535,7 @@ export async function buildSafeRecoverKeyParams(params: SafeRecoverKeyParamsRequ const { bitgo, mode } = params; if (params.slot === 'ed25519Multisig') { - return buildEd25519MultisigRecoverKeyParams(bitgo, params.coin, params.keys, mode); + return buildEd25519MultisigRecoverKeyParams(bitgo, params.coin, params.keys, mode, params.externalBackupSigner); } if (params.slot === 'ecdsaMpc') { @@ -438,8 +581,15 @@ export async function buildSafeRecoverKeyParams(params: SafeRecoverKeyParamsRequ }; } + if (keys.backup.prv === undefined) { + throw new Error( + 'buildSafeRecoverKeyParams: signed recovery of an external backup root needs the backup private key; ' + + 'pass externalBackupRootPrv to deriveSafeWalletKeys' + ); + } + const backupPrvKey = keys.backup.prv; const userPrv = bip32.fromBase58(keys.user.prv); - const backupPrv = bip32.fromBase58(keys.backup.prv); + const backupPrv = bip32.fromBase58(backupPrvKey); if (!userPrv.privateKey || !backupPrv.privateKey) { throw new Error('buildSafeRecoverKeyParams: keys.user.prv and keys.backup.prv must be private xprvs'); } @@ -447,7 +597,7 @@ export async function buildSafeRecoverKeyParams(params: SafeRecoverKeyParamsRequ const walletPassphrase = randomBytes(32).toString('hex'); const [userKey, backupKey] = await Promise.all([ bitgo.encrypt({ input: keys.user.prv, password: walletPassphrase, encryptionVersion: 1 }), - bitgo.encrypt({ input: keys.backup.prv, password: walletPassphrase, encryptionVersion: 1 }), + bitgo.encrypt({ input: backupPrvKey, password: walletPassphrase, encryptionVersion: 1 }), ]); return { @@ -465,8 +615,9 @@ export async function buildSafeRecoverKeyParams(params: SafeRecoverKeyParamsRequ async function buildEd25519MultisigRecoverKeyParams( bitgo: BitGoBase, coin: string, - keys: Ed25519MultisigChildKeys, - mode: 'signed' | 'unsigned' + keys: Ed25519ExternalBackupChildKeys, + mode: 'signed' | 'unsigned', + externalBackupSigner?: ExternalEd25519Signer ): Promise { const encoders = ED25519_MULTISIG_COIN_ENCODERS[coin]; if (!encoders) { @@ -512,6 +663,32 @@ async function buildEd25519MultisigRecoverKeyParams( encryptionVersion: 1, }); + // An external backup child has no key material here: the holder's own signer must cover it. + if (keys.backup.kind === 'external') { + if (externalBackupSigner === undefined) { + throw new Error( + 'buildSafeRecoverKeyParams: signed recovery of an external ed25519 backup root needs externalBackupSigner' + ); + } + assertValidExternalEd25519Signer(externalBackupSigner); + if (externalBackupSigner.publicKey.toLowerCase() !== keys.backup.pub.toLowerCase()) { + throw new Error('buildSafeRecoverKeyParams: externalBackupSigner.publicKey does not match the backup child pub'); + } + return { + slot: 'ed25519Multisig', + mode: 'signed', + userKey, + bitgoKey: encoders.encodePub(bitgoPub), + walletPassphrase, + ...rootAddressParams, + externalBackupSigner, + }; + } + if (externalBackupSigner !== undefined) { + throw new Error('buildSafeRecoverKeyParams: externalBackupSigner only applies to an external backup root'); + } + const backupKey = keys.backup; + return { slot: 'ed25519Multisig', mode: 'signed', @@ -520,13 +697,13 @@ async function buildEd25519MultisigRecoverKeyParams( walletPassphrase, ...rootAddressParams, externalBackupSigner: { - publicKey: keys.backup.pub, + publicKey: backupKey.pub, sign: async (message) => ({ signature: ( await signEd25519Extended( Buffer.from(message), - Buffer.from(keys.backup.kL, 'hex'), - Buffer.from(keys.backup.kR, 'hex') + Buffer.from(backupKey.kL, 'hex'), + Buffer.from(backupKey.kR, 'hex') ) ).toString('hex'), }), diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts index 5d9e0ac9c3b..5d1cfcd579b 100644 --- a/modules/sdk-core/src/bitgo/safe/safes.ts +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -9,6 +9,7 @@ import { FinalizeSafeBody, InitializeSafeBody, InitializeSafeResponse, + MultisigRootKeyType, RootKeysByType, RootKeyTriplet, RootKeyType, @@ -17,10 +18,27 @@ import { import { BitGoBase } from '../bitgoBase'; import { ApiResponseError } from '../errors'; import { decodeWithCodec } from '../utils/codecs'; -import { isDerivableEd25519Pub } from '@bitgo/sdk-lib-safes'; +import { + decodeEd25519StrKeyPublicKey, + encodeDerivableEd25519Pub, + generateEd25519ChainCode, + isDerivableEd25519Pub, + isValidEd25519StrKeyPublicKey, +} from '@bitgo/sdk-lib-safes'; +import { IBaseCoin } from '../baseCoin'; +import { Keychain } from '../keychain'; 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, + SafeKeys, + SafeMultisigRootKeyOptions, + SafeMultisigRootKeys, +} from './iSafes'; import { coinForRoot, SAFE_ROOT_SLOTS } from './rootCoin'; import { Safe } from './safe'; @@ -35,6 +53,68 @@ const ListSafesResponse = t.intersection([ t.partial({ nextBatchPrevId: t.string }), ]); +/** The only slots whose backup root can be caller-supplied; MPC slots have no pub-only backup. */ +const MULTISIG_ROOT_SLOTS: MultisigRootKeyType[] = ['secp256k1Multisig', 'ed25519Multisig']; + +/** + * Validates `multisigRoots` and returns the slots, in canonical order, that carry a caller-supplied + * backup root. Throws for any key that is not a multisig slot, so an MPC slot can never reach a + * ceremony. + */ +function externalBackupSlotsOf(multisigRoots: SafeMultisigRootKeys | undefined): MultisigRootKeyType[] { + if (multisigRoots === undefined) { + return []; + } + const unsupported = Object.keys(multisigRoots).filter((key) => !MULTISIG_ROOT_SLOTS.some((slot) => slot === key)); + if (unsupported.length > 0) { + throw new Error( + `multisigRoots only supports the multisig slots (${MULTISIG_ROOT_SLOTS.join(', ')}); got: ${unsupported.join( + ', ' + )}` + ); + } + const slots = MULTISIG_ROOT_SLOTS.filter((slot) => multisigRoots[slot] !== undefined); + for (const slot of slots) { + const backupXpub = multisigRoots[slot]?.backupXpub; + if (typeof backupXpub !== 'string' || backupXpub.length === 0) { + throw new Error(`multisigRoots.${slot}.backupXpub must be a non-empty string`); + } + } + return slots; +} + +/** + * Validates a caller-supplied backup root and returns the pub to register. + * + * Slot ①: must be a valid xpub (never a private key). Slot ④: an ed25519 StrKey is converted to the + * derivable format (`pub32 || chainCode32`) by appending a fresh chain code, exactly as + * `Keychains.createBackup` does for a local key; a 103-character derivable pub is used as is. The + * derivable pub, including its chain code, is what goes on the keycard. + */ +function prepareExternalBackupPub( + slot: MultisigRootKeyType, + coin: IBaseCoin, + external: SafeMultisigRootKeyOptions +): string { + const { backupXpub } = external; + if (slot === 'secp256k1Multisig') { + // The coin's own check: a valid xpub in BitGo's key encoding, never a private key. + if (!coin.isValidPub(backupXpub)) { + throw new Error('Safe secp256k1Multisig backupXpub must be a valid xpub (a private key is never accepted)'); + } + return backupXpub; + } + const pub = isValidEd25519StrKeyPublicKey(backupXpub) + ? encodeDerivableEd25519Pub(decodeEd25519StrKeyPublicKey(backupXpub), generateEd25519ChainCode()) + : backupXpub; + if (!isDerivableEd25519Pub(pub)) { + throw new Error( + 'Safe ed25519Multisig backupXpub must be an ed25519 public key (StrKey) or a 103-character derivable public key' + ); + } + return pub; +} + /** * Collection accessor for a single enterprise's safes, mirroring Wallets / Enterprises. * Safe routes are enterprise-scoped (/api/v2/enterprise/:eId/safes), so the accessor is @@ -64,27 +144,41 @@ export class Safes implements ISafes { * initialize (Phase 1) → createSafeKeys (Phase 2, enabled-slot ceremonies) → * finalize (Phase 3). HOT custody only in v1. * + * With `multisigRoots`, the listed multisig slots take a caller-supplied backup root public key + * (BYOK) instead of a locally generated backup key; they are declared to the server at initialize. + * * 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 { - const safe = await this.initializeSafe({ label: params.label }); + // Validate before initialize so a bad option never leaves a half-created safe behind. + const externalBackupSlots = externalBackupSlotsOf(params.multisigRoots); + const safe = await this.initializeSafe({ + label: params.label, + ...(externalBackupSlots.length > 0 ? { externalBackupSlots } : {}), + }); const rootKeys = await this.createSafeKeys({ ...params, safeId: safe.id, enabledRootSlots: safe.enabledRootSlots, + externalBackupSlots: safe.externalBackupSlots, }); return await this.finalizeSafe(safe.id, rootKeys); } /** * Phase 1 — initialize a safe (metadata only, no key material). - * POST /api/v2/enterprise/:eId/safes { label } - * Response is `{ id, status }` plus optional `enabledRootSlots` (absent on older WP). + * POST /api/v2/enterprise/:eId/safes { label, externalBackupSlots? } + * Response is `{ id, status }` plus optional `enabledRootSlots` and `externalBackupSlots` + * (absent on older WP). * @experimental */ async initializeSafe(params: InitializeSafeOptions): Promise { + // Encoding does not validate, so reject an MPC slot name here rather than at the server. + if (params.externalBackupSlots !== undefined && !t.array(MultisigRootKeyType).is(params.externalBackupSlots)) { + throw new Error(`externalBackupSlots only supports the multisig slots (${MULTISIG_ROOT_SLOTS.join(', ')})`); + } const response = await postWithCodec(this.bitgo, this.url(), InitializeSafeBody, params).result(); return decodeWithCodec(InitializeSafeResponse, response, 'InitializeSafeResponse'); } @@ -101,20 +195,51 @@ export class Safes implements ISafes { * * Not independently idempotent: re-running against a safe that already has some roots hits WP's * unique root-slot index. + * + * `multisigRoots` must agree with the `externalBackupSlots` the server recorded at initialize (a + * slot with a supplied backup key that was not declared, or the reverse, throws before any key is + * created, and the safe is archived). * @experimental */ async createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise { - const { safeId, passphrase, enabledRootSlots } = params; + const { safeId, passphrase, enabledRootSlots, multisigRoots } = params; const enterprise = this.enterpriseId; + const suppliedSlots = externalBackupSlotsOf(multisigRoots); + const declaredSlots = new Set(params.externalBackupSlots ?? []); + const notDeclared = suppliedSlots.filter((slot) => !declaredSlots.has(slot)); + const notSupplied = [...declaredSlots].filter((slot) => !suppliedSlots.includes(slot)); + const notEnabled = + enabledRootSlots === undefined ? [] : suppliedSlots.filter((slot) => !enabledRootSlots.includes(slot)); + if (notDeclared.length > 0 || notSupplied.length > 0 || notEnabled.length > 0) { + const problems = [ + notDeclared.length > 0 ? `backup key supplied but not declared at initialize: ${notDeclared.join(', ')}` : '', + notSupplied.length > 0 ? `declared at initialize but no backup key supplied: ${notSupplied.join(', ')}` : '', + notEnabled.length > 0 ? `backup key supplied for a slot that is not enabled: ${notEnabled.join(', ')}` : '', + ].filter((problem) => problem !== ''); + let message = `multisigRoots does not match the safe's external backup slots (${problems.join('; ')}).`; + // No key exists yet, but leave no initializing safe behind. + try { + await this.archiveSafe(safeId); + message += ` The safe (${safeId}) has been archived; create a new safe and retry.`; + } catch (e) { + message += ` NOTE: archiving the safe (${safeId}) also failed (${ + e instanceof Error ? e.message : String(e) + }); archive it manually before retrying.`; + } + throw new Error(message); + } + // 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> = { // 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, multisigRoots?.secp256k1Multisig), + ed25519Multisig: () => + this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise, multisigRoots?.ed25519Multisig), // 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), @@ -194,16 +319,22 @@ 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. + * With `external`, the backup is the caller-supplied public key (BYOK) added as a pub-only + * `source: 'backup'` key, and no backup private key is generated or encrypted. * Returns the [user, backup, bitgo] key id triplet. * @experimental */ private async createMultisigRoot( - slot: 'secp256k1Multisig' | 'ed25519Multisig', + slot: MultisigRootKeyType, safeId: string, passphrase: string, - enterprise: string + enterprise: string, + external?: SafeMultisigRootKeyOptions ): Promise { - const keychains = coinForRoot(this.bitgo, slot).keychains(); + const coin = coinForRoot(this.bitgo, slot); + const keychains = coin.keychains(); + // Validate the supplied pub before any key is created for this slot. + const externalBackupPub = external === undefined ? undefined : prepareExternalBackupPub(slot, coin, external); const userKeyPair = keychains.create(); const userKeychainPromise = keychains.add({ @@ -212,7 +343,10 @@ export class Safes implements ISafes { source: 'user', safeId, }); - const backupKeychainPromise = keychains.createBackup({ passphrase, safeId }); + const backupKeychainPromise: Promise = + externalBackupPub === undefined + ? keychains.createBackup({ passphrase, safeId }) + : keychains.add({ pub: externalBackupPub, source: 'backup', safeId, enterprise }); const bitgoKeychainPromise = keychains.createBitGo({ enterprise, safeId }); const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ diff --git a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts index e7b67b53ec9..3c60d15edf5 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts @@ -9,15 +9,18 @@ import { buildSafeMpcKeyEnvelopes } from '../../../../src/bitgo/utils/tss/keySha import { IncorrectPasswordError } from '../../../../src/bitgo/errors'; import { DecryptedSafeRoots, + DecryptedSafeRootsWithExternal, buildSafeRecoverKeyParams, decryptSafeKeycard, deriveSafeWalletKeys, } from '../../../../src/bitgo/safe/recovery'; import { algoMultisigAddress, + decodeEd25519StrKeySecretSeed, encodeAlgoKey, encodeDerivableEd25519Pub, encodeEd25519ChainCode, + signEd25519Extended, softDeriveChildPubEd25519AtPath, } from '../../../../src'; import { BitGoBase } from '../../../../src/bitgo/bitgoBase'; @@ -118,8 +121,9 @@ describe('decryptSafeKeycard', () => { 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)); + const ed25519Backup = result.ed25519Multisig.backup as { prv: string; chainCode: string }; + ed25519Backup.prv.should.equal('S-backup-seed'); + ed25519Backup.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')); @@ -186,8 +190,9 @@ describe('decryptSafeKeycard', () => { password: PASSWORD, }); - result.ed25519Multisig.backup.prv.should.equal('S-backup-seed'); - result.ed25519Multisig.backup.chainCode.should.equal('A'.repeat(52)); + const ed25519Backup = result.ed25519Multisig.backup as { prv: string; chainCode: string }; + ed25519Backup.prv.should.equal('S-backup-seed'); + ed25519Backup.chainCode.should.equal('A'.repeat(52)); }); it('rejects an ed25519 chain-code entry in a non-backup slot (fail closed)', async () => { @@ -815,3 +820,381 @@ describe('Safe ecdsaMpc derive → sign (gate)', function () { signatureString.should.match(/^\d+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); }); }); + +describe('external backup roots (BYOK)', () => { + afterEach(() => { + sinon.restore(); + }); + + const userRoot = bip32.fromSeed(Buffer.from('000102030405060708090a0b0c0d0e0f', 'hex')); + const bitgoRoot = bip32.fromSeed(Buffer.alloc(32, 0x2f)).neutered(); + // An HSM or hardware wallet exports an account-level key, never a master: depth 3 here. + const externalPrvRoot = bip32.fromSeed(Buffer.alloc(32, 0x09)).derivePath("m/44'/0'/0'"); + const externalPubRoot = externalPrvRoot.neutered(); + + describe('decryptSafeKeycard', () => { + const userBox = box({ + secp256k1Multisig: 'enc-user-secp', + ed25519Multisig: 'enc-user-ed25519', + ecdsaMpc: 'enc-user-ecdsa', + eddsaMpc: 'enc-user-eddsa', + }); + const bitgoBox = box({ + secp256k1Multisig: 'xpub-secp-bitgo', + ed25519Multisig: 'pub-ed25519-bitgo', + ecdsaMpc: 'common-ecdsa-bitgo', + eddsaMpc: 'common-eddsa-bitgo', + }); + const mpc = buildSafeMpcKeyEnvelopes(Buffer.from('signing'), Buffer.from('reduced'), Buffer.from('vrf')); + const decryptEntries = { + 'enc-user-secp': 'xprv-user', + 'enc-user-ed25519': 'S-user-seed', + 'enc-user-ecdsa': mpc.reducedEnvelope.toString('base64'), + 'enc-user-eddsa': mpc.reducedEnvelope.toString('base64'), + 'enc-backup-ecdsa': mpc.reducedEnvelope.toString('base64'), + 'enc-backup-eddsa': mpc.reducedEnvelope.toString('base64'), + }; + + // box() is typed to the local entries only; an external box is plain JSON on the wire. + const externalBackupBox = JSON.stringify({ + secp256k1Multisig: { kind: 'external', pub: 'xpub-external' }, + ed25519Multisig: { kind: 'external', pub: 'COMPOSITE', provider: 'dai' }, + ecdsaMpc: 'enc-backup-ecdsa', + eddsaMpc: 'enc-backup-eddsa', + }); + + it('returns external backup roots without trying to decrypt them', async () => { + const bitgo = makeMockBitGo(makeDecryptMap(decryptEntries)); + + const result = await decryptSafeKeycard({ + bitgo, + userKeyBox: userBox, + backupKeyBox: externalBackupBox, + bitgoKeyBox: bitgoBox, + password: PASSWORD, + }); + + result.secp256k1Multisig.user.should.equal('xprv-user'); + result.secp256k1Multisig.backup.should.deepEqual({ external: true, pub: 'xpub-external' }); + result.ed25519Multisig.backup.should.deepEqual({ external: true, pub: 'COMPOSITE', provider: 'dai' }); + const decrypted = (bitgo.decrypt as sinon.SinonStub).getCalls().map((call) => call.args[0].input); + decrypted.should.not.containEql('xpub-external'); + decrypted.should.not.containEql('COMPOSITE'); + }); + + it('rejects an external entry in the user box, where a bare string is required', async () => { + const bitgo = makeMockBitGo(makeDecryptMap(decryptEntries)); + const externalUserBox = JSON.stringify({ + secp256k1Multisig: { kind: 'external', pub: 'xpub-external' }, + ed25519Multisig: 'enc-user-ed25519', + ecdsaMpc: 'enc-user-ecdsa', + eddsaMpc: 'enc-user-eddsa', + }); + + await decryptSafeKeycard({ + bitgo, + userKeyBox: externalUserBox, + backupKeyBox: externalBackupBox, + bitgoKeyBox: bitgoBox, + password: PASSWORD, + }).should.be.rejectedWith(/unexpected external backup entry where a bare string was required/); + }); + }); + + describe('deriveSafeWalletKeys secp256k1Multisig', () => { + const roots: DecryptedSafeRootsWithExternal = { + ...makeRoots({ + user: userRoot.toBase58(), + backup: 'unused', + bitgo: bitgoRoot.toBase58(), + }), + secp256k1Multisig: { + user: userRoot.toBase58(), + backup: { external: true, pub: externalPubRoot.toBase58() }, + bitgo: bitgoRoot.toBase58(), + }, + }; + // account 0 on btc: m/44'/0'/1'/0' for the user, m/44/0/1/0 for the co-signers + const expectedBackupChild = externalPubRoot.derive(44).derive(0).derive(1).derive(0); + + it('derives the backup child pub level by level from a non-master xpub', async () => { + const result = await deriveSafeWalletKeys({ coin: 'btc', slot: 'secp256k1Multisig', account: 0, roots }); + if (result.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig slot'); + } + + externalPubRoot.depth.should.equal(3); + result.keys.backup.pub.should.equal(expectedBackupChild.toBase58()); + (result.keys.backup.prv === undefined).should.be.true(); + }); + + it('adds the backup child prv when the caller supplies their backup root xprv', async () => { + const result = await deriveSafeWalletKeys({ + coin: 'btc', + slot: 'secp256k1Multisig', + account: 0, + roots, + externalBackupRootPrv: externalPrvRoot.toBase58(), + }); + if (result.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig slot'); + } + + result.keys.backup.pub.should.equal(expectedBackupChild.toBase58()); + bip32 + .fromBase58(result.keys.backup.prv as string) + .neutered() + .toBase58() + .should.equal(result.keys.backup.pub); + }); + + it('rejects a backup root xprv that does not belong to the external root', async () => { + await deriveSafeWalletKeys({ + coin: 'btc', + slot: 'secp256k1Multisig', + account: 0, + roots, + externalBackupRootPrv: bip32.fromSeed(Buffer.alloc(32, 0x08)).derivePath("m/44'/0'/0'").toBase58(), + }).should.be.rejectedWith(/does not match the external backup root pub/); + }); + + it('rejects externalBackupRootPrv when the backup root is local', async () => { + const localRoots = makeRoots({ + user: userRoot.toBase58(), + backup: bip32.fromSeed(Buffer.alloc(32, 0x07)).toBase58(), + bitgo: bitgoRoot.toBase58(), + }); + await deriveSafeWalletKeys({ + coin: 'btc', + slot: 'secp256k1Multisig', + account: 0, + roots: localRoots, + externalBackupRootPrv: externalPrvRoot.toBase58(), + }).should.be.rejectedWith(/backup root is not external/); + }); + + it('rejects externalBackupRootPrv for any other slot', async () => { + await deriveSafeWalletKeys({ + coin: 'algo', + slot: 'ed25519Multisig', + account: 0, + roots, + externalBackupRootPrv: externalPrvRoot.toBase58(), + }).should.be.rejectedWith(/only applies to the secp256k1Multisig slot/); + }); + }); + + describe('buildSafeRecoverKeyParams secp256k1Multisig', () => { + const roots: DecryptedSafeRootsWithExternal = { + ...makeRoots({ user: 'x', backup: 'y', bitgo: 'z' }), + secp256k1Multisig: { + user: userRoot.toBase58(), + backup: { external: true, pub: externalPubRoot.toBase58() }, + bitgo: bitgoRoot.toBase58(), + }, + }; + + async function derive(externalBackupRootPrv?: string) { + const derived = await deriveSafeWalletKeys({ + coin: 'btc', + slot: 'secp256k1Multisig', + account: 0, + roots, + externalBackupRootPrv, + }); + if (derived.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig slot'); + } + return derived; + } + + it('unsigned mode needs no backup private key', async () => { + const derived = await derive(); + const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'btc', + slot: 'secp256k1Multisig', + keys: derived.keys, + mode: 'unsigned', + }); + + result.should.deepEqual({ + slot: 'secp256k1Multisig', + userKey: derived.keys.user.pub, + backupKey: derived.keys.backup.pub, + bitgoKey: derived.keys.bitgo.pub, + }); + (bitgo.encrypt as sinon.SinonStub).callCount.should.equal(0); + }); + + it('signed mode re-encrypts the supplied external backup child prv', async () => { + const derived = await derive(externalPrvRoot.toBase58()); + const encrypt = sinon.stub().callsFake(async ({ input }) => `enc(${input})`); + const bitgo = { encrypt } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'btc', + slot: 'secp256k1Multisig', + keys: derived.keys, + mode: 'signed', + }); + if (result.slot !== 'secp256k1Multisig') { + throw new Error('expected secp256k1Multisig params'); + } + + result.backupKey.should.equal(`enc(${derived.keys.backup.prv})`); + result.userKey.should.equal(`enc(${derived.keys.user.prv})`); + }); + + it('signed mode without the backup root xprv fails closed', async () => { + const derived = await derive(); + const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; + + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'btc', + slot: 'secp256k1Multisig', + keys: derived.keys, + mode: 'signed', + }).should.be.rejectedWith(/needs the backup private key.*externalBackupRootPrv/); + }); + }); + + describe('ed25519Multisig', () => { + const STRKEY_SEED = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + const backupChainCode = Buffer.alloc(32, 0xab); + // The BYOK holder's root pub is the ed25519 public key of its seed; the chain code rides on the keycard. + const backupComposite = encodeDerivableEd25519Pub( + Buffer.from(ed25519.getPublicKey(decodeEd25519StrKeySecretSeed(STRKEY_SEED))), + backupChainCode + ); + const bitgoComposite = encodeDerivableEd25519Pub( + Buffer.from(ed25519.getPublicKey(Buffer.alloc(32, 0x22))), + Buffer.alloc(32, 0xcd) + ); + const localRoots: DecryptedSafeRootsWithExternal = { + ...makeRoots({ user: 'x', backup: 'y', bitgo: 'z' }), + ed25519Multisig: { + user: STRKEY_SEED, + backup: { prv: STRKEY_SEED, chainCode: encodeEd25519ChainCode(backupChainCode) }, + bitgo: bitgoComposite, + }, + }; + const externalRoots: DecryptedSafeRootsWithExternal = { + ...localRoots, + ed25519Multisig: { + user: STRKEY_SEED, + backup: { external: true, pub: backupComposite }, + bitgo: bitgoComposite, + }, + }; + + async function derive(roots: DecryptedSafeRootsWithExternal) { + const derived = await deriveSafeWalletKeys({ coin: 'algo', slot: 'ed25519Multisig', account: 0, roots }); + if (derived.slot !== 'ed25519Multisig') { + throw new Error('expected ed25519Multisig slot'); + } + return derived; + } + + it('derives the external backup child pub, equal to the local derivation of the same root', async () => { + const local = await derive(localRoots); + const external = await derive(externalRoots); + + external.keys.backup.kind.should.equal('external'); + external.keys.backup.pub.should.equal(local.keys.backup.pub); + external.keys.backup.pub.should.equal( + (await softDeriveChildPubEd25519AtPath(backupComposite, 'm/44/283/2/0')).toString('hex') + ); + external.keys.user.pub.should.equal(local.keys.user.pub); + external.keys.bitgo.pub.should.equal(local.keys.bitgo.pub); + }); + + it('unsigned mode returns the encoded backup child without a signer', async () => { + const external = await derive(externalRoots); + const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: external.keys, + mode: 'unsigned', + }); + if (result.slot !== 'ed25519Multisig' || result.mode !== 'unsigned') { + throw new Error('expected unsigned ed25519Multisig params'); + } + + result.backupKey.should.equal(encodeAlgoKey(Buffer.from(external.keys.backup.pub, 'hex'))); + }); + + it('signed mode uses the holder-supplied signer for the external backup child', async () => { + const local = await derive(localRoots); + const external = await derive(externalRoots); + if (local.keys.backup.kind !== 'extended') { + throw new Error('expected an extended backup child'); + } + const { kL, kR } = local.keys.backup; + // The holder's own signer, standing in for an HSM that supports BIP32-Ed25519 soft derivation. + const holderSigner = { + publicKey: external.keys.backup.pub, + sign: async (message: Uint8Array) => ({ + signature: ( + await signEd25519Extended(Buffer.from(message), Buffer.from(kL, 'hex'), Buffer.from(kR, 'hex')) + ).toString('hex'), + }), + }; + const bitgo = { encrypt: sinon.stub().resolves('enc') } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: external.keys, + mode: 'signed', + externalBackupSigner: holderSigner, + }); + if (result.slot !== 'ed25519Multisig' || result.mode !== 'signed') { + throw new Error('expected signed ed25519Multisig params'); + } + + result.externalBackupSigner.should.equal(holderSigner); + }); + + it('signed mode fails closed without a signer, with a mismatched signer, or with a signer on a local root', async () => { + const local = await derive(localRoots); + const external = await derive(externalRoots); + const bitgo = { encrypt: sinon.stub().resolves('enc') } as unknown as BitGoBase; + const signer = (publicKey: string) => ({ publicKey, sign: async () => ({ signature: '00'.repeat(64) }) }); + + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: external.keys, + mode: 'signed', + }).should.be.rejectedWith(/needs externalBackupSigner/); + + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: external.keys, + mode: 'signed', + externalBackupSigner: signer('11'.repeat(32)), + }).should.be.rejectedWith(/does not match the backup child pub/); + + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: local.keys, + mode: 'signed', + externalBackupSigner: signer(local.keys.backup.pub), + }).should.be.rejectedWith(/only applies to an external backup root/); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts index 5eb236ec42b..dd83c2d10d2 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safes.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -1,7 +1,8 @@ import * as sinon from 'sinon'; import 'should'; import { InitializeSafeResponse } from '@bitgo/public-types'; -import { Enterprise, Safe, SafeKeys, Safes } from '../../../../src'; +import { bip32 } from '@bitgo/utxo-lib'; +import { Enterprise, Safe, SafeKeys, Safes, encodeEd25519StrKeyPublicKey } from '../../../../src'; /** A derivable slot-④ root: raw 32-byte public key plus raw 32-byte chain code in base32. */ const COMPOSITE_BACKUP_PUB = @@ -46,6 +47,25 @@ describe('Safes', function () { sinon.assert.calledWith(send, { label: 'my safe' }); }); + it('sends externalBackupSlots and decodes them from the response', async function () { + const wire = { id: 'test-safe-id', status: 'initializing', externalBackupSlots: ['secp256k1Multisig'] }; + const send = sinon.stub().returns({ result: sinon.stub().resolves(wire) }); + mockBitGo.post.returns({ send }); + + const result = await safes.initializeSafe({ label: 'my safe', externalBackupSlots: ['secp256k1Multisig'] }); + + sinon.assert.calledWith(send, { label: 'my safe', externalBackupSlots: ['secp256k1Multisig'] }); + result.externalBackupSlots?.should.deepEqual(['secp256k1Multisig']); + }); + + it('rejects an MPC slot in externalBackupSlots before sending anything', async function () { + const send = sinon.stub().returns({ result: sinon.stub().resolves({}) }); + mockBitGo.post.returns({ send }); + + await safes.initializeSafe({ label: 'my safe', externalBackupSlots: ['ecdsaMpc'] as any }).should.be.rejected(); + sinon.assert.notCalled(send); + }); + it('decodes enabledRootSlots when the server returns them', async function () { const initializeResponseWire = { id: 'test-safe-id', @@ -64,6 +84,14 @@ describe('Safes', function () { }); }); + // A real account-level (depth 3) xpub and its private counterpart. BitGo encodes every coin's keys + // as xpub/xprv, testnet roots included. + const EXTERNAL_PRV_ROOT = bip32.fromSeed(Buffer.alloc(32, 0x09)).derivePath("m/44'/0'/0'"); + const EXTERNAL_XPUB = EXTERNAL_PRV_ROOT.neutered().toBase58(); + const EXTERNAL_XPRV = EXTERNAL_PRV_ROOT.toBase58(); + // Slot ④ caller key as a Stellar StrKey, the format a caller's HSM or wallet usually exports. + const ED25519_STRKEY = encodeEd25519StrKeyPublicKey(Buffer.alloc(32, 7)); + describe('createSafeKeys', function () { // Per-coin keychains() mock: multisig coins use create/add/createBackup/createBitGo; MPC coins // use createMpc. Ids are derived from the coin name so we can assert per-slot coin routing. @@ -96,7 +124,17 @@ describe('Safes', function () { mockBitGo.encrypt = sinon.stub().resolves('encrypted-prv'); mockBitGo.coin = sinon.stub().callsFake((name: string) => { keychainsByCoin[name] = keychainsByCoin[name] || makeKeychains(name); - return { keychains: () => keychainsByCoin[name] }; + // mirrors the real utxo coin check: a base58 key that is neutered (an xpub, never an xprv) + return { + keychains: () => keychainsByCoin[name], + isValidPub: (pub: string) => { + try { + return bip32.fromBase58(pub).isNeutered(); + } catch (e) { + return false; + } + }, + }; }); }); @@ -248,6 +286,195 @@ describe('Safes', function () { keychainsByCoin.should.not.have.property('txlm'); }); + describe('external (BYOK) backup roots', function () { + const archiveOk = () => { + const send = sinon.stub().returns({ result: sinon.stub().resolves({ ...safeDataWire, status: 'archived' }) }); + mockBitGo.post.returns({ send }); + }; + + it('registers a supplied secp256k1 backup xpub as a pub-only backup key and never generates one', async function () { + const result = await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { secp256k1Multisig: { backupXpub: EXTERNAL_XPUB } }, + externalBackupSlots: ['secp256k1Multisig'], + }); + + const btc = keychainsByCoin['tbtc']; + sinon.assert.notCalled(btc.createBackup); + sinon.assert.calledWith(btc.add, { + pub: EXTERNAL_XPUB, + source: 'backup', + safeId: 'safe-1', + enterprise: 'test-enterprise-id', + }); + // user key add is untouched, and the other slots still generate a local backup + sinon.assert.calledWithMatch(btc.add, { source: 'user', safeId: 'safe-1' }); + sinon.assert.calledWithMatch(keychainsByCoin['txlm'].createBackup, { passphrase: 'pw', safeId: 'safe-1' }); + result.rootKeys.hot.secp256k1Multisig?.should.deepEqual(['tbtc-user', 'tbtc-user', 'tbtc-bitgo']); + }); + + it('converts a slot ④ StrKey into the derivable format before registering it', async function () { + keychainsByCoin['txlm'] = makeKeychains('txlm'); + keychainsByCoin['txlm'].add = sinon + .stub() + .callsFake(async (params: any) => ({ id: 'txlm-user', pub: params.pub })); + + await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { ed25519Multisig: { backupXpub: ED25519_STRKEY } }, + externalBackupSlots: ['ed25519Multisig'], + }); + + const txlm = keychainsByCoin['txlm']; + sinon.assert.notCalled(txlm.createBackup); + const backupAdd = txlm.add.getCalls().find((call: any) => call.args[0].source === 'backup'); + if (!backupAdd) { + throw new Error('expected the backup key to be added'); + } + backupAdd.args[0].pub.should.have.length(103); + // the derivable pub starts with the caller's raw public key, followed by the fresh chain code + backupAdd.args[0].pub.should.not.equal(ED25519_STRKEY); + sinon.assert.calledWithMatch(txlm.add, { + source: 'backup', + safeId: 'safe-1', + enterprise: 'test-enterprise-id', + }); + }); + + it('registers an already-derivable slot ④ pub as is', async function () { + keychainsByCoin['txlm'] = makeKeychains('txlm'); + keychainsByCoin['txlm'].add = sinon + .stub() + .callsFake(async (params: any) => ({ id: 'txlm-user', pub: params.pub })); + + await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { ed25519Multisig: { backupXpub: COMPOSITE_BACKUP_PUB } }, + externalBackupSlots: ['ed25519Multisig'], + }); + + sinon.assert.calledWithMatch(keychainsByCoin['txlm'].add, { pub: COMPOSITE_BACKUP_PUB, source: 'backup' }); + }); + + it('rejects a slot ④ backup that is not an ed25519 key before creating any key for the slot', async function () { + archiveOk(); + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { ed25519Multisig: { backupXpub: 'not-a-key' } }, + externalBackupSlots: ['ed25519Multisig'], + enabledRootSlots: ['ed25519Multisig'], + }) + .should.be.rejectedWith( + /Safe key generation failed for 1 root.*ed25519Multisig backupXpub must be an ed25519/ + ); + sinon.assert.notCalled(keychainsByCoin['txlm'].add); + sinon.assert.notCalled(keychainsByCoin['txlm'].createBitGo); + }); + + it('rejects a private key as the slot ① backup and creates no key for the slot', async function () { + archiveOk(); + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { secp256k1Multisig: { backupXpub: EXTERNAL_XPRV } }, + externalBackupSlots: ['secp256k1Multisig'], + enabledRootSlots: ['secp256k1Multisig'], + }) + .should.be.rejectedWith(/backupXpub must be a valid xpub/); + sinon.assert.notCalled(keychainsByCoin['tbtc'].add); + sinon.assert.notCalled(keychainsByCoin['tbtc'].createBitGo); + }); + + it('throws and archives when a backup key is supplied for a slot the server did not declare', async function () { + archiveOk(); + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { secp256k1Multisig: { backupXpub: EXTERNAL_XPUB } }, + }) + .should.be.rejectedWith(/supplied but not declared at initialize: secp256k1Multisig.*has been archived/); + sinon.assert.calledWith(mockBitGo.post, '/enterprise/test-enterprise-id/safes/safe-1/archive'); + keychainsByCoin.should.not.have.property('tbtc'); + }); + + it('throws when the server declared an external slot but no backup key was supplied', async function () { + archiveOk(); + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + externalBackupSlots: ['ed25519Multisig'], + }) + .should.be.rejectedWith(/declared at initialize but no backup key supplied: ed25519Multisig/); + }); + + it('throws when the supplied slot is not an enabled slot', async function () { + archiveOk(); + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { secp256k1Multisig: { backupXpub: EXTERNAL_XPUB } }, + externalBackupSlots: ['secp256k1Multisig'], + enabledRootSlots: ['ed25519Multisig'], + }) + .should.be.rejectedWith(/slot that is not enabled: secp256k1Multisig/); + }); + + it('rejects a non-multisig key in multisigRoots, so an MPC ceremony never sees the option', async function () { + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { ecdsaMpc: { backupXpub: EXTERNAL_XPUB } } as any, + }) + .should.be.rejectedWith(/multisigRoots only supports the multisig slots.*ecdsaMpc/); + keychainsByCoin.should.not.have.property('hteth'); + }); + + it('passes no external option to the MPC ceremonies', async function () { + await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { secp256k1Multisig: { backupXpub: EXTERNAL_XPUB } }, + externalBackupSlots: ['secp256k1Multisig'], + }); + + for (const coin of ['hteth', 'tsol']) { + const callArgs = keychainsByCoin[coin].createMpc.firstCall.args[0]; + Object.keys(callArgs).should.deepEqual(['multisigType', 'passphrase', 'enterprise', 'safeId']); + } + }); + + it('rejects an empty backupXpub', async function () { + await safes + .createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + multisigRoots: { secp256k1Multisig: { backupXpub: '' } }, + }) + .should.be.rejectedWith(/multisigRoots.secp256k1Multisig.backupXpub must be a non-empty string/); + }); + }); + 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'); @@ -343,6 +570,47 @@ describe('Safes', function () { sinon.assert.calledWith(finalizeStub, 'test-safe-id', rootKeys); sinon.assert.callOrder(initStub, keysStub, finalizeStub); result.status().should.equal('active'); + // no multisigRoots: initialize carries only the label, as before + sinon.assert.calledWith(initStub, { label: 'my safe' }); + }); + + it('declares the supplied backup slots at initialize and threads the server echo into the ceremonies', async function () { + const initializing: InitializeSafeResponse = { + id: 'test-safe-id', + status: 'initializing', + externalBackupSlots: ['ed25519Multisig', 'secp256k1Multisig'], + }; + const initStub = sinon.stub(safes, 'initializeSafe').resolves(initializing); + const keysStub = sinon.stub(safes, 'createSafeKeys').resolves({ rootKeys: { hot: {} } }); + sinon.stub(safes, 'finalizeSafe').resolves(new Safe(mockBitGo, safeDataWire as any)); + const multisigRoots = { + secp256k1Multisig: { backupXpub: EXTERNAL_XPUB }, + ed25519Multisig: { backupXpub: ED25519_STRKEY }, + }; + + await safes.generateSafe({ label: 'my safe', passphrase: 'pw', multisigRoots }); + + // declared in canonical slot order, whatever order the caller listed them in + sinon.assert.calledWith(initStub, { + label: 'my safe', + externalBackupSlots: ['secp256k1Multisig', 'ed25519Multisig'], + }); + sinon.assert.calledWithMatch(keysStub, { + label: 'my safe', + passphrase: 'pw', + safeId: 'test-safe-id', + multisigRoots, + externalBackupSlots: ['ed25519Multisig', 'secp256k1Multisig'], + }); + }); + + it('rejects an invalid multisigRoots before initializing, so no safe is created', async function () { + const initStub = sinon.stub(safes, 'initializeSafe'); + + await safes + .generateSafe({ label: 'my safe', passphrase: 'pw', multisigRoots: { eddsaMpc: { backupXpub: 'x' } } as any }) + .should.be.rejectedWith(/multisigRoots only supports the multisig slots/); + sinon.assert.notCalled(initStub); }); }); diff --git a/modules/sdk-lib-safes/package.json b/modules/sdk-lib-safes/package.json index 911a9fb0885..53272d81e08 100644 --- a/modules/sdk-lib-safes/package.json +++ b/modules/sdk-lib-safes/package.json @@ -31,7 +31,7 @@ "access": "public" }, "dependencies": { - "@bitgo/public-types": "6.90.0", + "@bitgo/public-types": "7.1.0", "@bitgo/statics": "^59.26.0", "@bitgo/utxo-lib": "^11.25.0", "create-hmac": "^1.1.7", diff --git a/modules/sdk-lib-safes/src/keycardCodec.ts b/modules/sdk-lib-safes/src/keycardCodec.ts index 2e5825954b3..7f32bf735e9 100644 --- a/modules/sdk-lib-safes/src/keycardCodec.ts +++ b/modules/sdk-lib-safes/src/keycardCodec.ts @@ -26,16 +26,38 @@ export type SafeRootKeyType = RootKeyType; * 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. + * seed), and an external backup slot carries `{ kind: 'external', pub }`; 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; +/** + * Box B entry for a slot whose backup root is external: the caller (BYOK) or a KRS provider holds the + * backup private key, so the card carries only the root public key. For `ed25519Multisig` the pub is + * the derivable composite (`pub32 || chainCode32`), so the chain code survives. `provider` names the + * KRS provider and is absent for a caller-supplied key. Additive: boxes without it still decode. + */ +export const ExternalBackupEntryCodec = t.intersection([ + t.type({ kind: t.literal('external'), pub: t.string }), + t.partial({ provider: t.string }), +]); +export type ExternalBackupEntry = t.TypeOf; + +/** + * What `decryptSafeKeycard` returns for an external backup root: there is nothing to decrypt. + */ +export type ExternalBackupRoot = { external: true; pub: string; provider?: string }; + +export type SafeKeycardRootEntry = string | Ed25519ChainCodeEntry | ExternalBackupEntry; export type SafeKeycardRoots = Record; -const SafeKeycardRootEntryCodec: t.Type = t.union([t.string, Ed25519ChainCodeEntryCodec]); +const SafeKeycardRootEntryCodec: t.Type = t.union([ + t.string, + Ed25519ChainCodeEntryCodec, + ExternalBackupEntryCodec, +]); // Annotating with SafeKeycardRoots makes the compiler reject a key added to or removed from one side only. const SafeKeycardRootsCodec: t.Type = t.type({ @@ -51,7 +73,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 not a bare string or a `{ prv, chainCode }` entry. Recovery tooling calls this on the A/B/C + * not valid JSON or any root is not a bare string, a `{ prv, chainCode }` entry or an external backup 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/src/safeDerivation.ts b/modules/sdk-lib-safes/src/safeDerivation.ts index f3f64c032c8..7b522512da0 100644 --- a/modules/sdk-lib-safes/src/safeDerivation.ts +++ b/modules/sdk-lib-safes/src/safeDerivation.ts @@ -17,6 +17,7 @@ import { bip32, BIP32Interface } from '@bitgo/utxo-lib'; import { coins, MAX_BIP32_INDEX, SAFE_ROOT_SLOT_ORDINALS } from '@bitgo/statics'; import type { RootKeyType } from '@bitgo/public-types'; import { Ed25519KeyDeriver } from './ed25519KeyDeriver'; +import type { ExternalBackupRoot } from './keycardCodec'; import { decodeEd25519StrKeySecretSeed, encodeEd25519StrKeyPublicKey } from './ed25519Pub'; const HARDENED_BIP44_PATH = /^m\/44'\/(\d+)'\/(\d+)'\/(\d+)'$/; @@ -236,19 +237,56 @@ export interface SafeSecp256k1ChildTriplet { bitgo: { pub: string }; } +/** + * Triplet for a safe whose backup root is external: the backup child is pub-only unless the caller + * supplied the root xprv from their own storage. + */ +export interface SafeSecp256k1ExternalBackupChildTriplet { + user: { prv: string; pub: string }; + backup: { prv?: string; pub: string }; + bitgo: { pub: string }; +} + +/** + * Derives a soft path one level at a time. Unlike `derivePath('m/…')`, which throws "Expected master, + * got child" on any key that is not depth 0, this works on a root exported at any depth (an HSM or + * hardware-wallet account xpub). + */ +export function deriveSoftPathRelative(node: BIP32Interface, path: DerivedFromParentWithSoftPath): BIP32Interface { + assertBip32Path(path, { hardened: false }); + return path + .split('/') + .slice(1) + .reduce((current, segment) => current.derive(Number(segment)), node); +} + /** * Derives the slot-① (secp256k1Multisig) child triplet from the keycard roots: the user child at * the hardened `path`, the backup and BitGo children at its soft twin. + * + * A backup root that is an {@link ExternalBackupRoot} is a public key only. Its child is derived + * relatively (it is usually not a master key), and is pub-only unless `externalBackupRootPrv` — the + * caller's own backup xprv — is supplied, in which case the derived private child must match it. + * Local roots keep the strict master-key derivation. */ export function deriveSafeChildTriplet( roots: { user: string; backup: string; bitgo: string }, path: DerivedFromParentWithHardenedPath -): SafeSecp256k1ChildTriplet { +): SafeSecp256k1ChildTriplet; +export function deriveSafeChildTriplet( + roots: { user: string; backup: string | ExternalBackupRoot; bitgo: string }, + path: DerivedFromParentWithHardenedPath, + opts?: { externalBackupRootPrv?: string } +): SafeSecp256k1ExternalBackupChildTriplet; +export function deriveSafeChildTriplet( + roots: { user: string; backup: string | ExternalBackupRoot; bitgo: string }, + path: DerivedFromParentWithHardenedPath, + opts: { externalBackupRootPrv?: string } = {} +): SafeSecp256k1ExternalBackupChildTriplet { assertHardenedPath(path); const userRoot = bip32.fromBase58(roots.user); - const backupRoot = bip32.fromBase58(roots.backup); const bitgoRoot = bip32.fromBase58(roots.bitgo); - if (userRoot.isNeutered() || backupRoot.isNeutered()) { + if (userRoot.isNeutered()) { throw new Error('deriveSafeChildTriplet: user and backup roots must be private (xprv)'); } if (!bitgoRoot.isNeutered()) { @@ -256,10 +294,33 @@ export function deriveSafeChildTriplet( } const softPath = toSoftTwinPath(path); const user = userRoot.derivePath(path); - const backup = backupRoot.derivePath(softPath); - return { - user: { prv: user.toBase58(), pub: user.neutered().toBase58() }, - backup: { prv: backup.toBase58(), pub: backup.neutered().toBase58() }, - bitgo: { pub: bitgoRoot.derivePath(softPath).toBase58() }, - }; + const bitgo = { pub: bitgoRoot.derivePath(softPath).toBase58() }; + const userChild = { prv: user.toBase58(), pub: user.neutered().toBase58() }; + + if (typeof roots.backup === 'string') { + const backupRoot = bip32.fromBase58(roots.backup); + if (backupRoot.isNeutered()) { + throw new Error('deriveSafeChildTriplet: user and backup roots must be private (xprv)'); + } + const backup = backupRoot.derivePath(softPath); + return { user: userChild, backup: { prv: backup.toBase58(), pub: backup.neutered().toBase58() }, bitgo }; + } + + const externalRoot = bip32.fromBase58(roots.backup.pub); + if (!externalRoot.isNeutered()) { + throw new Error('deriveSafeChildTriplet: external backup root must be public (xpub)'); + } + const backupPub = deriveSoftPathRelative(externalRoot, softPath).toBase58(); + if (opts.externalBackupRootPrv === undefined) { + return { user: userChild, backup: { pub: backupPub }, bitgo }; + } + const prvRoot = bip32.fromBase58(opts.externalBackupRootPrv); + if (prvRoot.isNeutered()) { + throw new Error('deriveSafeChildTriplet: externalBackupRootPrv must be private (xprv)'); + } + const backupPrvChild = deriveSoftPathRelative(prvRoot, softPath); + if (backupPrvChild.neutered().toBase58() !== backupPub) { + throw new Error('deriveSafeChildTriplet: externalBackupRootPrv does not match the external backup root pub'); + } + return { user: userChild, backup: { prv: backupPrvChild.toBase58(), pub: backupPub }, bitgo }; } diff --git a/modules/sdk-lib-safes/test/unit/keycardCodec.ts b/modules/sdk-lib-safes/test/unit/keycardCodec.ts index 1dde073689b..bebc2ce4e0e 100644 --- a/modules/sdk-lib-safes/test/unit/keycardCodec.ts +++ b/modules/sdk-lib-safes/test/unit/keycardCodec.ts @@ -38,5 +38,34 @@ describe('keycardCodec', function () { const mixed = { ...fullBox, ed25519Multisig: entry }; parseSafeKeycardBox(JSON.stringify(mixed)).ed25519Multisig.should.deepEqual(entry); }); + + describe('external backup entry', function () { + const external = { kind: 'external', pub: 'xpub-external-backup' }; + + it('accepts an external entry for a multisig slot', function () { + const box = { ...fullBox, secp256k1Multisig: external }; + parseSafeKeycardBox(JSON.stringify(box)).secp256k1Multisig.should.deepEqual(external); + }); + + it('keeps the provider when the entry carries one', function () { + const withProvider = { ...external, provider: 'dai' }; + const box = { ...fullBox, ed25519Multisig: withProvider }; + parseSafeKeycardBox(JSON.stringify(box)).ed25519Multisig.should.deepEqual(withProvider); + }); + + it('still decodes a keycard generated before external entries existed', function () { + parseSafeKeycardBox(JSON.stringify(fullBox)).should.deepEqual(fullBox); + }); + + it('rejects an external entry without a pub', function () { + const box = { ...fullBox, secp256k1Multisig: { kind: 'external' } }; + (() => parseSafeKeycardBox(JSON.stringify(box))).should.throw(/secp256k1Multisig/); + }); + + it('rejects an entry with an unknown kind', function () { + const box = { ...fullBox, secp256k1Multisig: { kind: 'other', pub: 'xpub' } }; + (() => parseSafeKeycardBox(JSON.stringify(box))).should.throw(/secp256k1Multisig/); + }); + }); }); }); diff --git a/modules/sdk-lib-safes/test/unit/safeDerivation.ts b/modules/sdk-lib-safes/test/unit/safeDerivation.ts index a2a959ffb49..0fc4ad6f944 100644 --- a/modules/sdk-lib-safes/test/unit/safeDerivation.ts +++ b/modules/sdk-lib-safes/test/unit/safeDerivation.ts @@ -6,6 +6,7 @@ import { deriveSafeChildEd25519Hardened, deriveSafeChildHardenedFromXprv, deriveSafeChildTriplet, + deriveSoftPathRelative, DerivedFromParentWithHardenedPath, DerivedFromParentWithSoftPath, getSafeBip44CoinType, @@ -225,4 +226,76 @@ describe('safeDerivation', function () { (() => deriveSafeChildTriplet({ ...roots, bitgo: bitgoRoot.toBase58() }, path)).should.throw(/must be public/); }); }); + + describe('deriveSoftPathRelative', function () { + it('matches derivePath on a master key', function () { + const master = bip32.fromSeed(Buffer.alloc(16, 3)); + const softPath = toSoftTwinPath(buildSafeBip44Path(0, 'secp256k1Multisig', 5)); + deriveSoftPathRelative(master, softPath).toBase58().should.equal(master.derivePath(softPath).toBase58()); + }); + }); + + describe('deriveSafeChildTriplet with an external backup root', function () { + const userRoot = bip32.fromSeed(Buffer.from(BIP32_VECTOR_1_SEED, 'hex')); + const bitgoRoot = bip32.fromSeed(Buffer.alloc(16, 2)); + // An HSM or hardware wallet exports an account-level key, never a master: depth 3 here. + const externalPrvRoot = bip32.fromSeed(Buffer.alloc(16, 9)).derivePath("m/44'/0'/0'"); + const externalPubRoot = externalPrvRoot.neutered(); + const path = buildSafeBip44Path(0, 'secp256k1Multisig', 3); + const softPath = toSoftTwinPath(path); + const externalRoots = { + user: userRoot.toBase58(), + backup: { external: true as const, pub: externalPubRoot.toBase58() }, + bitgo: bitgoRoot.neutered().toBase58(), + }; + // level-by-level derivation, which is what a non-master key requires + const expectedChild = externalPubRoot.derive(44).derive(0).derive(1).derive(3); + + it('has a depth-3 root that derivePath rejects, so the relative derive is required', function () { + externalPubRoot.depth.should.equal(3); + (() => externalPubRoot.derivePath(softPath)).should.throw(/Expected master/); + }); + + it('derives the backup child pub relatively from a depth-3 xpub', function () { + const triplet = deriveSafeChildTriplet(externalRoots, path); + triplet.backup.pub.should.equal(expectedChild.toBase58()); + (triplet.backup.prv === undefined).should.be.true(); + triplet.bitgo.pub.should.equal(bitgoRoot.neutered().derivePath(softPath).toBase58()); + triplet.user.pub.should.equal(deriveSafeChildHardenedFromXprv(externalRoots.user, path).pub); + }); + + it('adds the backup child prv from externalBackupRootPrv', function () { + const triplet = deriveSafeChildTriplet(externalRoots, path, { + externalBackupRootPrv: externalPrvRoot.toBase58(), + }); + triplet.backup.pub.should.equal(expectedChild.toBase58()); + bip32 + .fromBase58(triplet.backup.prv as string) + .neutered() + .toBase58() + .should.equal(triplet.backup.pub); + }); + + it('rejects an externalBackupRootPrv that does not match the external root pub', function () { + const other = bip32.fromSeed(Buffer.alloc(16, 8)).derivePath("m/44'/0'/0'"); + (() => deriveSafeChildTriplet(externalRoots, path, { externalBackupRootPrv: other.toBase58() })).should.throw( + /does not match the external backup root pub/ + ); + }); + + it('rejects an externalBackupRootPrv that is not private', function () { + (() => + deriveSafeChildTriplet(externalRoots, path, { + externalBackupRootPrv: externalPubRoot.toBase58(), + })).should.throw(/must be private/); + }); + + it('rejects an external backup root that is private', function () { + (() => + deriveSafeChildTriplet( + { ...externalRoots, backup: { external: true, pub: externalPrvRoot.toBase58() } }, + path + )).should.throw(/external backup root must be public/); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index c400f9cff89..5b315255382 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1045,10 +1045,10 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" -"@bitgo/public-types@6.91.0": - version "6.91.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.91.0.tgz#b67349150359bf648bd878a1a4382c9e151ed673" - integrity sha512-aDonUKK48GQApzyF9+LTs6aVNEyW0IO42rxNj9lvBFjkHyayBbBRaoY7ieWF1FUKLv+0eGwjjS3/WfRo6BfCgA== +"@bitgo/public-types@7.1.0": + version "7.1.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-7.1.0.tgz#659956b7dd280d5ba2f191783cf7e66af9dedaff" + integrity sha512-7HiK2ajVN2TVDc/YphQy/xPRAzoRIWsIMWLFn1k7ad/nnXYTtioTwrBIg2NzWSjaz2lAGy2hS2AkgBuf3x4GfQ== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4"