From 94a2b96d5de1f448c4aa0a77ece94854d316a3ed Mon Sep 17 00:00:00 2001 From: Sibi Krishnan Date: Fri, 25 Sep 2026 11:17:59 -0400 Subject: [PATCH] feat(express): add private Express endpoints for client-side Safe generation Ticket: WCN-2755 --- modules/express/README.md | 18 +- modules/express/package.json | 3 +- modules/express/src/clientRoutes.ts | 53 +++ modules/express/src/typedRoutes/api/index.ts | 19 +- .../src/typedRoutes/api/v2/generateSafe.ts | 64 ++++ .../typedRoutes/api/v2/generateSafeKeys.ts | 63 ++++ .../typedRoutes/api/v2/generateSafeWallet.ts | 68 ++++ .../test/unit/clientRoutes/generateSafe.ts | 330 ++++++++++++++++++ .../test/unit/typedRoutes/generateSafe.ts | 273 +++++++++++++++ 9 files changed, 888 insertions(+), 3 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/generateSafe.ts create mode 100644 modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts create mode 100644 modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts create mode 100644 modules/express/test/unit/clientRoutes/generateSafe.ts create mode 100644 modules/express/test/unit/typedRoutes/generateSafe.ts diff --git a/modules/express/README.md b/modules/express/README.md index f21074cdb6d..12273b7af05 100644 --- a/modules/express/README.md +++ b/modules/express/README.md @@ -232,7 +232,7 @@ BitGo Express is able to take configuration options from either command line arg | N/A | --disableproxy | `BITGO_DISABLE_PROXY` 0 | N/A | Disable proxying of routes not explicitly handled by bitgo-express | | N/A | --disableenvcheck | `BITGO_DISABLE_ENV_CHECK` 0 | N/A | Disable checking for correct `NODE_ENV` environment variable when running against BitGo production environment. | | -i | --ipc | `BITGO_IPC` | N/A | If set, bind to the given IPC (unix domain) socket. Binding to an IPC socket can be useful if the caller of bitgo-express resides on the same host as the bitgo-express instance itself, since the socket can be secured using normal file permissions and ownership semantics. Note: This is not supported on Windows platforms. | -| N/A | --authversion | `BITGO_AUTH_VERSION` | 2 | BitGo Authentication scheme version which should be used form making requests to the BitGo server. For more info on authentication scheme versions, view the API reference on the BitGo [Developer Portal](https://developers.bitgo.com/reference/overview#/). | +| N/A | --authversion | `BITGO_AUTH_VERSION` | 2 | BitGo Authentication scheme version which should be used form making requests to the BitGo server. For more info on authentication scheme versions, view the API reference on the BitGo [Developer Portal](https://developers.bitgo.com/reference/overview#/). | | N/A | --externalSignerUrl | `BITGO_EXTERNAL_SIGNER_URL` | N/A | URL specifying the external API to call for remote signing. | | N/A | --signerMode | `BITGO_SIGNER_MODE ` | N/A | If set, run Express as a remote signer. | | N/A | --signerFileSystemPath | `BITGO_SIGNER_FILE_SYSTEM_PATH ` | N/A | Local path specifying where an Express signer machine keeps the encrypted user private keys. Required when signerMode is set. | @@ -265,6 +265,22 @@ To enable the `bitgo:v2:utxo` and `bitgo:express` debug namespaces, start BitGo Wildcards using `*` are also supported. For example, all bitgo debug namespaces can be enabled with `--debug-namespace bitgo:*`, but beware, this can be very noisy. +## Wallet Safes (client-side generation) + +Safe creation and child-wallet minting run local key ceremonies, so they must be called through BitGo Express rather than the BitGo API. The passphrase never leaves the Express host. Production Express requires TLS. + +| Method | Path | SDK method | +| ------ | ------------------------------------------------------------------- | ---------------------- | +| POST | `/api/v2/enterprise/{enterpriseId}/safes/generate` | `Safes.generateSafe` | +| POST | `/api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets/generate` | `Safe.createWallet` | +| POST | `/api/v2/enterprise/{enterpriseId}/safes/{safeId}/keys/generate` | `Safes.createSafeKeys` | + +`POST /api/v2/enterprise/{enterpriseId}/safes` (without `/generate`) is initialize-only and is proxied to the BitGo API. Listing, get, finalize, archive, freeze, and update are also proxied. + +Default Express timeout is 305 seconds. `generateSafe` can run up to four parallel root-key ceremonies; raise `--timeout` if a run is cut off. + +When signing a safe child wallet through existing Express send/sign routes, `WALLET_{walletId}_PASSPHRASE` is the **safe** passphrase (it decrypts the root user keychain). + # Release Notes You can find the complete release notes (since version 4.44.0) [here](https://github.com/BitGo/BitGoJS/blob/master/CHANGELOG.md). diff --git a/modules/express/package.json b/modules/express/package.json index f62acb182c5..05f8a4e9f2e 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -41,6 +41,7 @@ "@api-ts/typed-express-router": "2.0.0", "@bitgo/abstract-lightning": "^8.3.9", "@bitgo/logger": "^1.4.0", + "@bitgo/public-types": "6.79.0", "@bitgo/sdk-core": "^38.19.0", "@bitgo/utxo-lib": "^11.24.4", "@types/proxyquire": "^1.3.31", @@ -60,7 +61,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.79.0", + "@bitgo/sdk-lib-mpc": "^11.0.0", "@bitgo/sdk-test": "^9.1.80", "@types/argparse": "^1.0.36", diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index bdcad288abe..f080cb87a4c 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -30,6 +30,7 @@ import { IncorrectPasswordError, MPCType, multisigTypes, + Safes, ShareType, SignShare, SShare, @@ -704,6 +705,50 @@ export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'expres return { ...result, wallet: result.wallet.toJSON() }; } +/** + * One-shot Wallet Safe creation: initialize → local root-key ceremonies → finalize. + */ +export async function handleV2GenerateSafe( + req: ExpressApiRouteRequest<'express.v2.safes.generate', 'post'> +): Promise { + const safes = new Safes(req.bitgo, req.decoded.enterpriseId); + const safe = await safes.generateSafe({ + label: req.decoded.label, + passphrase: req.decoded.passphrase, + }); + return safe.toJSON(); +} + +/** + * Mint a hot child wallet from a Wallet Safe (onchain hardened derive or TSS ceremony). + */ +export async function handleV2GenerateSafeWallet( + req: ExpressApiRouteRequest<'express.v2.safes.wallet.generate', 'post'> +) { + const safes = new Safes(req.bitgo, req.decoded.enterpriseId); + const safe = await safes.get({ id: req.decoded.safeId }); + const wallet = await safe.createWallet({ + coin: req.decoded.coin, + label: req.decoded.label, + passphrase: req.decoded.passphrase, + multisigType: req.decoded.multisigType, + }); + return wallet.toJSON(); +} + +/** + * Phase-2-only Safe key ceremonies for an already-initialized safe. + */ +export async function handleV2GenerateSafeKeys(req: ExpressApiRouteRequest<'express.v2.safes.keys.generate', 'post'>) { + const safes = new Safes(req.bitgo, req.decoded.enterpriseId); + return safes.createSafeKeys({ + label: '', + passphrase: req.decoded.passphrase, + safeId: req.decoded.safeId, + enabledRootSlots: req.decoded.enabledRootSlots, + }); +} + /** * Independently verify newly created address(es) against client-supplied trusted keychains. * @@ -2103,6 +2148,14 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { // generate wallet router.post('express.wallet.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateWallet)]); + // generate wallet safe / mint a child wallet from a safe / phase-2 key ceremonies + router.post('express.v2.safes.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateSafe)]); + router.post('express.v2.safes.wallet.generate', [ + prepareBitGo(config), + typedPromiseWrapper(handleV2GenerateSafeWallet), + ]); + router.post('express.v2.safes.keys.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateSafeKeys)]); + router.put('express.wallet.update', [prepareBitGo(config), typedPromiseWrapper(handleWalletUpdate)]); // change wallet passphrase diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 85adc8a2eae..5df558606aa 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -35,6 +35,9 @@ import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; import { PostGenerateWallet } from './v2/generateWallet'; +import { PostGenerateSafe } from './v2/generateSafe'; +import { PostGenerateSafeWallet } from './v2/generateSafeWallet'; +import { PostGenerateSafeKeys } from './v2/generateSafeKeys'; import { PostSignerMacaroon } from './v2/signerMacaroon'; import { PostCoinSignTx } from './v2/coinSignTx'; import { PostWalletSignTx } from './v2/walletSignTx'; @@ -356,6 +359,18 @@ export const ExpressWalletManagementApiSpec = apiSpec({ }, }); +export const ExpressSafesApiSpec = apiSpec({ + 'express.v2.safes.generate': { + post: PostGenerateSafe, + }, + 'express.v2.safes.wallet.generate': { + post: PostGenerateSafeWallet, + }, + 'express.v2.safes.keys.generate': { + post: PostGenerateSafeKeys, + }, +}); + export const ExpressV2CanonicalAddressApiSpec = apiSpec({ 'express.canonicaladdress': { post: PostCanonicalAddress, @@ -429,7 +444,8 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressV2WalletResourceDelegationsApiSpec & typeof ExpressV2WalletDelegateResourcesApiSpec & typeof ExpressV2WalletUndelegateResourcesApiSpec & - typeof ExpressWalletManagementApiSpec; + typeof ExpressWalletManagementApiSpec & + typeof ExpressSafesApiSpec; export const ExpressApi: ExpressApi = { ...ExpressPingApiSpec, @@ -476,6 +492,7 @@ export const ExpressApi: ExpressApi = { ...ExpressV2WalletDelegateResourcesApiSpec, ...ExpressV2WalletUndelegateResourcesApiSpec, ...ExpressWalletManagementApiSpec, + ...ExpressSafesApiSpec, }; type ExtractDecoded = T extends t.Type ? O : never; diff --git a/modules/express/src/typedRoutes/api/v2/generateSafe.ts b/modules/express/src/typedRoutes/api/v2/generateSafe.ts new file mode 100644 index 00000000000..c9194cec8cf --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/generateSafe.ts @@ -0,0 +1,64 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for one-shot Safe generation. + */ +export const GenerateSafeParams = { + /** Enterprise public id that owns the safe */ + enterpriseId: t.string, +} as const; + +/** + * Request body for one-shot Safe generation. + * + * `enabledRootSlots` is decided by the server at initialize (Flipt); do not send it here. + */ +export const GenerateSafeBody = { + /** Safe label */ + label: t.string, + /** Passphrase used to encrypt locally generated root user/backup keys and to run MPC ceremonies */ + passphrase: t.string, +} as const; + +/** + * Response body for one-shot Safe generation. + */ +export const GenerateSafeResponse = { + /** The newly created, finalized safe */ + 200: t.UnknownRecord, + /** Bad request */ + 400: BitgoExpressError, +} as const; + +/** + * Generate Safe + * + * One-shot Wallet Safe creation for hot custody. Runs locally on BitGo Express: + * + * 1. Initialize the safe on BitGo (metadata only). + * 2. Run the enabled root-key ceremonies on this machine (multisig keygen + MPC), encrypted with `passphrase`. + * 3. Finalize the safe with the minted root key ids. + * + * If a ceremony fails, the SDK archives the half-created safe and throws with the per-slot failure list. + * Create a new safe and retry. + * + * ⓘ This endpoint must be called through BitGo Express. `POST /api/v2/enterprise/{enterpriseId}/safes` + * (without `/generate`) is the server-side initialize-only call and does not run local crypto. + * + * ⓘ Production Express requires TLS. The passphrase never leaves the Express host. + * + * @operationId express.v2.safes.generate + * @tag Express + * @private + */ +export const PostGenerateSafe = httpRoute({ + path: '/api/v2/enterprise/{enterpriseId}/safes/generate', + method: 'POST', + request: httpRequest({ + params: GenerateSafeParams, + body: GenerateSafeBody, + }), + response: GenerateSafeResponse, +}); diff --git a/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts b/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts new file mode 100644 index 00000000000..064dbb94c57 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts @@ -0,0 +1,63 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { RootKeyType } from '@bitgo/public-types'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for Phase-2-only Safe key ceremonies. + */ +export const GenerateSafeKeysParams = { + /** Enterprise public id that owns the safe */ + enterpriseId: t.string, + /** Already-initialized safe id to tag the ceremonies with */ + safeId: t.string, +} as const; + +/** + * Request body for Phase-2-only Safe key ceremonies. + * + * `initializeSafe` and `finalizeSafe` remain direct server calls (no local crypto). + */ +export const GenerateSafeKeysBody = { + /** Passphrase used to encrypt locally generated root user/backup keys and to run MPC ceremonies */ + passphrase: t.string, + /** Slots to run. Omit to run all four (older WP / retry of a full ceremony set). */ + enabledRootSlots: optional(t.array(RootKeyType)), +} as const; + +/** + * Response body for Phase-2-only Safe key ceremonies. + */ +export const GenerateSafeKeysResponse = { + /** Minted root key ids, as ordered [user, backup, bitgo] triplets — the payload `finalizeSafe` consumes */ + 200: t.UnknownRecord, + /** Bad request */ + 400: BitgoExpressError, +} as const; + +/** + * Generate Safe Keys + * + * Phase 2 of Wallet Safe creation: run the enabled root-key ceremonies for an already-initialized + * safe. Use this when a thin client already called `POST /safes` (initialize) and only needs the + * local ceremonies before `POST .../finalize`. + * + * If a ceremony fails, the SDK archives the half-created safe and throws with the per-slot failure + * list. Create a new safe and retry. + * + * ⓘ This endpoint must be called through BitGo Express. One-shot creation is + * `POST /api/v2/enterprise/{enterpriseId}/safes/generate`. + * + * @operationId express.v2.safes.keys.generate + * @tag Express + * @private + */ +export const PostGenerateSafeKeys = httpRoute({ + path: '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/keys/generate', + method: 'POST', + request: httpRequest({ + params: GenerateSafeKeysParams, + body: GenerateSafeKeysBody, + }), + response: GenerateSafeKeysResponse, +}); diff --git a/modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts b/modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts new file mode 100644 index 00000000000..48d8ee0d5d2 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts @@ -0,0 +1,68 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { WalletResponse, multisigType } from '../../schemas/wallet'; + +/** + * Path parameters for minting a child wallet from a Safe. + */ +export const GenerateSafeWalletParams = { + /** Enterprise public id that owns the safe */ + enterpriseId: t.string, + /** Safe id to mint the wallet from */ + safeId: t.string, +} as const; + +/** + * Request body for minting a child wallet from a Safe. + * + * Safe wallets are hot-only in v1. Child keys are derived locally; do not send `keys`. + */ +export const GenerateSafeWalletBody = { + /** Coin ticker / chain identifier for the child wallet */ + coin: t.string, + /** Wallet label */ + label: t.string, + /** Safe passphrase — decrypts the root user keychain and encrypts derived material */ + passphrase: t.string, + /** `onchain` (default) mints a secp256k1/ed25519 multisig wallet; `tss` mints an MPC wallet */ + multisigType: optional(multisigType), +} as const; + +/** + * Response body for minting a child wallet from a Safe. + */ +export const GenerateSafeWalletResponse = { + /** The minted child wallet */ + 200: WalletResponse, + /** Bad request */ + 400: BitgoExpressError, +} as const; + +/** + * Generate Safe Wallet + * + * Mint a hot child wallet from an existing Wallet Safe. Runs locally on BitGo Express: + * + * - `onchain`: decrypt the safe root, hardened-derive the user child, register it, then mint. + * - `tss`: decrypt the MPC root blob, run the user/BitGo hard-derive ceremony, then mint. + * + * ⓘ This endpoint must be called through BitGo Express. `POST /api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets` + * (without `/generate`) is the server-side mint that expects pre-built `keys`. + * + * ⓘ For later signing through Express, `WALLET_{walletId}_PASSPHRASE` is the **safe** passphrase + * (it decrypts the root user keychain, not a child envelope). + * + * @operationId express.v2.safes.wallet.generate + * @tag Express + * @private + */ +export const PostGenerateSafeWallet = httpRoute({ + path: '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets/generate', + method: 'POST', + request: httpRequest({ + params: GenerateSafeWalletParams, + body: GenerateSafeWalletBody, + }), + response: GenerateSafeWalletResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/generateSafe.ts b/modules/express/test/unit/clientRoutes/generateSafe.ts new file mode 100644 index 00000000000..8a1066d7431 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/generateSafe.ts @@ -0,0 +1,330 @@ +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGo } from 'bitgo'; +import { common, decodeOrElse } from '@bitgo/sdk-core'; +import nock from 'nock'; + +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; + +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; +import { handleV2GenerateSafe, handleV2GenerateSafeKeys, handleV2GenerateSafeWallet } from '../../../src/clientRoutes'; +import { GenerateSafeResponse } from '../../../src/typedRoutes/api/v2/generateSafe'; +import { GenerateSafeKeysResponse } from '../../../src/typedRoutes/api/v2/generateSafeKeys'; +import { GenerateSafeWalletResponse } from '../../../src/typedRoutes/api/v2/generateSafeWallet'; + +const ENTERPRISE_ID = '6a217b164ae0ae10226a16569d3682ec'; +const SAFE_ID = 'safe123'; +const PASSPHRASE = 'mySecurePassphrase123'; +const USER_ROOT_ID = 'tbtc-user-root'; +const BACKUP_ROOT_ID = 'tbtc-backup-root'; +const BITGO_ROOT_ID = 'tbtc-bitgo-root'; + +function safeDataWire( + overrides: { + label?: string; + status?: string; + rootKeys?: { + hot?: { + secp256k1Multisig?: [string, string, string]; + }; + }; + } = {} +) { + return { + id: SAFE_ID, + enterpriseId: ENTERPRISE_ID, + label: overrides.label ?? 'Test Safe', + status: overrides.status ?? 'active', + creator: 'user1', + users: [{ userId: 'user1', permissions: ['admin', 'spend'] }], + createdAt: '2026-07-07T00:00:00.000Z', + rootKeys: overrides.rootKeys, + }; +} + +function nockSecp256k1MultisigRootKeys(bgUrl: string) { + return [ + nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { source?: string; safeId?: string }) => { + return body.source === 'user' && body.safeId === SAFE_ID; + }) + .reply(200, { id: USER_ROOT_ID }), + nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { source?: string; safeId?: string }) => { + return body.source === 'backup' && body.safeId === SAFE_ID; + }) + .reply(200, { id: BACKUP_ROOT_ID }), + nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { source?: string; safeId?: string }) => { + return body.source === 'bitgo' && body.safeId === SAFE_ID; + }) + .reply(200, { id: BITGO_ROOT_ID, pub: 'bitgo-pub' }), + ]; +} + +describe('Generate Safe (typed handler)', () => { + let bitgo: TestBitGoAPI; + let bgUrl: string; + + before(async function () { + if (!nock.isActive()) { + nock.activate(); + } + + bitgo = TestBitGo.decorate(BitGo, { env: 'test' }); + bitgo.initializeTestVars(); + + bgUrl = common.Environments[bitgo.getEnv()].uri; + + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + after(() => { + if (nock.isActive()) { + nock.restore(); + } + }); + + it('should initialize, mint the enabled secp256k1Multisig root, and finalize', async () => { + const label = 'Test Safe'; + const initializeNock = nock(bgUrl) + .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes`, { label }) + .reply(200, { + id: SAFE_ID, + status: 'initializing', + enabledRootSlots: ['secp256k1Multisig'], + }); + + const keyNocks = nockSecp256k1MultisigRootKeys(bgUrl); + + const finalizeNock = nock(bgUrl) + .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/finalize`, (body: { rootKeys?: unknown }) => { + return ( + JSON.stringify(body.rootKeys) === + JSON.stringify({ + hot: { + secp256k1Multisig: [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID], + }, + }) + ); + }) + .reply( + 200, + safeDataWire({ + label, + rootKeys: { + hot: { + secp256k1Multisig: [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID], + }, + }, + }) + ); + + const req = { + bitgo, + params: { + enterpriseId: ENTERPRISE_ID, + }, + query: {}, + body: { + label, + passphrase: PASSPHRASE, + }, + decoded: { + enterpriseId: ENTERPRISE_ID, + label, + passphrase: PASSPHRASE, + }, + } as unknown as ExpressApiRouteRequest<'express.v2.safes.generate', 'post'>; + + const res = await handleV2GenerateSafe(req); + decodeOrElse('GenerateSafeResponse', GenerateSafeResponse[200], JSON.parse(JSON.stringify(res)), (errors) => { + throw new Error(`Response did not match expected codec: ${JSON.stringify(errors)}`); + }); + + res.should.have.property('id', SAFE_ID); + res.should.have.property('label', label); + res.should.have.property('status', 'active'); + + initializeNock.done(); + keyNocks.forEach((scope) => scope.done()); + finalizeNock.done(); + }); + + it('should archive the safe and throw when a root ceremony fails', async () => { + const label = 'Test Safe'; + nock(bgUrl) + .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes`, { label }) + .reply(200, { + id: SAFE_ID, + status: 'initializing', + enabledRootSlots: ['secp256k1Multisig'], + }); + + nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { source?: string }) => body.source === 'user') + .reply(500, { error: 'user key boom' }); + nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { source?: string }) => body.source === 'backup') + .reply(200, { id: BACKUP_ROOT_ID }); + nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { source?: string }) => body.source === 'bitgo') + .reply(200, { id: BITGO_ROOT_ID, pub: 'bitgo-pub' }); + + const archiveNock = nock(bgUrl) + .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/archive`) + .reply(200, safeDataWire({ status: 'archived' })); + + const req = { + bitgo, + params: { + enterpriseId: ENTERPRISE_ID, + }, + query: {}, + body: { + label, + passphrase: PASSPHRASE, + }, + decoded: { + enterpriseId: ENTERPRISE_ID, + label, + passphrase: PASSPHRASE, + }, + } as unknown as ExpressApiRouteRequest<'express.v2.safes.generate', 'post'>; + + await handleV2GenerateSafe(req).should.be.rejectedWith(/has been archived/); + archiveNock.done(); + }); + + it('should run Phase-2 key ceremonies for an already-initialized safe', async () => { + const keyNocks = nockSecp256k1MultisigRootKeys(bgUrl); + + const req = { + bitgo, + params: { + enterpriseId: ENTERPRISE_ID, + safeId: SAFE_ID, + }, + query: {}, + body: { + passphrase: PASSPHRASE, + enabledRootSlots: ['secp256k1Multisig'], + }, + decoded: { + enterpriseId: ENTERPRISE_ID, + safeId: SAFE_ID, + passphrase: PASSPHRASE, + enabledRootSlots: ['secp256k1Multisig' as const], + }, + } as unknown as ExpressApiRouteRequest<'express.v2.safes.keys.generate', 'post'>; + + const res = await handleV2GenerateSafeKeys(req); + decodeOrElse('GenerateSafeKeysResponse', GenerateSafeKeysResponse[200], res, (errors) => { + throw new Error(`Response did not match expected codec: ${JSON.stringify(errors)}`); + }); + + res.should.have.property('rootKeys'); + res.rootKeys.should.have.property('hot'); + res.rootKeys.hot.should.have.property('secp256k1Multisig', [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID]); + + keyNocks.forEach((scope) => scope.done()); + }); + + it('should mint an onchain child wallet from a safe', async () => { + const label = 'Safe Child'; + const walletId = 'wallet-from-safe'; + const childKeyId = 'tbtc-child-user'; + const userKeyPair = bitgo.coin('tbtc').keychains().create(); + const encryptedPrv = await bitgo.encrypt({ input: userKeyPair.prv, password: PASSPHRASE }); + + const getSafeNock = nock(bgUrl) + .get(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}`) + .reply( + 200, + safeDataWire({ + rootKeys: { + hot: { + secp256k1Multisig: [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID], + }, + }, + }) + ); + + const derivationNock = nock(bgUrl) + .get(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/derivation-index`) + .query({ slot: 'secp256k1Multisig' }) + .reply(200, { slot: 'secp256k1Multisig', index: 0 }); + + const getRootKeyNock = nock(bgUrl).get(`/api/v2/tbtc/key/${USER_ROOT_ID}`).reply(200, { + id: USER_ROOT_ID, + pub: userKeyPair.pub, + source: 'user', + encryptedPrv, + }); + + const addChildNock = nock(bgUrl) + .post('/api/v2/tbtc/key', (body: { parent?: string; safeId?: string; source?: string }) => { + return body.parent === USER_ROOT_ID && body.safeId === SAFE_ID && body.source === 'user'; + }) + .reply(200, { id: childKeyId }); + + const mintNock = nock(bgUrl) + .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets`, { + coin: 'tbtc', + label, + type: 'hot', + multisigType: 'onchain', + keys: [childKeyId], + }) + .reply(200, { + id: walletId, + label, + coin: 'tbtc', + keys: [childKeyId], + type: 'hot', + multisigType: 'onchain', + }); + + const req = { + bitgo, + params: { + enterpriseId: ENTERPRISE_ID, + safeId: SAFE_ID, + }, + query: {}, + body: { + coin: 'tbtc', + label, + passphrase: PASSPHRASE, + }, + decoded: { + enterpriseId: ENTERPRISE_ID, + safeId: SAFE_ID, + coin: 'tbtc', + label, + passphrase: PASSPHRASE, + }, + } as unknown as ExpressApiRouteRequest<'express.v2.safes.wallet.generate', 'post'>; + + const res = await handleV2GenerateSafeWallet(req); + decodeOrElse('GenerateSafeWalletResponse', GenerateSafeWalletResponse[200], res, (errors) => { + throw new Error(`Response did not match expected codec: ${JSON.stringify(errors)}`); + }); + + res.should.have.property('id', walletId); + res.should.have.property('label', label); + res.should.have.property('coin', 'tbtc'); + + getSafeNock.done(); + derivationNock.done(); + getRootKeyNock.done(); + addChildNock.done(); + mintNock.done(); + }); +}); diff --git a/modules/express/test/unit/typedRoutes/generateSafe.ts b/modules/express/test/unit/typedRoutes/generateSafe.ts new file mode 100644 index 00000000000..e852ef0b339 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/generateSafe.ts @@ -0,0 +1,273 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { agent as supertest } from 'supertest'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import { IncorrectPasswordError, Safes } from '@bitgo/sdk-core'; +import { PostGenerateSafe } from '../../../src/typedRoutes/api/v2/generateSafe'; +import { PostGenerateSafeWallet } from '../../../src/typedRoutes/api/v2/generateSafeWallet'; +import { PostGenerateSafeKeys } from '../../../src/typedRoutes/api/v2/generateSafeKeys'; + +const ENTERPRISE_ID = '6a217b164ae0ae10226a16569d3682ec'; +const SAFE_ID = 'safe123'; + +function mockSafeJson(label: string) { + return { + id: SAFE_ID, + enterpriseId: ENTERPRISE_ID, + label, + status: 'active' as const, + creator: 'user1', + users: [{ userId: 'user1', permissions: ['admin', 'spend'] }], + createdAt: new Date('2026-07-07T00:00:00.000Z'), + }; +} + +function stubSafe(partial: { toJSON?: () => unknown; createWallet?: sinon.SinonStub }) { + return partial as unknown as Awaited>; +} + +describe('Generate Safe Typed Routes Tests', function () { + let agent: ReturnType; + + before(function () { + const { app } = require('../../../src/expressApp'); + const config = require('../../../src/config').DefaultConfig; + const testApp = app(config); + agent = supertest(testApp); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('express.v2.safes.generate', function () { + it('should generate a safe and return SafeData', async function () { + const label = 'Test Safe'; + const passphrase = 'mySecurePassphrase123'; + const safeJson = mockSafeJson(label); + const generateSafeStub = sinon.stub(Safes.prototype, 'generateSafe').resolves( + stubSafe({ + toJSON: () => safeJson, + }) + ); + + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({ + label, + passphrase, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', SAFE_ID); + res.body.should.have.property('label', label); + res.body.should.have.property('status', 'active'); + generateSafeStub.should.have.been.calledOnce(); + generateSafeStub.firstCall.args[0].should.deepEqual({ label, passphrase }); + }); + + 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', + }); + + 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/generate`).send({ + label: 'Test Safe', + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should propagate ceremony-failure errors unwrapped', async function () { + sinon + .stub(Safes.prototype, 'generateSafe') + .rejects( + new Error( + 'Safe key generation failed for 1 root ceremony/ies [ecdsaMpc: boom]. The safe (safe123) has been archived; create a new safe and retry.' + ) + ); + + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({ + label: 'Test Safe', + passphrase: 'password', + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + res.body.error.should.match(/has been archived/); + }); + + it('should have correct route metadata', function () { + assert.strictEqual(PostGenerateSafe.method, 'POST'); + assert.strictEqual(PostGenerateSafe.path, '/api/v2/enterprise/{enterpriseId}/safes/generate'); + assert.ok(PostGenerateSafe.response[200]); + assert.ok(PostGenerateSafe.response[400]); + }); + }); + + describe('express.v2.safes.wallet.generate', function () { + it('should mint a child wallet from a safe', async function () { + const label = 'Safe Child'; + const passphrase = 'mySecurePassphrase123'; + const coin = 'tsol'; + const walletJson = { + id: 'wallet123', + coin, + label, + keys: ['userKey123', 'backupKey123', 'bitgoKey123'], + multisigType: 'tss' as const, + }; + const createWalletStub = sinon.stub().resolves({ + toJSON: () => walletJson, + }); + sinon.stub(Safes.prototype, 'get').resolves( + stubSafe({ + createWallet: createWalletStub, + }) + ); + + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({ + coin, + label, + passphrase, + multisigType: 'tss', + }); + + res.status.should.equal(200); + res.body.should.have.property('id', 'wallet123'); + res.body.should.have.property('label', label); + res.body.should.have.property('multisigType', 'tss'); + createWalletStub.should.have.been.calledOnce(); + createWalletStub.firstCall.args[0].should.deepEqual({ + coin, + label, + passphrase, + multisigType: 'tss', + }); + }); + + it('should return 400 when coin is missing', async function () { + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({ + label: 'Safe Child', + passphrase: 'password', + }); + + 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}/wallets/generate`).send({ + coin: 'tbtc', + label: 'Safe Child', + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should return 400 when multisigType is invalid', async function () { + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({ + coin: 'tbtc', + label: 'Safe Child', + passphrase: 'password', + multisigType: 'cold', + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/multisigType/); + }); + + it('should propagate IncorrectPasswordError', async function () { + sinon.stub(Safes.prototype, 'get').resolves( + stubSafe({ + createWallet: sinon.stub().rejects(new IncorrectPasswordError()), + }) + ); + + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({ + coin: 'tbtc', + label: 'Safe Child', + passphrase: 'wrong', + }); + + res.status.should.equal(401); + res.body.should.have.property('error'); + String(res.body.error || res.body.message).should.match(/passphrase/i); + }); + + it('should have correct route metadata', function () { + assert.strictEqual(PostGenerateSafeWallet.method, 'POST'); + assert.strictEqual( + PostGenerateSafeWallet.path, + '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets/generate' + ); + assert.ok(PostGenerateSafeWallet.response[200]); + assert.ok(PostGenerateSafeWallet.response[400]); + }); + }); + + describe('express.v2.safes.keys.generate', function () { + const rootKeys = { + rootKeys: { + hot: { + secp256k1Multisig: ['user-1', 'backup-1', 'bitgo-1'] as [string, string, string], + }, + }, + }; + + it('should run phase-2 ceremonies and return root key ids', async function () { + const passphrase = 'mySecurePassphrase123'; + const createSafeKeysStub = sinon.stub(Safes.prototype, 'createSafeKeys').resolves(rootKeys); + + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({ + passphrase, + enabledRootSlots: ['secp256k1Multisig'], + }); + + res.status.should.equal(200); + res.body.should.have.property('rootKeys'); + res.body.rootKeys.hot.secp256k1Multisig.should.deepEqual(['user-1', 'backup-1', 'bitgo-1']); + createSafeKeysStub.should.have.been.calledOnce(); + createSafeKeysStub.firstCall.args[0].should.have.property('passphrase', passphrase); + createSafeKeysStub.firstCall.args[0].should.have.property('safeId', SAFE_ID); + const enabledRootSlots = createSafeKeysStub.firstCall.args[0].enabledRootSlots; + if (enabledRootSlots === undefined) { + throw new Error('expected enabledRootSlots'); + } + enabledRootSlots.should.deepEqual(['secp256k1Multisig']); + }); + + 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({}); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should return 400 when enabledRootSlots contains an unknown slot', async function () { + const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({ + passphrase: 'password', + enabledRootSlots: ['notASlot'], + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should have correct route metadata', function () { + assert.strictEqual(PostGenerateSafeKeys.method, 'POST'); + assert.strictEqual(PostGenerateSafeKeys.path, '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/keys/generate'); + assert.ok(PostGenerateSafeKeys.response[200]); + assert.ok(PostGenerateSafeKeys.response[400]); + }); + }); +});