diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index ea70ffc030..79558c3c51 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -18,12 +18,7 @@ import { parseSafeBip44Path, parseSafeKeycardBox, toSoftTwinPath, - algoMultisigAddress, - encodeAlgoKey, - encodeHbarPublicKeyDer, - encodeHbarPrivateKeyDer, - encodeEd25519StrKeyPublicKey, - encodeEd25519StrKeySecretSeed, + getEd25519MultisigCoinEncoders, type SafeKeycardRootEntry, } from '@bitgo/sdk-lib-safes'; import { @@ -338,40 +333,6 @@ export async function deriveSafeWalletKeys(params: { 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. - */ -const ALGO_ENCODERS = { - encodePub: encodeAlgoKey, - encodeSeed: encodeAlgoKey, - deriveRootAddress: (userPub: Buffer, backupPub: Buffer, bitgoPub: Buffer) => - algoMultisigAddress(1, 2, [userPub, backupPub, bitgoPub]), -}; - -const XLM_ENCODERS = { - encodePub: encodeEd25519StrKeyPublicKey, - encodeSeed: encodeEd25519StrKeySecretSeed, -}; - -const ED25519_MULTISIG_COIN_ENCODERS: Record< - string, - | { - encodePub: (bytes: Buffer) => string; - encodeSeed: (bytes: Buffer) => string; - deriveRootAddress?: (userPub: Buffer, backupPub: Buffer, bitgoPub: Buffer) => string; - } - | undefined -> = { - algo: ALGO_ENCODERS, - talgo: ALGO_ENCODERS, - hbar: { encodePub: encodeHbarPublicKeyDer, encodeSeed: encodeHbarPrivateKeyDer }, - thbar: { encodePub: encodeHbarPublicKeyDer, encodeSeed: encodeHbarPrivateKeyDer }, - xlm: XLM_ENCODERS, - txlm: XLM_ENCODERS, -}; - /** * The request shape for `buildSafeRecoverKeyParams`. The `slot` literal is the discriminant that * ties each slot to its own `keys` shape, so TypeScript narrows `params.keys` automatically and a @@ -502,7 +463,7 @@ async function buildEd25519MultisigRecoverKeyParams( keys: Ed25519MultisigChildKeys, mode: 'signed' | 'unsigned' ): Promise { - const encoders = ED25519_MULTISIG_COIN_ENCODERS[coin]; + const encoders = getEd25519MultisigCoinEncoders(coin); if (!encoders) { throw new Error(`buildSafeRecoverKeyParams: coin '${coin}' ed25519Multisig is not supported yet`); } diff --git a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts index 66e99f4ada..071dbab5ec 100644 --- a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts +++ b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts @@ -1,9 +1,17 @@ /** * @prettier */ +import { Buffer } from 'buffer'; import { BitGoBase } from '../bitgoBase'; import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain'; -import { deriveSafeChildHardenedFromXprv, DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; +import { + decodeEd25519StrKeyPublicKey, + decodeEd25519StrKeySecretSeed, + deriveSafeChildEd25519Hardened, + deriveSafeChildHardenedFromXprv, + DerivedFromParentWithHardenedPath, + getEd25519MultisigCoinEncoders, +} from '@bitgo/sdk-lib-safes'; import { decodeWithCodec } from '../utils/codecs'; import { IncorrectPasswordError } from '../errors'; import type { DecryptedKeychainData } from './iWallet'; @@ -29,7 +37,7 @@ export class SafeDerivedPublicKeyMismatchError extends Error { } } -/** Thrown when owner signing is not implemented for this safe slot (TSS, ed25519 multisig, …). */ +/** Thrown when owner signing cannot resolve child key material for this safe user key (public-only TSS child). */ export class SafeOwnerSigningNotImplementedError extends Error { constructor(walletId: string, detail: string) { super(`Safe wallet ${walletId}: ${detail}`); @@ -37,7 +45,7 @@ export class SafeOwnerSigningNotImplementedError extends Error { } } -/** Thrown when wallet sharing is not implemented for this safe slot (TSS, ed25519 multisig, …). */ +/** Thrown when wallet sharing cannot resolve child key material for this safe user key (public-only TSS child). */ export class SafeShareNotImplementedError extends Error { constructor(walletId: string, detail: string) { super(`Safe wallet ${walletId}: ${detail}`); @@ -45,9 +53,6 @@ export class SafeShareNotImplementedError extends Error { } } -/** ed25519 onchain multisig (slot ④). Needs SLIP-0010, not secp256k1 BIP32. */ -const ED25519_ONCHAIN_FAMILIES = new Set(['algo', 'xlm', 'hbar']); - /** * True when this is the safe minter's user key: wallet is in a safe, the key * has a parent root, and there is no child-level encryptedPrv (sharees have one). @@ -88,8 +93,9 @@ export interface SafeKeyMaterialBaseParams { bitgo: BitGoBase; keychains: IKeychains; walletId: string; - /** Onchain secp256k1: hardened-derive and verify pub. Other slots throw. */ + /** `'onchain'`: hardened-derive and verify the registered pub. Anything else throws. */ multisigType: string | undefined; + /** Coin family of the wallet (`baseCoin.getFamily()`); selects ed25519 vs secp256k1 derivation. */ coinFamily: string; childKeychain: Keychain; walletPassphrase: string; @@ -99,21 +105,34 @@ export interface SafeKeyMaterialBaseParams { export type ResolveSafeOwnerSigningPrvParams = SafeKeyMaterialBaseParams; -type SafeKeyMaterialSlot = 'tss' | 'ed25519'; - type ResolveSafeKeyMaterialParams = SafeKeyMaterialBaseParams & { - /** Constructs the not-implemented error for the current resolver (signing vs sharing). */ - makeNotImplementedError: (slot: SafeKeyMaterialSlot, walletId: string) => Error; + /** Constructs the error for a non-onchain wallet whose user child has no encryptedPrv (signing vs sharing). */ + makeUnsupportedMultisigTypeError: (walletId: string) => Error; }; +function isEd25519StrKeySecretSeed(value: string): boolean { + try { + decodeEd25519StrKeySecretSeed(value); + return true; + } catch { + return false; + } +} + /** * Shared core that resolves safe key material for a pub-only safe child. Returns the CHILD * `{prv, pub}` only — never the root — so the root can never leak into a share document. * - * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` - * (`m/'`), and verify the registered pub. TSS and ed25519 onchain throw via - * `makeNotImplementedError` — the caller constructs its own error class + message, so the - * guard set stays shared while signing/sharing report their own errors. + * - Onchain secp256k1: decrypt the root (BIP32 xprv), hardened-derive at + * `derivedFromParentWithPath` (`m/44'/'/'/'`) and verify the registered pub. + * - Onchain ed25519 (coins with an entry in the ed25519 multisig encoder registry): decrypt the + * root (a StrKey secret seed), SLIP-0010 hardened-derive at the same path, and encode the + * child into the coin's native formats. The derived pub, encoded for the coin, must equal the + * registered child pub; the returned `prv` is the coin-encoded child seed and the returned + * `pub` is the registered (coin-format) child pub. + * - Anything else (`multisigType !== 'onchain'`): an MPC safe child always carries its own + * encryptedPrv and never reaches this resolver, so a public-only one is an invalid state and + * fails closed via `makeUnsupportedMultisigTypeError`. */ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Promise<{ prv: string; pub: string }> { const { @@ -124,14 +143,11 @@ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Pro coinFamily, childKeychain, walletPassphrase, - makeNotImplementedError, + makeUnsupportedMultisigTypeError, } = params; if (multisigType !== 'onchain') { - throw makeNotImplementedError('tss', walletId); - } - if (ED25519_ONCHAIN_FAMILIES.has(coinFamily)) { - throw makeNotImplementedError('ed25519', walletId); + throw makeUnsupportedMultisigTypeError(walletId); } const rootKeychain = params.rootKeychain ?? (await fetchRootKeychainForSafeChild(keychains, childKeychain)); @@ -146,15 +162,33 @@ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Pro if (childKeychain.derivedFromParentWithPath === undefined) { throw new Error(`Safe wallet ${walletId}: child keychain is missing derivedFromParentWithPath`); } - - const derived = deriveSafeChildHardenedFromXprv( - rootPrv, - decodeWithCodec( - DerivedFromParentWithHardenedPath, - childKeychain.derivedFromParentWithPath, - 'derivedFromParentWithPath' - ) + const path = decodeWithCodec( + DerivedFromParentWithHardenedPath, + childKeychain.derivedFromParentWithPath, + 'derivedFromParentWithPath' ); + + const encoders = getEd25519MultisigCoinEncoders(coinFamily); + if (encoders) { + const derived = deriveSafeChildEd25519Hardened(rootPrv, path); + const encodedPub = encoders.encodePub(decodeEd25519StrKeyPublicKey(derived.pub)); + if (encodedPub !== childKeychain.pub) { + throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, encodedPub); + } + // The root pub is a StrKey; the child pub is coin-format. Compare in both forms. + if (derived.pub === rootKeychain.pub || encodedPub === rootKeychain.pub) { + throw new Error(`Safe wallet ${walletId}: derived child pub unexpectedly equals the root pub`); + } + return { prv: encoders.encodeSeed(Buffer.from(derived.prv, 'hex')), pub: childKeychain.pub }; + } + + if (isEd25519StrKeySecretSeed(rootPrv)) { + throw new Error( + `Safe wallet ${walletId}: coin family '${coinFamily}' has an ed25519 safe root but no ed25519 multisig key encoders` + ); + } + + const derived = deriveSafeChildHardenedFromXprv(rootPrv, path); if (derived.pub !== childKeychain.pub) { throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub); } @@ -168,9 +202,10 @@ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Pro /** * Resolve signing material for a safe owner (child key has no encryptedPrv). * - * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` - * (`m/44'/'/'/'`), and verify the registered pub. - * TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve. + * Onchain secp256k1 and onchain ed25519 (ALGO, XLM, HBAR): decrypt the root, hardened-derive the + * child at `derivedFromParentWithPath` (`m/44'/'/'/'`), verify the registered + * pub, and return the child private key in the format the coin signs with. Never returns the root. + * A public-only TSS child is an invalid state and throws. * * Do not use for wallet sharing — that must not receive root key material. * Call only when `isSafeChildPublicOnlyKeychain` is true. @@ -178,41 +213,26 @@ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Pro export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise { const { prv } = await resolveSafeKeyMaterial({ ...params, - makeNotImplementedError: (slot, walletId) => - slot === 'ed25519' - ? new SafeOwnerSigningNotImplementedError( - walletId, - `ed25519 multisig owner derivation (${params.coinFamily}) is not implemented; BIP32 would produce the wrong child key.` - ) - : new SafeOwnerSigningNotImplementedError( - walletId, - 'TSS owner signing from the root keyshare is not implemented. ' + - 'Returning the root private key would expose material that can derive every child in this slot.' - ), + makeUnsupportedMultisigTypeError: (walletId) => + new SafeOwnerSigningNotImplementedError( + walletId, + `cannot resolve an owner signing key from a public-only ${params.multisigType ?? 'unknown'} user key. ` + + 'MPC safe user keys always carry their own encrypted signing share, so this keychain is in an invalid state.' + ), }); return prv; } -export interface ResolveSafeChildPrvForSharingParams extends SafeKeyMaterialBaseParams { - mpcAlgorithm?: 'ecdsa' | 'eddsa'; -} - -/** Slot-named not-implemented detail for wallet sharing. */ -function safeShareSlotDetail(slot: SafeKeyMaterialSlot, params: ResolveSafeChildPrvForSharingParams): string { - if (slot === 'ed25519') { - return `ed25519 multisig safe sharing (${params.coinFamily}) is not implemented; BIP32 would derive the wrong child.`; - } - return params.mpcAlgorithm === 'eddsa' - ? 'eddsaMpc safe sharing is not implemented (needs the EdDSA derive ceremony).' - : 'ecdsaMpc safe sharing is not implemented (needs the DKLS derive ceremony).'; -} +export type ResolveSafeChildPrvForSharingParams = SafeKeyMaterialBaseParams; /** * Resolve sharing material for a safe owner (child key has no encryptedPrv). * - * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` - * (`m/'`), verify the registered pub, and return the CHILD `{prv, pub}` — never the root. - * TSS and ed25519 onchain: throw `SafeShareNotImplementedError` naming the slot + blocker. + * Onchain secp256k1 and onchain ed25519 (ALGO, XLM, HBAR): decrypt the root, hardened-derive the + * child at `derivedFromParentWithPath`, verify the registered pub, and return the CHILD + * `{prv, pub}` — never the root. `prv` is in the format the coin signs with and `pub` is the + * registered child pub, as the platform stores it. + * A public-only TSS child is an invalid state and throws `SafeShareNotImplementedError`. * * Call only when `isSafeChildPublicOnlyKeychain` is true. */ @@ -221,7 +241,11 @@ export async function resolveSafeChildPrvForSharing( ): Promise { return resolveSafeKeyMaterial({ ...params, - makeNotImplementedError: (slot, walletId) => - new SafeShareNotImplementedError(walletId, safeShareSlotDetail(slot, params)), + makeUnsupportedMultisigTypeError: (walletId) => + new SafeShareNotImplementedError( + walletId, + `cannot share a public-only ${params.multisigType ?? 'unknown'} user key. ` + + 'MPC safe user keys always carry their own encrypted signing share, so this keychain is in an invalid state.' + ), }); } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts index edda4854b6..53165e668a 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts @@ -333,24 +333,143 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); }); - it('fails closed for ed25519 onchain safe owner instead of BIP32-deriving', async function () { - mockBaseCoin.getFamily.returns('xlm'); - const wallet = makeWallet({ safe: 'safe-id-1', coin: 'txlm' }); + describe('ed25519Multisig safe owner (SLIP-0010 hardened child)', function () { + // Fixed root seed; children derived at m/44'/'/2'/0'. Expected values were produced with + // the public reference libraries (stellar-base for XLM, algosdk for ALGO, the documented Hedera + // DER prefixes for HBAR) from the pinned SLIP-0010 child seed. + const ED25519_ROOT_SEED = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + const ED25519_ROOT_PUB = 'GAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3QZ6Q'; + const HBAR_PUB_PREFIX = '302a300506032b6570032100'; + const HBAR_PRV_PREFIX = '302e020100300506032b657004220420'; + + const cases = [ + { + chain: 'txlm', + family: 'xlm', + coinType: 148, + childSeedHex: '855e8d991b48037b3e0161fd57f1643044878d6afe0db0243719b5201e2e4f69', + storedPub: 'GDD3VVFKM64URLG6PO2UFWQXJH67N5UDOV5HZ23M5FJJT5G4LUO7RJWG', + expectedPrv: 'SCCV5DMZDNEAG6Z6AFQ72V7RMQYEJB4NNL7A3MBEG4M3KIA6FZHWSAED', + }, + { + chain: 'talgo', + family: 'algo', + coinType: 283, + childSeedHex: 'be69531ebbab851b69aa41bb9c905afed52d26ff4278e6418609d4d60570510c', + storedPub: 'Q3LIAOII4UOIX7EIT7E5PCG3ZL3FNA3PPLCPC2PLZEFB4YHOVMS5P3DSOM', + expectedPrv: 'XZUVGHV3VOCRW2NKIG5ZZEC273KS2JX7IJ4OMQMGBHKNMBLQKEGFCK75SE', + }, + { + chain: 'thbar', + family: 'hbar', + coinType: 3030, + childSeedHex: '7dff4c0c0080e641e9273cf304804fcfd7e35a1b52424128dc69ad7a46783c7e', + storedPub: HBAR_PUB_PREFIX + '3bc193e74ce74dc20ab1ada472831abc7f9977435dada3fa3d5e2e5ba91509a3', + expectedPrv: HBAR_PRV_PREFIX + '7dff4c0c0080e641e9273cf304804fcfd7e35a1b52424128dc69ad7a46783c7e', + }, + ]; - await wallet - .getUserPrv({ - keychain: { - id: 'child-key', - pub: 'child-pub', - type: 'independent', - parent: rootKeyId, - derivedFromParentWithSeed: '123', - }, - walletPassphrase: passphrase, - }) - .should.be.rejectedWith(SafeOwnerSigningNotImplementedError); - keychainsGetStub.notCalled.should.be.true(); - mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + const ed25519RootKeychain = { + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${ED25519_ROOT_SEED}`, + type: 'independent', + pub: ED25519_ROOT_PUB, + }; + + for (const c of cases) { + const path = buildSafeBip44Path(c.coinType, 'ed25519Multisig', 0); + const ownerChild = { + id: 'child-key', + pub: c.storedPub, + type: 'independent' as const, + parent: rootKeyId, + derivedFromParentWithPath: path, + }; + + it(`${c.chain}: resolves the pinned SLIP-0010 child prv in the coin's signing format`, async function () { + mockBaseCoin.getFamily.returns(c.family); + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + keychainsGetStub.resolves(ed25519RootKeychain); + + const result = await wallet.getUserPrv({ keychain: ownerChild, walletPassphrase: passphrase }); + + result.should.equal(c.expectedPrv); + result.should.not.containEql(ED25519_ROOT_SEED); + keychainsGetStub.calledOnceWithExactly({ id: rootKeyId }).should.be.true(); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + if (c.family === 'hbar') { + // DER-prefixed private key wraps the raw SLIP-0010 child seed + result.should.equal(HBAR_PRV_PREFIX + c.childSeedHex); + } + }); + + it(`${c.chain}: fails closed when the registered pub does not match the derivation`, async function () { + mockBaseCoin.getFamily.returns(c.family); + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + keychainsGetStub.resolves(ed25519RootKeychain); + // another account's child is a valid pub for this coin but not the one at the registered path + const otherAccountPath = buildSafeBip44Path(c.coinType, 'ed25519Multisig', 1); + + await wallet + .getUserPrv({ + keychain: { ...ownerChild, derivedFromParentWithPath: otherAccountPath }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); + }); + + it(`${c.chain}: fails closed for a m/0' single-level path at the path codec`, async function () { + mockBaseCoin.getFamily.returns(c.family); + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + keychainsGetStub.resolves(ed25519RootKeychain); + + await wallet + .getUserPrv({ + keychain: { ...ownerChild, derivedFromParentWithPath: "m/0'" }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(/derivedFromParentWithPath/); + }); + } + + it('requires the registered pub in coin format (a bare G... strkey is a mismatch for ALGO)', async function () { + mockBaseCoin.getFamily.returns('algo'); + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'talgo' }); + keychainsGetStub.resolves(ed25519RootKeychain); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'GCDNNABZBDSRZC74RCP4TV4I3PFPMVUDN55MJ4LJ5PEQUHTA52VSKJKD', + type: 'independent', + parent: rootKeyId, + derivedFromParentWithPath: buildSafeBip44Path(283, 'ed25519Multisig', 0), + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); + }); + + it('rejects a wrong passphrase with IncorrectPasswordError', async function () { + mockBaseCoin.getFamily.returns('xlm'); + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'txlm' }); + keychainsGetStub.resolves(ed25519RootKeychain); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: cases[0].storedPub, + type: 'independent', + parent: rootKeyId, + derivedFromParentWithPath: buildSafeBip44Path(148, 'ed25519Multisig', 0), + }, + walletPassphrase: 'wrong-passphrase', + }) + .should.be.rejectedWith(IncorrectPasswordError); + }); }); it('aborts locally when derived pub does not match registered child pub', async function () { diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts index 63636c4078..000aa36143 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts @@ -8,6 +8,7 @@ import 'should'; import * as sinon from 'sinon'; import { + BulkCreateShareOption, IncorrectPasswordError, SafeDerivedPublicKeyMismatchError, SafeShareNotImplementedError, @@ -55,6 +56,18 @@ describe('Safe wallet spend sharing', function () { derivedFromParentWithPath: childPath, }); const publicOnlyKeychain = (id: string) => ({ id, pub: 'pub-' + id, type: 'independent' as const }); + // A real MPC safe child: it carries its own encrypted signing share, so it never takes the root detour. + const MPC_COMMON_PUB = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const MPC_COMMON_KEYCHAIN = MPC_COMMON_PUB + 'ab'.repeat(32); + const MPC_CHILD_SHARE = 'mpc-child-signing-share'; + const mpcChildKeychain = (id: string) => ({ + id, + type: 'tss' as const, + commonKeychain: MPC_COMMON_KEYCHAIN, + parent: rootKeyId, + derivedFromParentWithPath: buildSafeBip44Path(0, 'ecdsaMpc', 0), + encryptedPrv: `enc:${MPC_CHILD_SHARE}`, + }); const rootKeychain = { id: rootKeyId, source: 'user' as const, @@ -201,24 +214,209 @@ describe('Safe wallet spend sharing', function () { }); }); - describe('unsupported safe slots', function () { - it('TSS safe wallet throws SafeShareNotImplementedError without fetching the root', async function () { - const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + describe('ed25519Multisig safe wallets (xlm, algo, hbar)', function () { + // Fixed root seed; children derived with SLIP-0010 at m/44'/'/2'/0'. Expected strings were + // produced with public reference libraries (stellar-base, algosdk, documented Hedera DER prefixes). + const ED25519_ROOT_SEED = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + const ED25519_ROOT_PUB = 'GAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3QZ6Q'; + const ed25519RootKeychain = { + id: rootKeyId, + source: 'user' as const, + encryptedPrv: `enc:${ED25519_ROOT_SEED}`, + type: 'independent' as const, + pub: ED25519_ROOT_PUB, + }; + + const cases = [ + { + chain: 'txlm', + family: 'xlm', + coinType: 148, + storedPub: 'GDD3VVFKM64URLG6PO2UFWQXJH67N5UDOV5HZ23M5FJJT5G4LUO7RJWG', + childPrv: 'SCCV5DMZDNEAG6Z6AFQ72V7RMQYEJB4NNL7A3MBEG4M3KIA6FZHWSAED', + }, + { + chain: 'talgo', + family: 'algo', + coinType: 283, + storedPub: 'Q3LIAOII4UOIX7EIT7E5PCG3ZL3FNA3PPLCPC2PLZEFB4YHOVMS5P3DSOM', + childPrv: 'XZUVGHV3VOCRW2NKIG5ZZEC273KS2JX7IJ4OMQMGBHKNMBLQKEGFCK75SE', + }, + { + chain: 'thbar', + family: 'hbar', + coinType: 3030, + storedPub: '302a300506032b6570032100' + '3bc193e74ce74dc20ab1ada472831abc7f9977435dada3fa3d5e2e5ba91509a3', + childPrv: + '302e020100300506032b657004220420' + '7dff4c0c0080e641e9273cf304804fcfd7e35a1b52424128dc69ad7a46783c7e', + }, + ]; + + const ed25519Child = (id: string, coinType: number, storedPub: string, account = 0) => ({ + id, + pub: storedPub, + type: 'independent' as const, + parent: rootKeyId, + derivedFromParentWithPath: buildSafeBip44Path(coinType, 'ed25519Multisig', account), + }); + + function stubEd25519Keychains(coinType: number, storedPub: string, account = 0) { + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve( + id === rootKeyId + ? ed25519RootKeychain + : id === 'user-key' + ? ed25519Child(id, coinType, storedPub, account) + : publicOnlyKeychain(id) + ) + ); + } + + for (const c of cases) { + describe(`${c.chain}`, function () { + beforeEach(function () { + mockBaseCoin.getFamily.returns(c.family); + stubEd25519Keychains(c.coinType, c.storedPub); + }); + + it('shares the coin-encoded child with the stored coin-format pub and never ships root material', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + const options = await getShareOptions(wallet); + + options.skipKeychain.should.equal(false); + options.keychain.should.be.ok(); + options.keychain.pub.should.equal(c.storedPub); + + const json = JSON.stringify(options); + json.should.not.containEql(ED25519_ROOT_SEED); + json.should.not.containEql(ED25519_ROOT_PUB); + json.should.not.containEql(rootKeyId); + }); + + it('the encrypted prv ECDH-decrypts (sharee side) to the coin-encoded child seed', async function () { + const shareeKey = makeRandomKey(); + const shareePub = shareeKey.publicKey.toString('hex'); + mockBitGo.getSharingKey = sinon.stub().resolves({ userId: 'sharee-id', pubkey: shareePub, path: 'm/0' }); + + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + const options = await getShareOptions(wallet); + + const shareeSecret = getSharedSecret(shareeKey, Buffer.from(options.keychain.fromPubKey, 'hex')).toString( + 'hex' + ); + options.keychain.encryptedPrv.should.equal(`shared:${shareeSecret}:${c.childPrv}`); + options.keychain.encryptedPrv.should.not.containEql(ED25519_ROOT_SEED); + }); + + it('fails closed when the derived pub does not match the registered child pub', async function () { + // the child registered at account 1 is not the key derived at the stored account-0 pub + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + stubEd25519Keychains(c.coinType, c.storedPub, 1); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); + createShareStub.notCalled.should.equal(true); + }); + + it('fails closed for the retired single-level m/0 path', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', coin: c.chain }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve( + id === rootKeyId + ? ed25519RootKeychain + : id === 'user-key' + ? { ...ed25519Child(id, c.coinType, c.storedPub), derivedFromParentWithPath: "m/0'" } + : publicOnlyKeychain(id) + ) + ); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(/derivedFromParentWithPath/); + createShareStub.notCalled.should.equal(true); + }); + }); + } + + it('a wrong passphrase rejects with IncorrectPasswordError and posts nothing', async function () { + mockBaseCoin.getFamily.returns('xlm'); + stubEd25519Keychains(148, cases[0].storedPub); + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'txlm' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: 'wrong-passphrase' }) + .should.be.rejectedWith(IncorrectPasswordError); + createShareStub.notCalled.should.equal(true); + }); + + it('a coin family without ed25519 key encoders fails closed instead of BIP32-deriving the seed', async function () { + mockBaseCoin.getFamily.returns('sol'); + stubEd25519Keychains(148, cases[0].storedPub); + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'tsol' }); await wallet .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) - .should.be.rejectedWith(SafeShareNotImplementedError); - // child fetched (safe branch), root never fetched (guard fires first) - keychainsGetStub.calledOnce.should.equal(true); - keychainsGetStub.firstCall.args[0].should.deepEqual({ id: 'user-key' }); + .should.be.rejectedWith(/no ed25519 multisig key encoders/); + createShareStub.notCalled.should.equal(true); }); - it('ed25519 onchain safe wallet throws SafeShareNotImplementedError', async function () { - const wallet = makeWallet({ safe: 'safe-id-1', coin: 'txlm' }); - mockBaseCoin.getFamily.returns('xlm'); + it('bulk share derives the child once and fans it out with the stored pub', async function () { + mockBaseCoin.getFamily.returns('hbar'); + stubEd25519Keychains(3030, cases[2].storedPub); + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'thbar' }); + await wallet.createBulkWalletShare({ + walletPassphrase: passphrase, + keyShareOptions: [ + { userId: 'u1', pubKey: SHAREE_PUB, path: 'm/0', permissions: ['spend'] }, + { userId: 'u2', pubKey: SHAREE_PUB, path: 'm/0', permissions: ['spend'] }, + ], + }); + + const options = createBulkKeySharesStub.firstCall.args[0]; + options.length.should.equal(2); + options.forEach((o: BulkCreateShareOption) => o.keychain.pub.should.equal(cases[2].storedPub)); + keychainsGetStub + .getCalls() + .filter((c) => c.args?.[0]?.id === rootKeyId) + .length.should.equal(1); + }); + }); + + describe('MPC and public-only TSS safe children', function () { + it('a real MPC safe child (has encryptedPrv) shares through the ordinary path without fetching the root', async function () { + mockBaseCoin.getMPCAlgorithm.returns('ecdsa'); + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? mpcChildKeychain(id) : publicOnlyKeychain(id)) + ); + + const shareeKey = makeRandomKey(); + mockBitGo.getSharingKey = sinon + .stub() + .resolves({ userId: 'sharee-id', pubkey: shareeKey.publicKey.toString('hex'), path: 'm/0' }); + const options = await getShareOptions(wallet); + + options.skipKeychain.should.equal(false); + options.keychain.pub.should.equal(MPC_COMMON_PUB); + // the owner's own child signing share is re-encrypted for the sharee, nothing derived from the parent + const shareeSecret = getSharedSecret(shareeKey, Buffer.from(options.keychain.fromPubKey, 'hex')).toString('hex'); + options.keychain.encryptedPrv.should.equal(`shared:${shareeSecret}:${MPC_CHILD_SHARE}`); + const json = JSON.stringify(options); + json.should.not.containEql(rootKeyId); + json.should.not.containEql(prv); + json.should.not.containEql('root-pub'); + keychainsGetStub + .getCalls() + .filter((c) => c.args?.[0]?.id === rootKeyId) + .length.should.equal(0); + }); + + it('a public-only TSS child (invalid state) fails closed without fetching the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); await wallet .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) .should.be.rejectedWith(SafeShareNotImplementedError); + // child fetched (safe branch), root never fetched (the guard fires first) keychainsGetStub.calledOnce.should.equal(true); + keychainsGetStub.firstCall.args[0].should.deepEqual({ id: 'user-key' }); + createShareStub.notCalled.should.equal(true); }); }); @@ -317,7 +515,28 @@ describe('Safe wallet spend sharing', function () { rootCalls.length.should.equal(1); }); - it('the real error propagates instead of shareOptions cannot be empty', async function () { + it('a real MPC safe child (has encryptedPrv) fans out through the ordinary path without fetching the root', async function () { + mockBaseCoin.getMPCAlgorithm.returns('ecdsa'); + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? mpcChildKeychain(id) : publicOnlyKeychain(id)) + ); + await wallet.createBulkWalletShare(bulkParams); + + createBulkKeySharesStub.calledOnce.should.equal(true); + const options = createBulkKeySharesStub.firstCall.args[0]; + options.length.should.equal(2); + options.forEach((o: BulkCreateShareOption) => { + o.keychain.pub.should.equal(MPC_COMMON_PUB); + JSON.stringify(o.keychain).should.not.containEql(rootKeyId); + }); + keychainsGetStub + .getCalls() + .filter((c) => c.args?.[0]?.id === rootKeyId) + .length.should.equal(0); + }); + + it('a public-only TSS child (invalid state) still rejects and sends nothing', async function () { const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); await wallet.createBulkWalletShare(bulkParams).should.be.rejectedWith(SafeShareNotImplementedError); createBulkKeySharesStub.notCalled.should.equal(true); diff --git a/modules/sdk-lib-safes/src/ed25519CoinEncoders.ts b/modules/sdk-lib-safes/src/ed25519CoinEncoders.ts new file mode 100644 index 0000000000..0a3b09a730 --- /dev/null +++ b/modules/sdk-lib-safes/src/ed25519CoinEncoders.ts @@ -0,0 +1,61 @@ +/** + * @prettier + * + * Per-coin encoders for the `ed25519Multisig` safe slot. Every slot coin uses the same raw 32-byte + * ed25519 keys but expects them in its own string format (address / public key / secret seed). + */ +import { + algoMultisigAddress, + encodeAlgoKey, + encodeEd25519StrKeyPublicKey, + encodeEd25519StrKeySecretSeed, + encodeHbarPrivateKeyDer, + encodeHbarPublicKeyDer, +} from './ed25519Pub'; + +export interface Ed25519MultisigCoinEncoders { + /** Raw 32-byte public key -> the coin's stored public key format. */ + encodePub: (rawPub: Buffer) => string; + /** Raw 32-byte secret seed -> the private key format the coin signs with. */ + encodeSeed: (rawSeed: Buffer) => string; + /** Present only for coins whose root address is derivable from the three child public keys. */ + deriveRootAddress?: (userPub: Buffer, backupPub: Buffer, bitgoPub: Buffer) => string; +} + +const ALGO_ENCODERS: Ed25519MultisigCoinEncoders = { + encodePub: encodeAlgoKey, + encodeSeed: encodeAlgoKey, + deriveRootAddress: (userPub, backupPub, bitgoPub) => algoMultisigAddress(1, 2, [userPub, backupPub, bitgoPub]), +}; + +const HBAR_ENCODERS: Ed25519MultisigCoinEncoders = { + encodePub: encodeHbarPublicKeyDer, + encodeSeed: encodeHbarPrivateKeyDer, +}; + +const XLM_ENCODERS: Ed25519MultisigCoinEncoders = { + encodePub: encodeEd25519StrKeyPublicKey, + encodeSeed: encodeEd25519StrKeySecretSeed, +}; + +/** + * Registry keyed by coin chain name. A mainnet chain name is also its coin family name, so + * `getEd25519MultisigCoinEncoders(baseCoin.getFamily())` resolves for testnets too + * (talgo/algo, thbar/hbar, txlm/xlm all share a family). A coin absent from the registry does not + * support `ed25519Multisig` safe key handling. + */ +export const ED25519_MULTISIG_COIN_ENCODERS: Readonly> = { + algo: ALGO_ENCODERS, + talgo: ALGO_ENCODERS, + hbar: HBAR_ENCODERS, + thbar: HBAR_ENCODERS, + xlm: XLM_ENCODERS, + txlm: XLM_ENCODERS, +}; + +/** Look up the encoders for a coin chain or coin family; `undefined` when unsupported. */ +export function getEd25519MultisigCoinEncoders(chainOrFamily: string): Ed25519MultisigCoinEncoders | undefined { + return Object.prototype.hasOwnProperty.call(ED25519_MULTISIG_COIN_ENCODERS, chainOrFamily) + ? ED25519_MULTISIG_COIN_ENCODERS[chainOrFamily] + : undefined; +} diff --git a/modules/sdk-lib-safes/src/index.ts b/modules/sdk-lib-safes/src/index.ts index 9b41befe0a..b8ba476d9e 100644 --- a/modules/sdk-lib-safes/src/index.ts +++ b/modules/sdk-lib-safes/src/index.ts @@ -4,6 +4,7 @@ * @experimental Pure Safe derivation and keycard box codec leaf. Never imports sdk-core/BaseCoin/Wallet/Safe/key-card. */ export * from './ed25519KeyDeriver'; +export * from './ed25519CoinEncoders'; export * from './ed25519Pub'; export * from './keycardCodec'; export * from './safeDerivation'; diff --git a/modules/sdk-lib-safes/test/unit/ed25519CoinEncoders.ts b/modules/sdk-lib-safes/test/unit/ed25519CoinEncoders.ts new file mode 100644 index 0000000000..0939ad7086 --- /dev/null +++ b/modules/sdk-lib-safes/test/unit/ed25519CoinEncoders.ts @@ -0,0 +1,113 @@ +import 'should'; +import { + buildSafeBip44Path, + decodeEd25519StrKeyPublicKey, + deriveSafeChildEd25519Hardened, + ED25519_MULTISIG_COIN_ENCODERS, + getEd25519MultisigCoinEncoders, +} from '../../src'; + +// Same root seed and child as the pinned SLIP-0010 vector in safeDerivation.ts (m/44'/148'/2'/0'). +const ROOT_SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + +// The expected strings below were produced with the public reference libraries: +// XLM: stellar-base `StrKey.encodeEd25519SecretSeed` / `StrKey.decodeEd25519PublicKey` +// ALGO: algosdk `encodeAddress` (an Algorand seed and an address share the same 58-char encoding) +// HBAR: documented DER prefixes (302a300506032b6570032100 public, 302e020100300506032b657004220420 private) +const VECTORS = [ + { + account: 0, + rawSeedHex: '855e8d991b48037b3e0161fd57f1643044878d6afe0db0243719b5201e2e4f69', + strKeyPub: 'GDD3VVFKM64URLG6PO2UFWQXJH67N5UDOV5HZ23M5FJJT5G4LUO7RJWG', + rawPubHex: 'c7bad4aa67b948acde7bb542da1749fdf6f683757a7ceb6ce95299f4dc5d1df8', + xlmSeed: 'SCCV5DMZDNEAG6Z6AFQ72V7RMQYEJB4NNL7A3MBEG4M3KIA6FZHWSAED', + algoAddress: 'Y65NJKTHXFEKZXT3WVBNUF2J7X3PNA3VPJ6OW3HJKKM7JXC5DX4O53XQMQ', + algoSeed: 'QVPI3GI3JABXWPQBMH6VP4LEGBCIPDLK7YG3AJBXDG2SAHROJ5UXL6VE4Q', + hbarPub: '302a300506032b6570032100c7bad4aa67b948acde7bb542da1749fdf6f683757a7ceb6ce95299f4dc5d1df8', + hbarPrv: '302e020100300506032b657004220420855e8d991b48037b3e0161fd57f1643044878d6afe0db0243719b5201e2e4f69', + }, + { + account: 7, + rawSeedHex: '3790123d377fec4805f52cbd0aa6e4f5277ed2ef75dbdbc0bf723370d10c4a82', + strKeyPub: 'GBQ52JFLZ3BEAF3XL3CZKYJ4H67OLMXZ5MNWNMRI57HR7ZPK4PK7UEEY', + rawPubHex: '61dd24abcec24017775ec595613c3fbee5b2f9eb1b66b228efcf1fe5eae3d5fa', + xlmSeed: 'SA3ZAER5G576YSAF6UWL2CVG4T2SO7WS5525XW6AX5ZDG4GRBRFIEFHX', + algoAddress: 'MHOSJK6OYJABO526YWKWCPB7X3S3F6PLDNTLEKHPZ4P6L2XD2X5DG3N6NU', + algoSeed: 'G6IBEPJXP7WEQBPVFS6QVJXE6UTX5UXPOXN5XQF7OIZXBUIMJKBD2PN6OE', + hbarPub: '302a300506032b6570032100' + '61dd24abcec24017775ec595613c3fbee5b2f9eb1b66b228efcf1fe5eae3d5fa', + hbarPrv: '302e020100300506032b657004220420' + '3790123d377fec4805f52cbd0aa6e4f5277ed2ef75dbdbc0bf723370d10c4a82', + }, +]; + +describe('ed25519CoinEncoders', function () { + describe('registry lookup', function () { + it('resolves mainnet and testnet chains to the same encoders per family', function () { + getEd25519MultisigCoinEncoders('xlm')!.should.equal(getEd25519MultisigCoinEncoders('txlm')!); + getEd25519MultisigCoinEncoders('algo')!.should.equal(getEd25519MultisigCoinEncoders('talgo')!); + getEd25519MultisigCoinEncoders('hbar')!.should.equal(getEd25519MultisigCoinEncoders('thbar')!); + }); + + it('returns undefined for unsupported coins and inherited object keys', function () { + for (const name of ['btc', 'tbtc', 'eth', 'sol', '', 'constructor', 'toString', '__proto__']) { + (getEd25519MultisigCoinEncoders(name) === undefined).should.equal(true, name); + } + }); + + it('exposes a root address deriver for ALGO only', function () { + for (const [chain, encoders] of Object.entries(ED25519_MULTISIG_COIN_ENCODERS)) { + const hasDeriver = encoders?.deriveRootAddress !== undefined; + hasDeriver.should.equal(chain === 'algo' || chain === 'talgo', chain); + } + }); + }); + + for (const vector of VECTORS) { + describe(`pinned vector at account ${vector.account}`, function () { + const rawSeed = Buffer.from(vector.rawSeedHex, 'hex'); + const rawPub = Buffer.from(vector.rawPubHex, 'hex'); + + it('inputs match the SLIP-0010 derivation of the root seed', function () { + const derived = deriveSafeChildEd25519Hardened( + ROOT_SEED_STRKEY, + buildSafeBip44Path(148, 'ed25519Multisig', vector.account) + ); + derived.prv.should.equal(vector.rawSeedHex); + derived.pub.should.equal(vector.strKeyPub); + decodeEd25519StrKeyPublicKey(derived.pub).toString('hex').should.equal(vector.rawPubHex); + }); + + it('XLM / TXLM: G... public key and S... secret seed', function () { + for (const chain of ['xlm', 'txlm']) { + const encoders = getEd25519MultisigCoinEncoders(chain)!; + encoders.encodePub(rawPub).should.equal(vector.strKeyPub); + encoders.encodeSeed(rawSeed).should.equal(vector.xlmSeed); + } + }); + + it('ALGO / TALGO: 58-char base32 address and seed', function () { + for (const chain of ['algo', 'talgo']) { + const encoders = getEd25519MultisigCoinEncoders(chain)!; + encoders.encodePub(rawPub).should.equal(vector.algoAddress); + encoders.encodeSeed(rawSeed).should.equal(vector.algoSeed); + } + }); + + it('HBAR / THBAR: DER-prefixed public and private key hex', function () { + for (const chain of ['hbar', 'thbar']) { + const encoders = getEd25519MultisigCoinEncoders(chain)!; + encoders.encodePub(rawPub).should.equal(vector.hbarPub); + encoders.encodePub(rawPub).should.match(/^302a300506032b6570032100[0-9a-f]{64}$/); + encoders.encodeSeed(rawSeed).should.equal(vector.hbarPrv); + encoders.encodeSeed(rawSeed).should.match(/^302e020100300506032b657004220420[0-9a-f]{64}$/); + } + }); + }); + } + + it('every registered encoder rejects keys that are not 32 bytes', function () { + for (const encoders of Object.values(ED25519_MULTISIG_COIN_ENCODERS)) { + (() => encoders!.encodePub(Buffer.alloc(31))).should.throw(/32 bytes/); + (() => encoders!.encodeSeed(Buffer.alloc(33))).should.throw(/32 bytes/); + } + }); +});