From bb4fa6d24ad7356a5bfa78248f3fabb6dc91fc42 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Mon, 9 Nov 2026 11:54:57 -0500 Subject: [PATCH] feat: sign and share ed25519 multisig safe wallets Safe owners of ALGO, XLM and HBAR wallets could neither sign nor share spend: the shared safe key resolver rejected every ed25519 family because it only knew secp256k1 BIP32 derivation. Add an ed25519 branch to the resolver. It SLIP-0010 hardened-derives the user child from the root at the registered path, encodes the derived pub for the coin and requires it to equal the registered child pub, and returns only the child seed encoded the way the coin signs with it. The root is never returned or placed in a share payload. Move the per-coin ed25519 encoders out of recovery into sdk-lib-safes so recovery, owner signing and sharing use one registry. MPC safe children already carry their own encrypted signing share and share through the ordinary TSS path. Keep a fail-closed error for a public-only TSS child and drop the wording that claimed a missing derive ceremony. TICKET: WCN-2431 TICKET: WCN-4257 --- modules/sdk-core/src/bitgo/safe/recovery.ts | 43 +--- .../sdk-core/src/bitgo/wallet/safeKeychain.ts | 144 ++++++----- .../test/unit/bitgo/wallet/safeGetUserPrv.ts | 153 +++++++++-- .../test/unit/bitgo/wallet/safeShareWallet.ts | 241 +++++++++++++++++- .../sdk-lib-safes/src/ed25519CoinEncoders.ts | 61 +++++ modules/sdk-lib-safes/src/index.ts | 1 + .../test/unit/ed25519CoinEncoders.ts | 113 ++++++++ 7 files changed, 627 insertions(+), 129 deletions(-) create mode 100644 modules/sdk-lib-safes/src/ed25519CoinEncoders.ts create mode 100644 modules/sdk-lib-safes/test/unit/ed25519CoinEncoders.ts diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index ea70ffc030f..79558c3c51a 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 66e99f4adad..071dbab5ece 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 edda4854b6a..53165e668ac 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 63636c40788..000aa361432 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 00000000000..0a3b09a730d --- /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 9b41befe0ab..b8ba476d9ef 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 00000000000..0939ad7086c --- /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/); + } + }); +});