From c6509710caa8086e4f0f3eb7f321f1fc02062316 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Thu, 29 Oct 2026 00:30:49 -0400 Subject: [PATCH] feat: add eddsaMpc and XLM Safe recovery key params Ticket: WCN-3914 --- .../sdk-coin-ada/test/unit/adaSafeRecovery.ts | 164 ++++++++++++++ .../sdk-coin-sol/test/unit/solSafeRecovery.ts | 202 ++++++++++++++++++ .../sdk-coin-xlm/test/unit/xlmSafeRecovery.ts | 200 +++++++++++++++++ modules/sdk-core/src/bitgo/safe/recovery.ts | 175 +++++++++------ .../sdk-core/test/unit/bitgo/safe/recovery.ts | 199 +++++++++++------ 5 files changed, 807 insertions(+), 133 deletions(-) create mode 100644 modules/sdk-coin-ada/test/unit/adaSafeRecovery.ts create mode 100644 modules/sdk-coin-sol/test/unit/solSafeRecovery.ts create mode 100644 modules/sdk-coin-xlm/test/unit/xlmSafeRecovery.ts diff --git a/modules/sdk-coin-ada/test/unit/adaSafeRecovery.ts b/modules/sdk-coin-ada/test/unit/adaSafeRecovery.ts new file mode 100644 index 0000000000..b1f5043f1f --- /dev/null +++ b/modules/sdk-coin-ada/test/unit/adaSafeRecovery.ts @@ -0,0 +1,164 @@ +import assert from 'assert'; +import Sinon from 'sinon'; +import nacl from 'tweetnacl'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { MPSUtil, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import { buildSafeRecoverKeyParams, deriveSafeWalletKeys, type DecryptedSafeRoots } from '@bitgo/sdk-core'; +import { coins } from '@bitgo/statics'; +import { Ada, Tada } from '../../src'; +import { Transaction } from '../../src/lib'; +import { address, endpointResponses } from '../resources'; + +/** + * End-to-end Safe ADA recovery: real MPS DKG + VRF roots -> deriveSafeWalletKeys (slot-4 eddsaMpc + * child) -> buildSafeRecoverKeyParams -> Ada.recover() with the real MPS 2-of-2 signing path. Only + * chain queries are mocked. + * + * ADA is a second EdDSA family next to SOL, so this confirms the slot-4 key params are not + * SOL-specific: the derived child keychain is accepted as `bitgoKey` and the witness verifies. + */ +describe('Safe ADA recovery end-to-end', function () { + const sandbox = Sinon.createSandbox(); + let bitgo: TestBitGoAPI; + let basecoin: Tada; + let roots: DecryptedSafeRoots; + + before(async function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('ada', Ada.createInstance); + bitgo.safeRegister('tada', Tada.createInstance); + basecoin = bitgo.coin('tada') as Tada; + + const [userDkg, backupDkg, bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [vrfUser, vrfBackup] = await MpsVrfUtils.generateVrfDKGKeyShares(); + roots = { + secp256k1Multisig: { user: 'u', backup: 'b', bitgo: 'g' }, + ed25519Multisig: { user: 'u', backup: { prv: 'b', chainCode: 'c' }, bitgo: 'g' }, + ecdsaMpc: { + user: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + backup: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + bitgo: 'g', + }, + eddsaMpc: { + user: { signing: userDkg.getReducedKeyShare(), vrf: vrfUser.getKeyShare() }, + backup: { signing: backupDkg.getReducedKeyShare(), vrf: vrfBackup.getKeyShare() }, + bitgo: bitgoDkg.getCommonKeychain(), + }, + }; + }); + + afterEach(function () { + sandbox.restore(); + }); + + async function deriveChild(account: number) { + const derived = await deriveSafeWalletKeys({ coin: 'tada', slot: 'eddsaMpc', account, roots }); + assert(derived.slot === 'eddsaMpc'); + const { address: walletAddress, accountId } = await basecoin.getAdaAddressAndAccountId({ + bitgoKey: derived.keys.commonKeychain, + index: 0, + }); + return { derived, walletAddress, accountId }; + } + + function stubChain(walletAddress: string): void { + const getDataFromNode = sandbox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + getDataFromNode + .withArgs('address_info', { _addresses: [walletAddress] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + getDataFromNode.withArgs('tip').resolves(endpointResponses.tipInfoResponse); + } + + it('recovers a Safe ADA wallet offline: derive -> map -> sign, verifiable under the derived key', async function () { + const { derived, walletAddress, accountId } = await deriveChild(0); + stubChain(walletAddress); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tada', + slot: 'eddsaMpc', + keys: derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + + const recovery = await basecoin.recover({ + ...keyParams, + recoveryDestination: address.address2, + }); + assert('serializedTx' in recovery); + + const tx = new Transaction(coins.get('tada')); + tx.fromRawTransaction(recovery.serializedTx); + const { outputs, witnesses } = tx.toJson(); + assert.strictEqual(outputs[0].address, address.address2); + assert.strictEqual(witnesses.length, 1); + assert.strictEqual(witnesses[0].publicKey, accountId); + assert.strictEqual( + nacl.sign.detached.verify( + new Uint8Array(tx.signablePayload), + new Uint8Array(Buffer.from(witnesses[0].signature, 'hex')), + new Uint8Array(Buffer.from(accountId, 'hex')) + ), + true + ); + }); + + it('returns an unsigned sweep for the derived child keychain without any key material', async function () { + const { derived, walletAddress } = await deriveChild(0); + stubChain(walletAddress); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tada', + slot: 'eddsaMpc', + keys: derived.keys, + mode: 'unsigned', + }); + assert(keyParams.mode === 'unsigned'); + assert(!('userKey' in keyParams) && !('backupKey' in keyParams) && !('walletPassphrase' in keyParams)); + + const recovery = await basecoin.recover({ + ...keyParams, + recoveryDestination: address.address2, + }); + assert('txRequests' in recovery); + + const unsignedTx = recovery.txRequests[0].transactions[0].unsignedTx; + assert.strictEqual(unsignedTx.coinSpecific?.commonKeychain, derived.keys.commonKeychain); + assert.strictEqual(unsignedTx.derivationPath, 'm/0'); + assert(unsignedTx.signableHex); + + const tx = new Transaction(coins.get('tada')); + tx.fromRawTransaction(unsignedTx.serializedTx); + const { outputs, witnesses } = tx.toJson(); + assert.strictEqual(outputs[0].address, address.address2); + assert.strictEqual(witnesses.length, 0); + }); + + it('fails closed when the signing shares belong to a different account than bitgoKey', async function () { + const account0 = await deriveChild(0); + const account1 = await deriveChild(1); + stubChain(account0.walletAddress); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tada', + slot: 'eddsaMpc', + keys: account1.derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + + await assert.rejects( + basecoin.recover({ + ...keyParams, + bitgoKey: account0.derived.keys.commonKeychain, + recoveryDestination: address.address2, + }), + /commonKeyChain from keycard does not match bitgoKey/ + ); + }); +}); diff --git a/modules/sdk-coin-sol/test/unit/solSafeRecovery.ts b/modules/sdk-coin-sol/test/unit/solSafeRecovery.ts new file mode 100644 index 0000000000..5b7cd85994 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/solSafeRecovery.ts @@ -0,0 +1,202 @@ +import assert from 'assert'; +import Sinon from 'sinon'; +import * as nacl from 'tweetnacl'; +import * as base58 from 'bs58'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { MPSUtil, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import { + buildSafeRecoverKeyParams, + deriveSafeWalletKeys, + EDDSAMethods, + type DecryptedSafeRoots, +} from '@bitgo/sdk-core'; +import { KeyPair, Tsol, Sol } from '../../src'; +import { TransactionBuilderFactory } from '../../src/lib'; +import { coins } from '@bitgo/statics'; +import * as testData from '../fixtures/sol'; + +/** + * End-to-end Safe SOL recovery: real MPS DKG + VRF roots -> deriveSafeWalletKeys (slot-4 eddsaMpc + * child) -> buildSafeRecoverKeyParams -> Sol.recover() with the real MPS 2-of-2 signing path. Only + * chain queries are mocked. + * + * This proves the derived child keychain is accepted as `bitgoKey`, the re-encrypted child shares + * decrypt and sign, and the resulting signature verifies under the derived wallet address. + */ +describe('Safe SOL recovery end-to-end', function () { + const sandbox = Sinon.createSandbox(); + let bitgo: TestBitGoAPI; + let basecoin: Tsol; + let roots: DecryptedSafeRoots; + + before(async function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('tsol', Tsol.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tsol') as Tsol; + + const [userDkg, backupDkg, bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [vrfUser, vrfBackup] = await MpsVrfUtils.generateVrfDKGKeyShares(); + roots = { + secp256k1Multisig: { user: 'u', backup: 'b', bitgo: 'g' }, + ed25519Multisig: { user: 'u', backup: { prv: 'b', chainCode: 'c' }, bitgo: 'g' }, + ecdsaMpc: { + user: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + backup: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + bitgo: 'g', + }, + eddsaMpc: { + user: { signing: userDkg.getReducedKeyShare(), vrf: vrfUser.getKeyShare() }, + backup: { signing: backupDkg.getReducedKeyShare(), vrf: vrfBackup.getKeyShare() }, + bitgo: bitgoDkg.getCommonKeychain(), + }, + }; + }); + + afterEach(function () { + sandbox.restore(); + }); + + async function deriveChild(account: number) { + const derived = await deriveSafeWalletKeys({ coin: 'tsol', slot: 'eddsaMpc', account, roots }); + assert(derived.slot === 'eddsaMpc'); + const mpc = await EDDSAMethods.getInitializedMpcInstance(); + const publicKey = mpc.deriveUnhardened(derived.keys.commonKeychain, 'm/0').slice(0, 64); + return { derived, publicKey, address: new KeyPair({ pub: publicKey }).getAddress() }; + } + + function stubChain(walletAddress: string): void { + const getDataFromNode = sandbox.stub(Sol.prototype, 'getDataFromNode' as keyof Sol); + getDataFromNode + .withArgs({ + payload: { id: '1', jsonrpc: '2.0', method: 'getLatestBlockhash', params: [{ commitment: 'finalized' }] }, + }) + .resolves(testData.SolResponses.getBlockhashResponse); + getDataFromNode + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [walletAddress] } }) + .resolves(testData.SolResponses.getAccountBalanceResponse); + getDataFromNode + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getFeeForMessage', + params: [Sinon.match.string, { commitment: 'finalized' }], + }, + }) + .resolves(testData.SolResponses.getFeesForMessageResponse); + } + + it('recovers a Safe SOL wallet offline: derive -> map -> sign, verifiable under the derived address', async function () { + const { derived, publicKey, address } = await deriveChild(0); + stubChain(address); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tsol', + slot: 'eddsaMpc', + keys: derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + assert.strictEqual(keyParams.isTss, true); + assert.strictEqual(keyParams.broadcast, false); + assert.strictEqual(keyParams.bitgoKey, derived.keys.commonKeychain); + + const recovery = await basecoin.recover({ + ...keyParams, + recoveryDestination: testData.keys.destinationPubKey, + }); + + assert('serializedTx' in recovery); + assert.strictEqual(recovery.scanIndex, 0); + + // The signature on the recovered tx verifies under the derived child wallet key. + const tx = await new TransactionBuilderFactory(coins.get('tsol')).from(recovery.serializedTx).build(); + assert.strictEqual(tx.signature.length, 1); + assert.strictEqual( + nacl.sign.detached.verify( + new Uint8Array(tx.signablePayload), + new Uint8Array(base58.decode(tx.signature[0])), + new Uint8Array(Buffer.from(publicKey, 'hex')) + ), + true + ); + }); + + it('returns an unsigned sweep from the derived child address with no key material in the params', async function () { + const { derived, address } = await deriveChild(0); + stubChain(address); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tsol', + slot: 'eddsaMpc', + keys: derived.keys, + mode: 'unsigned', + }); + assert(keyParams.mode === 'unsigned'); + assert(!('userKey' in keyParams) && !('backupKey' in keyParams) && !('walletPassphrase' in keyParams)); + + const recovery = await basecoin.recover({ + ...keyParams, + recoveryDestination: testData.keys.destinationPubKey, + }); + assert('txRequests' in recovery); + + const unsignedTx = recovery.txRequests[0].transactions[0].unsignedTx; + assert.strictEqual(unsignedTx.coinSpecific?.commonKeychain, derived.keys.commonKeychain); + assert.strictEqual(unsignedTx.derivationPath, 'm/0'); + assert(unsignedTx.signableHex); + const tx = await new TransactionBuilderFactory(coins.get('tsol')).from(unsignedTx.serializedTx).build(); + assert.strictEqual(tx.inputs[0].address, address); + }); + + it('fails closed when the signing shares belong to a different account than bitgoKey', async function () { + const account0 = await deriveChild(0); + const account1 = await deriveChild(1); + stubChain(account0.address); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tsol', + slot: 'eddsaMpc', + keys: account1.derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + + await assert.rejects( + basecoin.recover({ + ...keyParams, + bitgoKey: account0.derived.keys.commonKeychain, + recoveryDestination: testData.keys.destinationPubKey, + }), + /commonKeyChain from keycard does not match bitgoKey/ + ); + }); + + it('fails closed when the wallet passphrase cannot decrypt the re-encrypted child shares', async function () { + const { derived, address } = await deriveChild(0); + stubChain(address); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'tsol', + slot: 'eddsaMpc', + keys: derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + + await assert.rejects( + basecoin.recover({ + ...keyParams, + walletPassphrase: 'not-the-ephemeral-passphrase', + recoveryDestination: testData.keys.destinationPubKey, + }), + /Error decrypting user keychain/ + ); + }); +}); diff --git a/modules/sdk-coin-xlm/test/unit/xlmSafeRecovery.ts b/modules/sdk-coin-xlm/test/unit/xlmSafeRecovery.ts new file mode 100644 index 0000000000..7276233d6d --- /dev/null +++ b/modules/sdk-coin-xlm/test/unit/xlmSafeRecovery.ts @@ -0,0 +1,200 @@ +import assert from 'assert'; +import nock from 'nock'; +import Sinon from 'sinon'; +import * as stellar from 'stellar-sdk'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { + buildSafeRecoverKeyParams, + deriveSafeWalletKeys, + encodeDerivableEd25519Pub, + encodeEd25519ChainCode, + RecoveryKeyMismatchError, + type DecryptedSafeRoots, +} from '@bitgo/sdk-core'; +import { Txlm } from '../../src'; + +/** + * End-to-end Safe XLM recovery: derive slot-2 children from real roots -> buildSafeRecoverKeyParams + * -> `Xlm.recover()`. The user child is a hardened seed and the backup child is a seedless soft + * extended key, so signed recovery signs the backup leg through the lazy external signer; the final + * transaction carries real signatures that verify under the derived child keys. + */ +describe('Safe XLM recovery end-to-end', function () { + const horizon = 'https://horizon-testnet.stellar.org'; + const rootAddress = 'GBIEJQUARJ33DIZU4AIRDOKYPSVK66Z3O5XU7OOI7LUOAJWTPI4OA4JI'; + const recoveryDestination = 'GDDHCKMYYYCVXOSAVMSEIYGYNX74LIAV3ACXYQ6WPMDUF7W3KZNWTHTH'; + let bitgo: TestBitGoAPI; + const sandbox = Sinon.createSandbox(); + let basecoin: Txlm; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('txlm', Txlm.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('txlm') as Txlm; + }); + + afterEach(function () { + nock.cleanAll(); + sandbox.restore(); + }); + + // The ed25519Multisig root fixture the sdk-core unit tests pin (with a distinct BitGo root, so the + // backup child and the BitGo child are different keys and a slot swap changes every assertion). + function ed25519Roots(): DecryptedSafeRoots { + const seed = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + const bitgoRootPub = stellar.Keypair.fromRawEd25519Seed(Buffer.alloc(32, 0x22)).rawPublicKey(); + return { + secp256k1Multisig: { user: 'u', backup: 'b', bitgo: 'g' }, + ed25519Multisig: { + user: seed, + backup: { prv: seed, chainCode: encodeEd25519ChainCode(Buffer.alloc(32, 0xab)) }, + bitgo: encodeDerivableEd25519Pub(bitgoRootPub, Buffer.alloc(32, 0xcd)), + }, + ecdsaMpc: { + user: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + backup: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + bitgo: 'g', + }, + eddsaMpc: { + user: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + backup: { signing: Buffer.alloc(0), vrf: Buffer.alloc(0) }, + bitgo: 'g', + }, + }; + } + + async function deriveChildren() { + const derived = await deriveSafeWalletKeys({ + coin: 'txlm', + slot: 'ed25519Multisig', + account: 0, + roots: ed25519Roots(), + }); + assert(derived.slot === 'ed25519Multisig'); + const [userPub, backupPub] = [derived.keys.user.pub, derived.keys.backup.pub].map((pub) => + stellar.StrKey.encodeEd25519PublicKey(Buffer.from(pub, 'hex')) + ); + return { derived, userPub, backupPub }; + } + + function nockHorizon(signers: string[]): void { + nock(`${horizon}/accounts`) + .get('/' + rootAddress) + .reply(200, { + sequence: '35995558267060226', + balances: [{ asset_type: 'native', balance: '6500000000' }], + signers: signers.map((key) => ({ key, weight: 1, type: 'ed25519_public_key' })), + }); + nock(`${horizon}/accounts`) + .get('/' + recoveryDestination) + .reply(200, { sequence: '35995558267060213', balances: 13131313 }); + nock(horizon) + .get('/ledgers') + .query({ order: 'desc', limit: 1 }) + .reply(200, { records: [{ base_reserve_in_stroops: '5000000', base_fee_in_stroops: 100 }] }) + .persist(); + } + + it('recovers a Safe XLM wallet: user seed and lazy backup signer sign verifiable signatures', async function () { + const { derived, userPub, backupPub } = await deriveChildren(); + assert.notStrictEqual(derived.keys.user.pub, derived.keys.backup.pub); + nockHorizon([userPub, backupPub]); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txlm', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + assert(!('rootAddress' in keyParams)); + + const recovery = await basecoin.recover({ + userKey: keyParams.userKey, + walletPassphrase: keyParams.walletPassphrase, + externalBackupSigner: keyParams.externalBackupSigner, + rootAddress, + recoveryDestination, + }); + + const tx = new stellar.Transaction(recovery.txBase64, stellar.Networks.TESTNET); + assert.strictEqual(tx.signatures.length, 2); + assert.ok(stellar.Keypair.fromPublicKey(userPub).verify(tx.hash(), tx.signatures[0].signature())); + assert.ok(stellar.Keypair.fromPublicKey(backupPub).verify(tx.hash(), tx.signatures[1].signature())); + assert.strictEqual(recovery.coin, 'txlm'); + }); + + it('returns an unsigned sweep for the derived child pubs without encrypting or decrypting', async function () { + const { derived, userPub, backupPub } = await deriveChildren(); + nockHorizon([userPub, backupPub]); + const encryptSpy = sandbox.spy(bitgo, 'encrypt'); + const decryptSpy = sandbox.spy(bitgo, 'decrypt'); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txlm', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'unsigned', + }); + assert(keyParams.mode === 'unsigned'); + assert.strictEqual(keyParams.userKey, userPub); + assert.strictEqual(keyParams.backupKey, backupPub); + + const { userKey, backupKey } = keyParams; + const recovery = await basecoin.recover({ userKey, backupKey, rootAddress, recoveryDestination }); + + const tx = new stellar.Transaction(recovery.txBase64, stellar.Networks.TESTNET); + assert.strictEqual(tx.signatures.length, 0); + Sinon.assert.notCalled(encryptSpy); + Sinon.assert.notCalled(decryptSpy); + }); + + it('fails closed with RecoveryKeyMismatchError when the derived keys are not signers of rootAddress', async function () { + const { derived } = await deriveChildren(); + const otherSigner = stellar.Keypair.random().publicKey(); + nockHorizon([otherSigner]); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txlm', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'unsigned', + }); + assert(keyParams.mode === 'unsigned'); + + await assert.rejects( + basecoin.recover({ ...keyParams, rootAddress, recoveryDestination }), + (err) => err instanceof RecoveryKeyMismatchError && err.code === 'recovery_key_mismatch' + ); + }); + + it('fails closed with RecoveryKeyMismatchError in signed mode too, before signing anything', async function () { + const { derived } = await deriveChildren(); + nockHorizon([stellar.Keypair.random().publicKey()]); + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'txlm', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'signed', + }); + assert(keyParams.mode === 'signed'); + + await assert.rejects( + basecoin.recover({ + userKey: keyParams.userKey, + walletPassphrase: keyParams.walletPassphrase, + externalBackupSigner: keyParams.externalBackupSigner, + rootAddress, + recoveryDestination, + }), + (err) => err instanceof RecoveryKeyMismatchError && err.code === 'recovery_key_mismatch' + ); + }); +}); diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index 6dd9440405..ea70ffc030 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -22,6 +22,8 @@ import { encodeAlgoKey, encodeHbarPublicKeyDer, encodeHbarPrivateKeyDer, + encodeEd25519StrKeyPublicKey, + encodeEd25519StrKeySecretSeed, type SafeKeycardRootEntry, } from '@bitgo/sdk-lib-safes'; import { @@ -52,13 +54,15 @@ export type Secp256k1MultisigChildKeys = { bitgo: { pub: string }; }; -export type EcdsaMpcChildKeys = { +export type MpcChildKeys = { commonKeychain: string; user: string; backup: string; }; -export type EddsaMpcChildKeys = EcdsaMpcChildKeys; +export type EcdsaMpcChildKeys = MpcChildKeys; + +export type EddsaMpcChildKeys = MpcChildKeys; /** * A hardened (SLIP-0010) ed25519 child. The `kind` discriminator prevents a raw seed from being @@ -98,17 +102,18 @@ export type SecpSafeRecoverKeyParams = { }; /** - * Slot-3 (ecdsaMpc) safe recovery key params. A discriminated union on `mode`, so the invalid - * combos are not representable: signed mode always carries the re-encrypted child reduced shares - * and the ephemeral passphrase; unsigned mode carries only the child common keychain. + * Safe recovery key params for the MPC slots (`ecdsaMpc`, `eddsaMpc`). A discriminated union on + * `mode`, so the invalid combos are not representable: signed mode always carries the re-encrypted + * child reduced shares and the ephemeral passphrase; unsigned mode carries only the child common + * keychain. * * `broadcast: false` is always set so Safe recovery never auto-broadcasts: WRW's coin-agnostic * engine spreads these params into `coin.recover()`, and ICP is the one coin whose `recover()` * would otherwise submit the sweep to the network. */ -export type EcdsaMpcSafeRecoverKeyParams = +type MpcSafeRecoverKeyParams = | { - slot: 'ecdsaMpc'; + slot: Slot; mode: 'signed'; userKey: string; backupKey: string; @@ -118,13 +123,16 @@ export type EcdsaMpcSafeRecoverKeyParams = broadcast: false; } | { - slot: 'ecdsaMpc'; + slot: Slot; mode: 'unsigned'; bitgoKey: string; isTss: true; broadcast: false; }; +export type EcdsaMpcSafeRecoverKeyParams = MpcSafeRecoverKeyParams<'ecdsaMpc'>; +export type EddsaMpcSafeRecoverKeyParams = MpcSafeRecoverKeyParams<'eddsaMpc'>; + /** * Slot-2 (ed25519Multisig) safe recovery key params. A discriminated union on `mode`, so the invalid * combos are not representable: signed mode always carries the lazy signer and never a `backupKey`, @@ -134,7 +142,8 @@ export type EcdsaMpcSafeRecoverKeyParams = * three child pubs — derivable here, but `recover()` still requires it as input. Note this is * *derived*, not independently verified against a known wallet address; callers with a recorded * address should cross-check it themselves. For HBAR this field is omitted so the caller's - * network-assigned account ID is preserved when the key params are spread into a recovery request. + * network-assigned account ID is neither overwritten nor re-derived; the same goes for XLM, whose + * base address is network-assigned too. For both, the caller must pass `rootAddress` to `recover()`. */ export type Ed25519SafeRecoverKeyParams = | { @@ -164,7 +173,8 @@ export type Ed25519SafeRecoverKeyParams = export type SafeRecoverKeyParams = | SecpSafeRecoverKeyParams | Ed25519SafeRecoverKeyParams - | EcdsaMpcSafeRecoverKeyParams; + | EcdsaMpcSafeRecoverKeyParams + | EddsaMpcSafeRecoverKeyParams; export type DecryptedSafeRoots = { secp256k1Multisig: OnchainSafeRoot; @@ -326,10 +336,12 @@ export async function deriveSafeWalletKeys(params: { throw new Error(`deriveSafeWalletKeys: slot '${params.slot}' is not supported yet`); } +const ED25519_32_BYTE_HEX = /^[0-9a-f]{64}$/i; + /** * Per-coin encoders for the `ed25519Multisig` slot: each slot-2 coin re-encodes the same 32-byte * children into its own address/seed string formats. A coin absent from the registry is not yet - * supported for slot-2 recovery (xlm is a future arm). + * supported for slot-2 recovery. */ const ALGO_ENCODERS = { encodePub: encodeAlgoKey, @@ -338,6 +350,11 @@ const ALGO_ENCODERS = { algoMultisigAddress(1, 2, [userPub, backupPub, bitgoPub]), }; +const XLM_ENCODERS = { + encodePub: encodeEd25519StrKeyPublicKey, + encodeSeed: encodeEd25519StrKeySecretSeed, +}; + const ED25519_MULTISIG_COIN_ENCODERS: Record< string, | { @@ -351,6 +368,8 @@ const ED25519_MULTISIG_COIN_ENCODERS: Record< talgo: ALGO_ENCODERS, hbar: { encodePub: encodeHbarPublicKeyDer, encodeSeed: encodeHbarPrivateKeyDer }, thbar: { encodePub: encodeHbarPublicKeyDer, encodeSeed: encodeHbarPrivateKeyDer }, + xlm: XLM_ENCODERS, + txlm: XLM_ENCODERS, }; /** @@ -371,11 +390,10 @@ export type SafeRecoverKeyParamsRequest = { /** * Builds the key fields `coin.recover()` expects from derived Safe child keys. - * Supports `secp256k1Multisig` (slot 1), `ed25519Multisig` (slot 2), and `ecdsaMpc` (slot 3); - * `eddsaMpc` fails closed. Signed mode re-encrypts the child secrets under a fresh ephemeral + * Supports all four slots. Signed mode re-encrypts the child secrets under a fresh ephemeral * passphrase (v1/SJCL); unsigned mode returns pubs / the child common keychain. Where supported, * `rootAddress` is derived from the child keys, not independently verified; otherwise it is - * omitted so the caller can supply it. `ecdsaMpc` params always carry `broadcast: false` so Safe + * omitted so the caller can supply it. MPC-slot params always carry `broadcast: false` so Safe * recovery never auto-broadcasts. */ export async function buildSafeRecoverKeyParams( @@ -387,79 +405,95 @@ export async function buildSafeRecoverKeyParams( export async function buildSafeRecoverKeyParams( params: Extract ): Promise; +export async function buildSafeRecoverKeyParams( + params: Extract +): Promise; export async function buildSafeRecoverKeyParams(params: SafeRecoverKeyParamsRequest): Promise; export async function buildSafeRecoverKeyParams(params: SafeRecoverKeyParamsRequest): Promise { const { bitgo, mode } = params; + // Untyped (JS) callers can pass values the types forbid; fail closed rather than fall through. + if (mode !== 'signed' && mode !== 'unsigned') { + throw new Error(`buildSafeRecoverKeyParams: unknown mode '${mode}'`); + } + if (params.slot === 'ed25519Multisig') { return buildEd25519MultisigRecoverKeyParams(bitgo, params.coin, params.keys, mode); } if (params.slot === 'ecdsaMpc') { - const keys = params.keys; - - if (mode === 'unsigned') { - return { - slot: 'ecdsaMpc', - mode: 'unsigned', - bitgoKey: keys.commonKeychain, - isTss: true, - broadcast: false, - }; - } - - const walletPassphrase = randomBytes(32).toString('hex'); - const [userKey, backupKey] = await Promise.all([ - bitgo.encrypt({ input: keys.user, password: walletPassphrase, encryptionVersion: 1 }), - bitgo.encrypt({ input: keys.backup, password: walletPassphrase, encryptionVersion: 1 }), - ]); - - return { - slot: 'ecdsaMpc', - mode: 'signed', - userKey, - backupKey, - bitgoKey: keys.commonKeychain, - walletPassphrase, - isTss: true, - broadcast: false, - }; + return buildMpcRecoverKeyParams(bitgo, 'ecdsaMpc', params.keys, mode); } - if (params.slot === 'secp256k1Multisig') { - const keys = params.keys; - - if (mode === 'unsigned') { - return { - slot: 'secp256k1Multisig', - userKey: keys.user.pub, - backupKey: keys.backup.pub, - bitgoKey: keys.bitgo.pub, - }; - } + if (params.slot === 'eddsaMpc') { + return buildMpcRecoverKeyParams(bitgo, 'eddsaMpc', params.keys, mode); + } - const userPrv = bip32.fromBase58(keys.user.prv); - const backupPrv = bip32.fromBase58(keys.backup.prv); - if (!userPrv.privateKey || !backupPrv.privateKey) { - throw new Error('buildSafeRecoverKeyParams: keys.user.prv and keys.backup.prv must be private xprvs'); - } + // Every other typed slot returned above. The runtime guard keeps an unknown slot from falling + // into slot-1 handling; the narrowing makes a slot added without a branch a compile error below. + const slot: string = params.slot; + if (slot !== 'secp256k1Multisig') { + throw new Error(`buildSafeRecoverKeyParams: slot '${slot}' is not supported yet`); + } - 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 }), - ]); + const keys = params.keys; + if (mode === 'unsigned') { return { slot: 'secp256k1Multisig', - userKey, - backupKey, + userKey: keys.user.pub, + backupKey: keys.backup.pub, bitgoKey: keys.bitgo.pub, - walletPassphrase, }; } - throw new Error(`buildSafeRecoverKeyParams: slot '${params.slot}' is not supported yet`); + const userPrv = bip32.fromBase58(keys.user.prv); + const backupPrv = bip32.fromBase58(keys.backup.prv); + if (!userPrv.privateKey || !backupPrv.privateKey) { + throw new Error('buildSafeRecoverKeyParams: keys.user.prv and keys.backup.prv must be private xprvs'); + } + + 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 }), + ]); + + return { + slot: 'secp256k1Multisig', + userKey, + backupKey, + bitgoKey: keys.bitgo.pub, + walletPassphrase, + }; +} + +async function buildMpcRecoverKeyParams( + bitgo: BitGoBase, + slot: Slot, + keys: MpcChildKeys, + mode: 'signed' | 'unsigned' +): Promise> { + if (mode === 'unsigned') { + return { slot, mode, bitgoKey: keys.commonKeychain, isTss: true, broadcast: false }; + } + + const walletPassphrase = randomBytes(32).toString('hex'); + const [userKey, backupKey] = await Promise.all([ + bitgo.encrypt({ input: keys.user, password: walletPassphrase, encryptionVersion: 1 }), + bitgo.encrypt({ input: keys.backup, password: walletPassphrase, encryptionVersion: 1 }), + ]); + + return { + slot, + mode, + userKey, + backupKey, + bitgoKey: keys.commonKeychain, + walletPassphrase, + isTss: true, + broadcast: false, + }; } async function buildEd25519MultisigRecoverKeyParams( @@ -476,7 +510,7 @@ async function buildEd25519MultisigRecoverKeyParams( for (const pub of [keys.user.pub, keys.backup.pub, keys.bitgo.pub]) { // strict hex, not length-only: Buffer.from(pub, 'hex') truncates at the first bad pair, so a // length check alone would accept trailing garbage - if (!/^[0-9a-f]{64}$/i.test(pub)) { + if (!ED25519_32_BYTE_HEX.test(pub)) { throw new Error('buildSafeRecoverKeyParams: ed25519 child public keys must be 32 bytes'); } } @@ -505,6 +539,11 @@ async function buildEd25519MultisigRecoverKeyParams( // The user child is a hardened SLIP-0010 seed, so it re-encrypts exactly like slot 1. The backup // child is a seedless soft extended key: it cannot be re-encrypted as a seed, so it becomes a lazy // external signer over the tx bytes (WCN-2895's extended-key signer, invoked by the coin). + // strict hex: Buffer.from(seed, 'hex') truncates at the first bad pair, which would silently + // encode a wrong private key + if (!ED25519_32_BYTE_HEX.test(keys.user.seed)) { + throw new Error('buildSafeRecoverKeyParams: ed25519 user seed must be 32 bytes'); + } const walletPassphrase = randomBytes(32).toString('hex'); const userKey = await bitgo.encrypt({ input: encoders.encodeSeed(Buffer.from(keys.user.seed, 'hex')), diff --git a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts index e7b67b53ec..1b45d1936b 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts @@ -504,15 +504,34 @@ describe('buildSafeRecoverKeyParams', () => { encrypt.callCount.should.equal(0); }); - it('rejects unsupported slots', async () => { - const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; - await buildSafeRecoverKeyParams({ - bitgo, - coin: 'btc', - slot: 'eddsaMpc', - keys: { commonKeychain: 'ck', user: 'u', backup: 'b' }, - mode: 'signed', - }).should.be.rejectedWith(/not supported yet/); + // WRW passes these requests over IPC, so the real untyped input is parsed JSON (`any`), which + // type-checks without a suppression directive or cast. + function untypedRequest(bitgo: BitGoBase, fields: Record) { + return { bitgo, coin: 'btc', ...JSON.parse(JSON.stringify(fields)) }; + } + + it('rejects unknown slots in both modes instead of falling through to slot 1', async () => { + const encrypt = sinon.stub(); + const bitgo = { encrypt } as unknown as BitGoBase; + for (const mode of ['signed', 'unsigned']) { + await buildSafeRecoverKeyParams( + untypedRequest(bitgo, { + slot: 'futureSlot', + keys: { commonKeychain: 'ck', user: 'u', backup: 'b' }, + mode, + }) + ).should.be.rejectedWith(/slot 'futureSlot' is not supported yet/); + } + encrypt.callCount.should.equal(0); + }); + + it('rejects an unknown mode instead of defaulting to the encrypting path', async () => { + const encrypt = sinon.stub(); + const bitgo = { encrypt } as unknown as BitGoBase; + await buildSafeRecoverKeyParams( + untypedRequest(bitgo, { slot: 'secp256k1Multisig', keys, mode: 'sign' }) + ).should.be.rejectedWith(/unknown mode 'sign'/); + encrypt.callCount.should.equal(0); }); describe('ed25519Multisig', () => { @@ -677,15 +696,58 @@ describe('buildSafeRecoverKeyParams', () => { } } - it('rejects slot-2 coins that have no registry entry yet', async () => { + // Golden vectors generated with stellar-sdk StrKey over the same bytes as `edKeys`. + for (const coin of ['xlm', 'txlm']) { + for (const mode of ['signed', 'unsigned'] as const) { + it(`${coin} ${mode} mode encodes Stellar strkeys without overwriting the caller's rootAddress`, async function () { + const encrypt = sinon.stub().callsFake(async ({ input }) => `enc(${input})`); + const bitgo = { encrypt } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ + bitgo, + coin, + slot: 'ed25519Multisig', + keys: edKeys, + mode, + }); + + assert(!('rootAddress' in result)); + const rootAddress = 'GBIEJQUARJ33DIZU4AIRDOKYPSVK66Z3O5XU7OOI7LUOAJWTPI4OA4JI'; + assert.strictEqual({ rootAddress, ...result }.rootAddress, rootAddress); + result.bitgoKey.should.equal('GBQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWDRY7'); + + if (result.mode === 'unsigned') { + result.userKey.should.equal('GAQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCB5Q'); + result.backupKey.should.equal('GBIVCUKRKFIVCUKRKFIVCUKRKFIVCUKRKFIVCUKRKFIVCUKRKFIVDV3D'); + result.should.not.have.property('walletPassphrase'); + result.should.not.have.property('externalBackupSigner'); + sinon.assert.notCalled(encrypt); + } else { + const encodedSeed = 'SAIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRDBNT'; + result.userKey.should.equal(`enc(${encodedSeed})`); + result.should.not.have.property('backupKey'); + result.externalBackupSigner.publicKey.should.equal(edKeys.backup.pub); + result.walletPassphrase.should.match(/^[0-9a-f]{64}$/); + sinon.assert.calledOnce(encrypt); + sinon.assert.calledWithExactly(encrypt, { + input: encodedSeed, + password: result.walletPassphrase, + encryptionVersion: 1, + }); + } + }); + } + } + + it('rejects slot-2 coins that have no registry entry yet', async function () { const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; await buildSafeRecoverKeyParams({ bitgo, - coin: 'xlm', + coin: 'sol', slot: 'ed25519Multisig', keys: edKeys, mode: 'signed', - }).should.be.rejectedWith(/coin 'xlm' ed25519Multisig is not supported yet/); + }).should.be.rejectedWith(/coin 'sol' ed25519Multisig is not supported yet/); }); it('rejects ed25519 children with malformed public keys', async () => { @@ -698,68 +760,75 @@ describe('buildSafeRecoverKeyParams', () => { mode: 'unsigned', }).should.be.rejectedWith(/ed25519 child public keys must be 32 bytes/); }); - }); - - describe('ecdsaMpc', () => { - const mpcKeys = { commonKeychain: 'ck', user: 'user-child-share', backup: 'backup-child-share' }; - it('signed mode re-encrypts the child reduced shares under a single ephemeral passphrase', async () => { - const passphrases: string[] = []; - const encrypt = sinon.stub().callsFake(async ({ input, password, encryptionVersion }) => { - passphrases.push(password); - encryptionVersion.should.equal(1); - return `enc(${input})`; - }); + it('rejects a malformed user seed in signed mode before encrypting anything', async () => { + const encrypt = sinon.stub(); const bitgo = { encrypt } as unknown as BitGoBase; - - const result = await buildSafeRecoverKeyParams({ - bitgo, - coin: 'icp', - slot: 'ecdsaMpc', - keys: mpcKeys, - mode: 'signed', - }); - - if (result.slot !== 'ecdsaMpc' || result.mode !== 'signed') { - throw new Error('expected signed ecdsaMpc key params'); + for (const seed of ['11'.repeat(31) + 'zz', '11'.repeat(31), '11'.repeat(33)]) { + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: { ...edKeys, user: { ...edKeys.user, seed } }, + mode: 'signed', + }).should.be.rejectedWith(/ed25519 user seed must be 32 bytes/); } - result.userKey.should.equal('enc(user-child-share)'); - result.backupKey.should.equal('enc(backup-child-share)'); - result.bitgoKey.should.equal('ck'); - result.isTss.should.equal(true); - result.broadcast.should.equal(false); - result.walletPassphrase.should.be.a.String(); - passphrases.should.have.length(2); - passphrases[0].should.equal(passphrases[1]); - passphrases[0].should.equal(result.walletPassphrase); - encrypt.callCount.should.equal(2); + encrypt.callCount.should.equal(0); }); + }); - it('unsigned mode returns the child common keychain and never encrypts', async () => { - const encrypt = sinon.stub().callsFake(async () => { - throw new Error('encrypt should not be called'); + for (const { slot, coin } of [ + { slot: 'ecdsaMpc', coin: 'icp' }, + { slot: 'eddsaMpc', coin: 'sol' }, + ] as const) { + describe(slot, function () { + const mpcKeys = { commonKeychain: 'ck', user: 'user-child-share', backup: 'backup-child-share' }; + + it('signed mode re-encrypts the child reduced shares under a single ephemeral passphrase', async function () { + const passphrases: string[] = []; + const encrypt = sinon.stub().callsFake(async ({ input, password, encryptionVersion }) => { + passphrases.push(password); + encryptionVersion.should.equal(1); + return `enc(${input})`; + }); + const bitgo = { encrypt } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ bitgo, coin, slot, keys: mpcKeys, mode: 'signed' }); + + assert('isTss' in result && result.mode === 'signed'); + result.slot.should.equal(slot); + result.userKey.should.equal('enc(user-child-share)'); + result.backupKey.should.equal('enc(backup-child-share)'); + result.bitgoKey.should.equal('ck'); + result.isTss.should.equal(true); + result.broadcast.should.equal(false); + result.walletPassphrase.should.be.a.String(); + passphrases.should.have.length(2); + passphrases[0].should.equal(passphrases[1]); + passphrases[0].should.equal(result.walletPassphrase); + encrypt.callCount.should.equal(2); }); - const bitgo = { encrypt } as unknown as BitGoBase; - const result = await buildSafeRecoverKeyParams({ - bitgo, - coin: 'icp', - slot: 'ecdsaMpc', - keys: mpcKeys, - mode: 'unsigned', + it('unsigned mode returns the child common keychain and never encrypts', async function () { + const encrypt = sinon.stub().callsFake(async () => { + throw new Error('encrypt should not be called'); + }); + const bitgo = { encrypt } as unknown as BitGoBase; + + const result = await buildSafeRecoverKeyParams({ bitgo, coin, slot, keys: mpcKeys, mode: 'unsigned' }); + + assert('isTss' in result && result.mode === 'unsigned'); + result.slot.should.equal(slot); + result.bitgoKey.should.equal('ck'); + result.isTss.should.equal(true); + result.broadcast.should.equal(false); + result.should.not.have.property('userKey'); + result.should.not.have.property('backupKey'); + result.should.not.have.property('walletPassphrase'); + encrypt.callCount.should.equal(0); }); - - result.slot.should.equal('ecdsaMpc'); - result.mode.should.equal('unsigned'); - result.bitgoKey.should.equal('ck'); - result.isTss.should.equal(true); - result.broadcast.should.equal(false); - result.should.not.have.property('userKey'); - result.should.not.have.property('backupKey'); - result.should.not.have.property('walletPassphrase'); - encrypt.callCount.should.equal(0); }); - }); + } }); describe('Safe ecdsaMpc derive → sign (gate)', function () {