diff --git a/modules/sdk-coin-algo/package.json b/modules/sdk-coin-algo/package.json index 13e71d3cb6e..23dd5d99d34 100644 --- a/modules/sdk-coin-algo/package.json +++ b/modules/sdk-coin-algo/package.json @@ -56,6 +56,7 @@ "devDependencies": { "@bitgo/sdk-api": "^3.1.3", "@bitgo/sdk-test": "^9.1.88", + "@noble/curves": "1.8.1", "should": "^13.1.3", "sinon": "^7.5.0" }, diff --git a/modules/sdk-coin-algo/src/algo.ts b/modules/sdk-coin-algo/src/algo.ts index bf2e55ce98c..ddeb9adff5c 100644 --- a/modules/sdk-coin-algo/src/algo.ts +++ b/modules/sdk-coin-algo/src/algo.ts @@ -12,8 +12,8 @@ import { BaseCoin, BitGoBase, ErrorNoInputToRecover, - assertValidExternalEd25519Signature, - ExternalEd25519Signature, + assertValidExternalEd25519Signer, + ExternalEd25519Signer, InvalidAddressError, InvalidKey, KeyIndices, @@ -22,6 +22,7 @@ import { ParseTransactionOptions, RecoveryKeyMismatchError, RecoveryProviderError, + signWithExternalEd25519Signer, SignedTransaction, SignTransactionOptions as BaseSignTransactionOptions, TokenManagementType, @@ -150,7 +151,7 @@ export interface VerifiedTransactionParameters { } export interface RecoveryOptions { - backupKey: string; + backupKey?: string; userKey: string; rootAddress: string; recoveryDestination: string; @@ -160,8 +161,13 @@ export interface RecoveryOptions { firstRound?: number; note?: string; nodeParams: NodeParams; - /** Optional pre-made signature for the backup child, whose signature must be over the transaction's `bytesToSign()`. */ - externalBackupSignature?: ExternalEd25519Signature; + /** + * Optional lazy signer for a seedless soft ed25519 backup child (Safe slot-2). Unlike a pre-made + * signature, the signature is produced here once the transaction exists: the signer receives + * `tx.bytesToSign()` and returns a 64-byte ed25519 signature. The private halves never leave the + * caller's closure. + */ + externalBackupSigner?: ExternalEd25519Signer; } interface RecoveryInfo { @@ -855,7 +861,8 @@ export class Algo extends BaseCoin { } public async recover(params: RecoveryOptions): Promise { - const isUnsignedSweep = this.isValidPub(params.userKey) && this.isValidPub(params.backupKey); + const backupKey = params.backupKey; + const isUnsignedSweep = this.isValidPub(params.userKey) && backupKey !== undefined && this.isValidPub(backupKey); if (!params.nodeParams) { throw new Error('Please provide the details of an ALGO node to use for recovery'); @@ -887,10 +894,13 @@ export class Algo extends BaseCoin { const factory = new AlgoLib.TransactionBuilderFactory(coins.get('algo')); const txBuilder = factory.getTransferBuilder(); - const externalBackupSignature = params.externalBackupSignature; - if (externalBackupSignature) { - assertValidExternalEd25519Signature(externalBackupSignature); + const externalBackupSigner = params.externalBackupSigner; + if (externalBackupSigner) { + assertValidExternalEd25519Signer(externalBackupSigner); } + // The backup child's pub: the one value needed to locate the backup slot, whether the backup + // signs externally (lazy signer) or from a decrypted seed. + const externalBackupPub = externalBackupSigner?.publicKey; let userPrv: string | undefined; let backupPrv: string | undefined; let userKeyAddress: string; @@ -902,10 +912,13 @@ export class Algo extends BaseCoin { try { userPrv = await this.bitgo.decrypt({ input: params.userKey, password: params.walletPassphrase }); userKeyAddress = Utils.privateKeyToAlgoAddress(userPrv); - if (externalBackupSignature) { - backupKeyAddress = algosdk.encodeAddress(Buffer.from(externalBackupSignature.publicKey, 'hex')); + if (externalBackupPub) { + backupKeyAddress = algosdk.encodeAddress(Buffer.from(externalBackupPub, 'hex')); } else { - backupPrv = await this.bitgo.decrypt({ input: params.backupKey, password: params.walletPassphrase }); + if (!backupKey) { + throw new Error('backupKey is required for non-bitgo recovery when no external backup signer is provided'); + } + backupPrv = await this.bitgo.decrypt({ input: backupKey, password: params.walletPassphrase }); backupKeyAddress = Utils.privateKeyToAlgoAddress(backupPrv); } } catch (e) { @@ -915,8 +928,11 @@ export class Algo extends BaseCoin { } txBuilder.numberOfRequiredSigners(2).setSigners([userKeyAddress, backupKeyAddress, params.bitgoKey]); } else { + if (!backupKey) { + throw new Error('backupKey is required for an unsigned sweep'); + } userKeyAddress = this.stellarAddressToAlgoAddress(params.userKey); - backupKeyAddress = this.stellarAddressToAlgoAddress(params.backupKey); + backupKeyAddress = this.stellarAddressToAlgoAddress(backupKey); } // Make sure the recovery keys are the on-chain signers of the wallet, before any node call. @@ -994,6 +1010,9 @@ export class Algo extends BaseCoin { // Cold wallet, offline vault if (isUnsignedSweep) { + if (!backupKey) { + throw new Error('backupKey is required for an unsigned sweep'); + } const tx = await txBuilder.build(); const txJson = tx.toJson() as TxData; @@ -1001,7 +1020,7 @@ export class Algo extends BaseCoin { txHex: Buffer.from(tx.toBroadcastFormat()).toString('hex'), type: txJson.type, userKey: params.userKey, - backupKey: params.backupKey, + backupKey, bitgoKey: params.bitgoKey, address: params.rootAddress, coin: this.getChain(), @@ -1012,22 +1031,35 @@ export class Algo extends BaseCoin { genesisId: genesisId, genesisHash: genesisHash, note: txJson.note ? Buffer.from(txJson.note.buffer).toString('utf-8') : undefined, - keys: [params.userKey, params.backupKey, params.bitgoKey], + keys: [params.userKey, backupKey, params.bitgoKey], addressVersion: 1, }; } // Non-bitgo Recovery (Hot wallets) txBuilder.sign({ key: userPrv }); - if (!externalBackupSignature) { + if (!externalBackupPub) { txBuilder.sign({ key: backupPrv }); } const tx = (await txBuilder.build()) as AlgoLib.Transaction; - if (externalBackupSignature) { - const { publicKey, signature } = externalBackupSignature; - tx.addRawMultisigSignature(algosdk.encodeAddress(Buffer.from(publicKey, 'hex')), Buffer.from(signature, 'hex')); + if (externalBackupSigner) { + // Slot-2 Safe recovery: the seedless backup child signs lazily, over the exact transaction the + // user key just signed. Signing anything earlier would run the risk of covering different bytes + // (e.g. a different round) than the transaction that actually gets built. + const algoTransaction = tx.getAlgoTransaction(); + if (!algoTransaction) { + throw new Error('failed to build the Algorand transaction for the external backup signature'); + } + // signWithExternalEd25519Signer validates the pub, the 64-byte shape, and that the signature + // verifies against the advertised pub — all shared, no per-coin reimplementation. + const message = algoTransaction.bytesToSign(); + const signature = await signWithExternalEd25519Signer(externalBackupSigner, message); + tx.addRawMultisigSignature( + algosdk.encodeAddress(Buffer.from(externalBackupSigner.publicKey, 'hex')), + Buffer.from(signature, 'hex') + ); } const txJson = tx.toJson() as TxData; diff --git a/modules/sdk-coin-algo/test/unit/algo.ts b/modules/sdk-coin-algo/test/unit/algo.ts index a76d70d10fd..0ce0ac066bb 100644 --- a/modules/sdk-coin-algo/test/unit/algo.ts +++ b/modules/sdk-coin-algo/test/unit/algo.ts @@ -19,6 +19,7 @@ import { } from '@bitgo/sdk-core'; import { algoBackupKey } from './fixtures/algoBackupKey'; import nock from 'nock'; +import * as nacl from 'tweetnacl'; describe('ALGO:', function () { let bitgo: TestBitGoAPI; @@ -832,13 +833,23 @@ describe('ALGO:', function () { txJson.fee.should.equal(fee); }); - it('should sign with an external backup signature without decrypting the backup key', async function () { + it('should sign with an external backup signer without decrypting the backup key', async function () { const getBalanceStub = sandBox.stub(Algo.prototype, 'getAccountBalance').resolves(nativeBalance); + // The backup seed is decrypted here in the test, not by recover(); the signer closure below + // holds it and signs the tx bytes lazily, exactly as the Safe slot-2 flow hands the seedless + // backup child to `buildSafeRecoverKeyParams`. + const backupPrv = await bitgo.decrypt({ input: backupKey, password: walletPassphrase }); + const backupKeyPair = new AlgoLib.KeyPair({ prv: backupPrv }); + const backupKeyPairPrv = backupKeyPair.getKeys().prv; + if (!backupKeyPairPrv) { + throw new Error('invalid fixture: backup key pair must have a private key'); + } + const backupSeedBytes = Buffer.from(backupKeyPairPrv, 'hex'); + const backupSecretKey = nacl.sign.keyPair.fromSeed(new Uint8Array(backupSeedBytes)).secretKey; const decryptSpy = sandBox.spy(bitgo, 'decrypt'); const recovery = await basecoin.recover({ userKey, - backupKey, rootAddress, walletPassphrase, fee, @@ -846,15 +857,97 @@ describe('ALGO:', function () { recoveryDestination: recoveryDestination, firstRound: 5002596, nodeParams, - externalBackupSignature: { + externalBackupSigner: { + // raw 32-byte form of `backupPub` (6FVGZUZOH…) — the same key the multisig address embeds publicKey: 'f16a6cd32e3e6579cc315db23b9b12b56b86b799249046648c40522a04e2e8bf', - signature: 'ab'.repeat(64), + sign: async (message: Uint8Array) => ({ + signature: Buffer.from(nacl.sign.detached(message, backupSecretKey)).toString('hex'), + }), }, }); recovery.should.have.property('tx'); getBalanceStub.callCount.should.equal(1); + // only the user key is decrypted by recover(); the backup signed through the closure + decryptSpy.callCount.should.equal(1); + }); + + it('calls the lazy signer with the tx bytes and rejects a non-verifying signature', async function () { + const getBalanceStub = sandBox.stub(Algo.prototype, 'getAccountBalance').resolves(nativeBalance); + const decryptSpy = sandBox.spy(bitgo, 'decrypt'); + + const signedMessages: Uint8Array[] = []; + await assert.rejects( + () => + basecoin.recover({ + userKey, + rootAddress, + walletPassphrase, + fee, + bitgoKey: bitgoPub, + recoveryDestination: recoveryDestination, + firstRound: 5002596, + nodeParams, + externalBackupSigner: { + // raw 32-byte form of `backupPub` (6FVGZUZOH…) — the same key the multisig address embeds + publicKey: 'f16a6cd32e3e6579cc315db23b9b12b56b86b799249046648c40522a04e2e8bf', + sign: async (message: Uint8Array) => { + signedMessages.push(message); + return { signature: 'ab'.repeat(64) }; + }, + }, + }), + /does not verify against its public key/ + ); + + getBalanceStub.callCount.should.equal(1); + // only the user key is decrypted; the seedless backup child is signed by the closure decryptSpy.callCount.should.equal(1); + signedMessages.should.have.length(1); + signedMessages[0].should.be.an.instanceOf(Uint8Array); + signedMessages[0].length.should.be.greaterThan(0); + }); + + it('rejects a lazy external backup signer with a malformed public key or signature', async function () { + sandBox.stub(Algo.prototype, 'getAccountBalance').resolves(nativeBalance); + + await assert.rejects( + () => + basecoin.recover({ + userKey, + rootAddress, + walletPassphrase, + fee, + bitgoKey: bitgoPub, + recoveryDestination: recoveryDestination, + firstRound: 5002596, + nodeParams, + externalBackupSigner: { + publicKey: 'not-hex', + sign: async () => ({ signature: 'ab'.repeat(64) }), + }, + }), + /expected 32 bytes/ + ); + + await assert.rejects( + () => + basecoin.recover({ + userKey, + rootAddress, + walletPassphrase, + fee, + bitgoKey: bitgoPub, + recoveryDestination: recoveryDestination, + firstRound: 5002596, + nodeParams, + externalBackupSigner: { + publicKey: 'f16a6cd32e3e6579cc315db23b9b12b56b86b799249046648c40522a04e2e8bf', + sign: async () => ({ signature: 'ab' }), + }, + }), + /expected 64 bytes/ + ); }); it('should throw for invalid rootAddress', async function () { diff --git a/modules/sdk-coin-algo/test/unit/algoSafeRecovery.ts b/modules/sdk-coin-algo/test/unit/algoSafeRecovery.ts new file mode 100644 index 00000000000..4e10f8a8732 --- /dev/null +++ b/modules/sdk-coin-algo/test/unit/algoSafeRecovery.ts @@ -0,0 +1,170 @@ +import should from 'should'; +import { Talgo } from '../../src'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import Sinon from 'sinon'; +import { Algo } from '../../src/algo'; +import { ed25519 } from '@noble/curves/ed25519'; +import { + buildSafeRecoverKeyParams, + deriveSafeWalletKeys, + encodeDerivableEd25519Pub, + encodeEd25519ChainCode, + type DecryptedSafeRoots, +} from '@bitgo/sdk-core'; +import algosdk from 'algosdk'; + +/** + * End-to-end Safe ALGO recovery: keycard decrypt → derive slot-2 children → map to recover() + * params → `Algo.recover()` builds and signs a 2-of-3 multisig sweep whose rootAddress equals + * `multisigAddress(1, 2, [user, backup, bitgo])`. + * + * This proves the whole WCN-2898 chain with real keys (no mocked derivation or encryption): + * the lazy backup signer signs the actual transaction bytes with the derived kL/kR, so the final + * transaction verifies under algosdk.verifyMultisig. + */ +describe('Safe ALGO recovery end-to-end', function () { + let bitgo: TestBitGoAPI; + let basecoin; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('talgo', Talgo.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('talgo'); + }); + + // 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 chainCode = Buffer.alloc(32, 0xab); + const bitgoSeed = Buffer.alloc(32, 0x22); + const bitgoRootPub = Buffer.from(ed25519.getPublicKey(bitgoSeed)); + const bitgoChainCode = Buffer.alloc(32, 0xcd); + return { + secp256k1Multisig: { user: 'u', backup: 'b', bitgo: 'g' }, + ed25519Multisig: { + user: seed, + backup: { prv: seed, chainCode: encodeEd25519ChainCode(chainCode) }, + bitgo: encodeDerivableEd25519Pub(bitgoRootPub, bitgoChainCode), + }, + 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', + }, + }; + } + + const nodeParams = { + token: '2810c2d168e8417c5f111d38d68327b8cfe2d0ddc02986490c22f8ddf4128bcd', + baseServer: 'http://localhost/', + port: 8443, + }; + const fee = 1000; + const nativeBalance = 10_000_000; // 10 ALGO + const recoveryDestination = 'GB3YETD5TSTBAIYGYHVWU3O3I7XGOB44HOZA5MOEF5M23CLLZKRQLEVAOA'; + + it('recovers a Safe ALGO wallet whose rootAddress equals multisigAddress(1,2,[user,backup,bitgo])', async function () { + const sandbox = Sinon.createSandbox(); + sandbox.stub(Algo.prototype, 'getAccountBalance').resolves(nativeBalance); + + const roots = ed25519Roots(); + const derived = await deriveSafeWalletKeys({ coin: 'algo', slot: 'ed25519Multisig', account: 0, roots }); + if (derived.slot !== 'ed25519Multisig') { + throw new Error('expected ed25519Multisig slot'); + } + + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'ed25519Multisig' || keyParams.mode !== 'signed') { + throw new Error('expected signed ed25519Multisig key params'); + } + + // Distinct fixture roots: a slot swap between backup and bitgo must change the signer and root. + derived.keys.user.pub.should.not.equal(derived.keys.backup.pub); + derived.keys.backup.pub.should.not.equal(derived.keys.bitgo.pub); + + // The mapper's rootAddress is the wallet's on-chain address, derived from the same children. + const expectedRoot = algosdk.multisigAddress({ + version: 1, + threshold: 2, + addrs: [ + algosdk.encodeAddress(Buffer.from(derived.keys.user.pub, 'hex')), + algosdk.encodeAddress(Buffer.from(derived.keys.backup.pub, 'hex')), + algosdk.encodeAddress(Buffer.from(derived.keys.bitgo.pub, 'hex')), + ], + }); + keyParams.rootAddress.should.equal(expectedRoot); + + const recovery = await basecoin.recover({ + userKey: keyParams.userKey, + rootAddress: keyParams.rootAddress, + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + fee, + recoveryDestination, + firstRound: 5002596, + nodeParams, + externalBackupSigner: keyParams.externalBackupSigner, + }); + + recovery.should.have.property('tx'); + const decoded = algosdk.decodeSignedTransaction(Buffer.from(recovery.tx as string, 'base64')); + should(decoded.msig).not.be.undefined(); + + const msigPk = algosdk.decodeAddress(expectedRoot).publicKey; + algosdk.verifyMultisig(decoded.txn.bytesToSign(), decoded.msig!, msigPk).should.equal(true); + + sandbox.restore(); + }); + + it('fails closed with RecoveryKeyMismatchError when the user key does not belong to the root', async function () { + const sandbox = Sinon.createSandbox(); + sandbox.stub(Algo.prototype, 'getAccountBalance').resolves(nativeBalance); + + const roots = ed25519Roots(); + const derived = await deriveSafeWalletKeys({ coin: 'algo', slot: 'ed25519Multisig', account: 0, roots }); + if (derived.slot !== 'ed25519Multisig') { + throw new Error('expected ed25519Multisig slot'); + } + const keyParams = await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'signed', + }); + if (keyParams.slot !== 'ed25519Multisig' || keyParams.mode !== 'signed') { + throw new Error('expected signed ed25519Multisig key params'); + } + + // A rootAddress the derived keys do not control: the fail-closed check fires before any node call. + await basecoin + .recover({ + userKey: keyParams.userKey, + rootAddress: algosdk.encodeAddress(Buffer.alloc(32, 0xee)), + bitgoKey: keyParams.bitgoKey, + walletPassphrase: keyParams.walletPassphrase, + fee, + recoveryDestination, + firstRound: 5002596, + nodeParams, + externalBackupSigner: keyParams.externalBackupSigner, + }) + .should.be.rejectedWith(/does not match the given rootAddress/); + + sandbox.restore(); + }); +}); diff --git a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts index 57188f1a716..9e91fb9265d 100644 --- a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts +++ b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts @@ -10,6 +10,7 @@ */ import { createHash } from 'crypto'; +import * as nacl from 'tweetnacl'; import { Ed25519BIP32, Eddsa, @@ -28,7 +29,7 @@ import { /** Ed25519 extended-key halves + public point, parameterised by encoding (Buffer raw, string hex). */ export type Ed25519ExtendedKeyParts = { - readonly kL: T; // clamped signing scalar + readonly kL: T; // signing scalar, reduced mod L readonly kR: T; // nonce prefix readonly pub: T; // 32-byte public key }; @@ -62,7 +63,7 @@ export async function softDeriveChildPubEd25519(compositePub: string, index: num /** * Soft-derive an ed25519 co-signer child at an unhardened path, returning the private - * extended-key material — the clamped scalar `kL`, the nonce prefix `kR`, and the matching + * extended-key material — the reduced-mod-L scalar `kL`, the nonce prefix `kR`, and the matching * public point — from a root secret seed plus the root chain code carried on the keycard. */ export async function softDeriveChildPrvEd25519AtPath(params: { @@ -98,10 +99,10 @@ export async function softDeriveChildPrvEd25519AtPath(params: { * * A soft-derived child has no 32-byte seed whose SHA-512 yields `(kL, kR)`, so the * seed-based `nacl.sign.keyPair.fromSeed` cannot sign for it. This performs RFC 8032 - * scalar signing straight from the clamped scalar `kL` and the nonce prefix `kR`. + * scalar signing straight from the reduced-mod-L scalar `kL` and the nonce prefix `kR`. * * @param message bytes to sign - * @param kL 32-byte clamped signing scalar (little-endian) + * @param kL 32-byte signing scalar, reduced mod L (little-endian) * @param kR 32-byte nonce prefix (big-endian) * @returns 64-byte Ed25519 signature `R ‖ S` */ @@ -150,3 +151,50 @@ export function assertValidExternalEd25519Signature(sig: ExternalEd25519Signatur throw new Error('Invalid external Ed25519 signature: expected 64 bytes (128 hex characters)'); } } + +/** + * A lazily-invoked external Ed25519 signer. Carries the public key up front and produces the + * signature on demand, once the message it must cover exists — unlike `ExternalEd25519Signature`, + * which is a pre-made signature. Used for seedless soft-derived backup children whose signature + * must cover transaction bytes that only exist at `recover()` time. + */ +export type ExternalEd25519Signer = { + readonly publicKey: string; // raw 32-byte public key (hex) + readonly sign: (message: Uint8Array) => Promise<{ signature: string }>; // raw 64-byte signature (hex) +}; + +/** Fails fast if an `ExternalEd25519Signer` is missing or mis-shaped. */ +export function assertValidExternalEd25519Signer(signer: ExternalEd25519Signer): void { + if (!/^[0-9a-f]{64}$/i.test(signer.publicKey)) { + throw new Error('Invalid external Ed25519 public key: expected 32 bytes (64 hex characters)'); + } + if (typeof signer.sign !== 'function') { + throw new Error('Invalid external Ed25519 signer: sign must be a function'); + } +} + +/** + * Calls an `ExternalEd25519Signer` over `message`, fails fast unless the signature is a valid + * 64-byte (128-hex) signature that verifies against the signer's advertised public key, and + * returns the hex signature. The cryptographic check lives here — once, shared by every coin — + * so no caller can inject a well-formed-but-wrong signature into a transaction. + */ +export async function signWithExternalEd25519Signer( + signer: ExternalEd25519Signer, + message: Uint8Array +): Promise { + assertValidExternalEd25519Signer(signer); + const result = await signer.sign(message); + if (!result || typeof result.signature !== 'string') { + throw new Error('Invalid external Ed25519 signer: sign must resolve to { signature: string }'); + } + if (!/^[0-9a-f]{128}$/i.test(result.signature)) { + throw new Error('Invalid external Ed25519 signature: expected 64 bytes (128 hex characters)'); + } + const signature = Buffer.from(result.signature, 'hex'); + const publicKey = Buffer.from(signer.publicKey, 'hex'); + if (!nacl.sign.detached.verify(message, signature, publicKey)) { + throw new Error('Invalid external Ed25519 signer: signature does not verify against its public key'); + } + return result.signature; +} diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index 64526ad370d..e789f5bc334 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -18,12 +18,16 @@ import { parseSafeBip44Path, parseSafeKeycardBox, toSoftTwinPath, + algoMultisigAddress, + encodeAlgoKey, type SafeKeycardRootEntry, } from '@bitgo/sdk-lib-safes'; import { + signEd25519Extended, softDeriveChildPubEd25519AtPath, softDeriveChildPrvEd25519AtPath, type Ed25519ExtendedKeyParts, + type ExternalEd25519Signer, } from './derivableEd25519Pub'; import { DklsVrfUtils, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; import { BitGoBase } from '../bitgoBase'; @@ -83,13 +87,51 @@ export type DerivedSafeWalletKeys = | { slot: 'eddsaMpc'; path: string; keys: EddsaMpcChildKeys } | { slot: 'ed25519Multisig'; path: string; keys: Ed25519MultisigChildKeys }; -export type SafeRecoverKeyParams = { +export type SecpSafeRecoverKeyParams = { + slot: 'secp256k1Multisig'; userKey: string; backupKey: string; bitgoKey: string; walletPassphrase?: string; }; +/** + * 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`, + * unsigned mode always carries the encoded `backupKey` and never a signer or passphrase. + * + * `rootAddress` is the wallet's on-chain address. For ALGO it's the 2-of-3 multisig address over the + * 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. + */ +export type Ed25519SafeRecoverKeyParams = + | { + slot: 'ed25519Multisig'; + mode: 'signed'; + userKey: string; + bitgoKey: string; + walletPassphrase: string; + rootAddress: string; + /** + * Lazy signer for the seedless backup child. The backup child has no seed, so it cannot be + * seed-signed; and its signature must cover `tx.bytesToSign()`, which only exists once + * `recover()` builds the tx. So instead of a pre-made signature we pass a closure that holds + * the private `kL`/`kR` and signs on demand — the private halves never leave it. + */ + externalBackupSigner: ExternalEd25519Signer; + } + | { + slot: 'ed25519Multisig'; + mode: 'unsigned'; + userKey: string; + backupKey: string; + bitgoKey: string; + rootAddress: string; + }; + +export type SafeRecoverKeyParams = SecpSafeRecoverKeyParams | Ed25519SafeRecoverKeyParams; + export type DecryptedSafeRoots = { secp256k1Multisig: OnchainSafeRoot; ed25519Multisig: Ed25519MultisigSafeRoot; @@ -251,46 +293,157 @@ export async function deriveSafeWalletKeys(params: { } /** - * Builds the key fields `coin.recover()` expects from derived Safe child keys. - * Only `secp256k1Multisig` (slot 1) is supported for now. Signed mode re-encrypts the - * child xprvs under a fresh ephemeral passphrase (v1/SJCL); unsigned mode returns pubs. + * 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/hbar are future arms). + */ +const ED25519_MULTISIG_COIN_ENCODERS: Record< + string, + { encodePub: (bytes: Buffer) => string; encodeSeed: (bytes: Buffer) => string } | undefined +> = { + algo: { encodePub: encodeAlgoKey, encodeSeed: encodeAlgoKey }, + talgo: { encodePub: encodeAlgoKey, encodeSeed: encodeAlgoKey }, +}; + +/** + * 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 + * slot added without a branch is a compile error rather than a silent cast. */ -export async function buildSafeRecoverKeyParams(params: { +export type SafeRecoverKeyParamsRequest = { bitgo: BitGoBase; coin: string; - slot: RootKeyType; - keys: Secp256k1MultisigChildKeys; mode: 'signed' | 'unsigned'; -}): Promise { - if (params.slot !== 'secp256k1Multisig') { - throw new Error(`buildSafeRecoverKeyParams: slot '${params.slot}' is not supported yet`); +} & ( + | { slot: 'secp256k1Multisig'; keys: Secp256k1MultisigChildKeys } + | { slot: 'ed25519Multisig'; keys: Ed25519MultisigChildKeys } + | { slot: 'ecdsaMpc'; keys: EcdsaMpcChildKeys } + | { slot: 'eddsaMpc'; keys: EddsaMpcChildKeys } +); + +/** + * Builds the key fields `coin.recover()` expects from derived Safe child keys. + * Supports `secp256k1Multisig` (slot 1) and `ed25519Multisig` (slot 2); the MPC slots fail closed. + * Signed mode re-encrypts the child secrets under a fresh ephemeral passphrase (v1/SJCL); unsigned + * mode returns pubs. `rootAddress` is derived from the child keys, not independently verified. + */ +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; + + if (params.slot === 'ed25519Multisig') { + return buildEd25519MultisigRecoverKeyParams(bitgo, params.coin, params.keys, mode); } - const { bitgo, keys, mode } = params; - if (mode === 'unsigned') { + 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, + }; + } + + 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 { - userKey: keys.user.pub, - backupKey: keys.backup.pub, + slot: 'secp256k1Multisig', + userKey, + backupKey, bitgoKey: keys.bitgo.pub, + walletPassphrase, }; } - 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'); + throw new Error(`buildSafeRecoverKeyParams: slot '${params.slot}' is not supported yet`); +} + +async function buildEd25519MultisigRecoverKeyParams( + bitgo: BitGoBase, + coin: string, + keys: Ed25519MultisigChildKeys, + mode: 'signed' | 'unsigned' +): Promise { + const encoders = ED25519_MULTISIG_COIN_ENCODERS[coin]; + if (!encoders) { + throw new Error(`buildSafeRecoverKeyParams: coin '${coin}' ed25519Multisig is not supported yet`); + } + + 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)) { + throw new Error('buildSafeRecoverKeyParams: ed25519 child public keys must be 32 bytes'); + } + } + + const userPub = Buffer.from(keys.user.pub, 'hex'); + const backupPub = Buffer.from(keys.backup.pub, 'hex'); + const bitgoPub = Buffer.from(keys.bitgo.pub, 'hex'); + + // Algorand wallet addresses are the version-1 2-of-3 multisig address over the three child pubs + // in wallet-creation order. `Algo.recover()` recomputes and enforces the same value and throws + // `RecoveryKeyMismatchError` on any mismatch (sdk-coin-algo/src/algo.ts). + const rootAddress = algoMultisigAddress(1, 2, [userPub, backupPub, bitgoPub]); + + if (mode === 'unsigned') { + return { + slot: 'ed25519Multisig', + mode: 'unsigned', + userKey: encoders.encodePub(userPub), + backupKey: encoders.encodePub(backupPub), + bitgoKey: encoders.encodePub(bitgoPub), + rootAddress, + }; } + // 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). 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 userKey = await bitgo.encrypt({ + input: encoders.encodeSeed(Buffer.from(keys.user.seed, 'hex')), + password: walletPassphrase, + encryptionVersion: 1, + }); return { + slot: 'ed25519Multisig', + mode: 'signed', userKey, - backupKey, - bitgoKey: keys.bitgo.pub, + bitgoKey: encoders.encodePub(bitgoPub), walletPassphrase, + rootAddress, + externalBackupSigner: { + publicKey: keys.backup.pub, + sign: async (message) => ({ + signature: ( + await signEd25519Extended( + Buffer.from(message), + Buffer.from(keys.backup.kL, 'hex'), + Buffer.from(keys.backup.kR, 'hex') + ) + ).toString('hex'), + }), + }, }; } diff --git a/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts index 3ff6f98dbb8..40c060b9593 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts @@ -16,6 +16,8 @@ import { encodeEd25519ChainCode, encodeEd25519StrKeyPublicKey, generateEd25519ChainCode, + assertValidExternalEd25519Signer, + signWithExternalEd25519Signer, isChecksumValidStrKeyEd25519Pub, isDerivableEd25519Pub, isValidEd25519StrKeyPublicKey, @@ -23,6 +25,7 @@ import { softDeriveChildPubEd25519, softDeriveChildPubEd25519AtPath, softDeriveChildPrvEd25519AtPath, + type ExternalEd25519Signer, } from '../../../../src'; // Cross-repo fixture: byte-identical to wallet-platform @@ -207,4 +210,75 @@ describe('derivableEd25519Pub', function () { await signEd25519Extended(message, Buffer.alloc(32), Buffer.alloc(33)).should.be.rejectedWith(/kR length/); }); }); + + describe('ExternalEd25519Signer', function () { + const message = Buffer.from('external-signer message'); + const keyPair = nacl.sign.keyPair.fromSeed(Buffer.alloc(32, 0x42)); + const publicKey = Buffer.from(keyPair.publicKey).toString('hex'); + const otherKeyPair = nacl.sign.keyPair.fromSeed(Buffer.alloc(32, 0x77)); + const validSigner: ExternalEd25519Signer = { + publicKey, + sign: async (m: Uint8Array) => ({ + signature: Buffer.from(nacl.sign.detached(m, keyPair.secretKey)).toString('hex'), + }), + }; + + it('rejects a malformed public key', function () { + (() => + assertValidExternalEd25519Signer({ + publicKey: 'not-hex', + sign: async () => ({ signature: 'a'.repeat(128) }), + })).should.throw(/expected 32 bytes/); + }); + + it('rejects a public key with trailing garbage after 64 hex chars', function () { + // Buffer.from(pub, 'hex') truncates at the first bad pair, so a length-only check would + // accept this; the strict regex must not. + (() => + assertValidExternalEd25519Signer({ + publicKey: 'f1'.repeat(32) + 'zz', + sign: async () => ({ signature: 'a'.repeat(128) }), + })).should.throw(/expected 32 bytes/); + }); + + it('rejects a signer without a sign function', function () { + (() => + assertValidExternalEd25519Signer({ + publicKey, + sign: undefined as unknown as ExternalEd25519Signer['sign'], + })).should.throw(/sign must be a function/); + }); + + it('rejects a non-64-byte signature', async function () { + await signWithExternalEd25519Signer( + { publicKey, sign: async () => ({ signature: 'ab' }) }, + message + ).should.be.rejectedWith(/expected 64 bytes/); + }); + + it('rejects when sign resolves to a non-object', async function () { + await signWithExternalEd25519Signer( + { publicKey, sign: async () => null as unknown as { signature: string } }, + message + ).should.be.rejectedWith(/sign must resolve to \{ signature: string \}/); + }); + + it('rejects a well-formed signature from the wrong key', async function () { + await signWithExternalEd25519Signer( + { + publicKey, + sign: async (m: Uint8Array) => ({ + signature: Buffer.from(nacl.sign.detached(m, otherKeyPair.secretKey)).toString('hex'), + }), + }, + message + ).should.be.rejectedWith(/does not verify against its public key/); + }); + + it('signs and returns a verified 64-byte signature', async function () { + const signature = await signWithExternalEd25519Signer(validSigner, message); + signature.should.match(/^[0-9a-f]{128}$/); + nacl.sign.detached.verify(message, Buffer.from(signature, 'hex'), keyPair.publicKey).should.equal(true); + }); + }); }); diff --git a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts index 79841df67c2..e59f7dfdb0c 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts @@ -14,7 +14,8 @@ import { deriveSafeWalletKeys, } from '../../../../src/bitgo/safe/recovery'; import { - decodeEd25519StrKeySecretSeed, + algoMultisigAddress, + encodeAlgoKey, encodeDerivableEd25519Pub, encodeEd25519ChainCode, softDeriveChildPubEd25519AtPath, @@ -366,15 +367,18 @@ describe('deriveSafeWalletKeys', () => { it('derives the ed25519Multisig triplet at the BIP44 path', async () => { const STRKEY_SEED = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; - const backupSeed = decodeEd25519StrKeySecretSeed(STRKEY_SEED); - const backupRootPub = Buffer.from(ed25519.getPublicKey(backupSeed)); const backupChainCode = Buffer.alloc(32, 0xab); + // Distinct BitGo root + chain code: if the BitGo composite were derived from the backup root, the + // backup child and the BitGo child would be the same key and a slot swap would pass every golden. + const bitgoSeed = Buffer.alloc(32, 0x22); + const bitgoRootPub = Buffer.from(ed25519.getPublicKey(bitgoSeed)); + const bitgoChainCode = Buffer.alloc(32, 0xcd); const ed25519Roots: DecryptedSafeRoots = { ...roots, ed25519Multisig: { user: STRKEY_SEED, backup: { prv: STRKEY_SEED, chainCode: encodeEd25519ChainCode(backupChainCode) }, - bitgo: encodeDerivableEd25519Pub(backupRootPub, backupChainCode), + bitgo: encodeDerivableEd25519Pub(bitgoRootPub, bitgoChainCode), }, }; @@ -409,7 +413,9 @@ describe('deriveSafeWalletKeys', () => { } result.keys.backup.kL.should.equal(algoSoft.childKLHex); result.keys.backup.kR.should.equal(algoSoft.childKRHex); - result.keys.backup.pub.should.equal(bitgoPub); + // With distinct roots the three children are provably distinct, so a slot swap cannot sneak through. + result.keys.user.pub.should.not.equal(result.keys.backup.pub); + result.keys.backup.pub.should.not.equal(result.keys.bitgo.pub); result.keys.bitgo.pub.should.equal(bitgoPub); }); @@ -504,10 +510,151 @@ describe('buildSafeRecoverKeyParams', () => { bitgo, coin: 'btc', slot: 'eddsaMpc', - keys, + keys: { commonKeychain: 'ck', user: 'u', backup: 'b' }, mode: 'signed', }).should.be.rejectedWith(/not supported yet/); }); + + describe('ed25519Multisig (algo)', () => { + // Golden vectors generated with algosdk: encodeAddress(Buffer.alloc(32, n)) and + // multisigAddress({ version: 1, threshold: 2, addrs: [...] }) over the same pubs. + const edKeys = { + user: { kind: 'seed', seed: '11'.repeat(32), pub: '21'.repeat(32) }, + backup: { kind: 'extended', kL: '31'.repeat(32), kR: '41'.repeat(32), pub: '51'.repeat(32) }, + bitgo: { pub: '61'.repeat(32) }, + } as const; + + it('unsigned mode returns ALGO addresses and the multisig rootAddress, and never encrypts', async () => { + 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: 'algo', + slot: 'ed25519Multisig', + keys: edKeys, + mode: 'unsigned', + }); + if (result.slot !== 'ed25519Multisig' || result.mode !== 'unsigned') { + throw new Error('expected unsigned ed25519Multisig key params'); + } + + result.userKey.should.equal('EEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQYNI53HM'); + result.backupKey.should.equal('KFIVCUKRKFIVCUKRKFIVCUKRKFIVCUKRKFIVCUKRKFIVCUKRKFI33B3AOY'); + result.bitgoKey.should.equal('MFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQS3KHEHM'); + result.rootAddress.should.equal('LHWEIOU2UIPSKCZ3XMKQUKRYY3FM6XTZKBHDMIU737OQQEXQTYSGDFOHDM'); + result.userKey.should.match(/^[A-Z2-7]{58}$/); + result.should.not.have.property('walletPassphrase'); + result.should.not.have.property('externalBackupSigner'); + encrypt.callCount.should.equal(0); + }); + + it('signed mode encrypts the 58-char seed and returns a lazy external backup signer', async () => { + // Real derivation output, with a distinct BitGo root so the backup child and the BitGo child + // are different keys — a slot swap between them must change the outputs. + const STRKEY_SEED = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + const backupChainCode = Buffer.alloc(32, 0xab); + const bitgoSeed = Buffer.alloc(32, 0x22); + const bitgoRootPub = Buffer.from(ed25519.getPublicKey(bitgoSeed)); + const bitgoChainCode = Buffer.alloc(32, 0xcd); + const ed25519Roots: DecryptedSafeRoots = { + ...makeRoots({ user: 'x', backup: 'y', bitgo: 'z' }), + ed25519Multisig: { + user: STRKEY_SEED, + backup: { prv: STRKEY_SEED, chainCode: encodeEd25519ChainCode(backupChainCode) }, + bitgo: encodeDerivableEd25519Pub(bitgoRootPub, bitgoChainCode), + }, + }; + const derived = await deriveSafeWalletKeys({ + coin: 'algo', + slot: 'ed25519Multisig', + account: 0, + roots: ed25519Roots, + }); + if (derived.slot !== 'ed25519Multisig') { + throw new Error('expected ed25519Multisig slot'); + } + + 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: 'talgo', + slot: 'ed25519Multisig', + keys: derived.keys, + mode: 'signed', + }); + if (result.slot !== 'ed25519Multisig' || result.mode !== 'signed') { + throw new Error('expected signed ed25519Multisig key params'); + } + + derived.keys.user.pub.should.not.equal(derived.keys.backup.pub); + derived.keys.backup.pub.should.not.equal(derived.keys.bitgo.pub); + + // user seed re-encoded as the 58-char ALGO seed before encryption; no backupKey — the + // seedless backup child only exists as the lazy signer. + result.userKey.should.equal(`enc(${encodeAlgoKey(Buffer.from(derived.keys.user.seed, 'hex'))})`); + result.should.not.have.property('backupKey'); + result.bitgoKey.should.equal(encodeAlgoKey(Buffer.from(derived.keys.bitgo.pub, 'hex'))); + result.bitgoKey.should.match(/^[A-Z2-7]{58}$/); + // The wallet's on-chain address over the three distinct children, in wallet-creation order. + result.rootAddress.should.equal( + algoMultisigAddress(1, 2, [ + Buffer.from(derived.keys.user.pub, 'hex'), + Buffer.from(derived.keys.backup.pub, 'hex'), + Buffer.from(derived.keys.bitgo.pub, 'hex'), + ]) + ); + result.rootAddress.should.match(/^[A-Z2-7]{58}$/); + result.walletPassphrase.should.be.a.String(); + passphrases.should.have.length(1); + passphrases[0].should.equal(result.walletPassphrase); + encrypt.callCount.should.equal(1); + + // the lazy signer signs with the real extended key halves and its pub matches the backup child + result.externalBackupSigner.publicKey.should.equal(derived.keys.backup.pub); + const message = Buffer.from('deadbeef', 'hex'); + const { signature } = await result.externalBackupSigner.sign(message); + signature.should.match(/^[0-9a-f]{128}$/); + ed25519 + .verify( + new Uint8Array(Buffer.from(signature, 'hex')), + new Uint8Array(message), + new Uint8Array(Buffer.from(result.externalBackupSigner.publicKey, 'hex')) + ) + .should.equal(true); + }); + + it('rejects slot-2 coins that have no registry entry yet', async () => { + const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'xlm', + slot: 'ed25519Multisig', + keys: edKeys, + mode: 'signed', + }).should.be.rejectedWith(/coin 'xlm' ed25519Multisig is not supported yet/); + }); + + it('rejects ed25519 children with malformed public keys', async () => { + const bitgo = { encrypt: sinon.stub() } as unknown as BitGoBase; + await buildSafeRecoverKeyParams({ + bitgo, + coin: 'algo', + slot: 'ed25519Multisig', + keys: { ...edKeys, bitgo: { pub: '1234' } }, + mode: 'unsigned', + }).should.be.rejectedWith(/ed25519 child public keys must be 32 bytes/); + }); + }); }); describe('Safe ecdsaMpc derive → sign (gate)', function () { diff --git a/modules/sdk-lib-safes/src/ed25519Pub.ts b/modules/sdk-lib-safes/src/ed25519Pub.ts index dfe642cebaa..2b6d26eacab 100644 --- a/modules/sdk-lib-safes/src/ed25519Pub.ts +++ b/modules/sdk-lib-safes/src/ed25519Pub.ts @@ -200,6 +200,32 @@ export function encodeAlgoKey(bytes: Buffer): string { return base32Encode(Buffer.concat([bytes, checksum])); } +/** The "MultisigAddr" preimage tag algosdk hashes for multisig addresses. */ +const ALGORAND_MULTISIG_ADDRESS_PREFIX = Buffer.from('MultisigAddr', 'utf8'); + +/** + * Compute an Algorand multisig address from raw 32-byte ed25519 public keys, without algosdk. + * + * Matches `algosdk.multisigAddress`: `encodeAddress(SHA-512/256("MultisigAddr" ‖ version ‖ threshold ‖ pks))`. + * The pub-key order is the wallet's signer order and is part of the address — swapping two keys + * changes the address, so callers must pass [user, backup, bitgo] in wallet-creation order. + */ +export function algoMultisigAddress(version: number, threshold: number, pubKeys: Buffer[]): string { + if (version !== 1) { + throw new Error(`Invalid multisig version: expected 1, got ${version}`); + } + if (!Number.isInteger(threshold) || threshold < 1 || threshold > pubKeys.length || threshold > 255) { + throw new Error(`Invalid multisig threshold: expected 1..${Math.min(pubKeys.length, 255)}, got ${threshold}`); + } + for (const pubKey of pubKeys) { + if (pubKey.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { + throw new Error('Invalid multisig public key: expected 32 bytes'); + } + } + const preimage = Buffer.concat([ALGORAND_MULTISIG_ADDRESS_PREFIX, Buffer.from([version, threshold]), ...pubKeys]); + return encodeAlgoKey(Buffer.from(sha512.sha512_256.array(preimage))); +} + /** Encode a raw 32-byte public key as a DER-prefixed Hedera public key (88 hex chars). */ export function encodeHbarPublicKeyDer(pub: Buffer): string { if (pub.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { diff --git a/modules/sdk-lib-safes/test/unit/ed25519Pub.ts b/modules/sdk-lib-safes/test/unit/ed25519Pub.ts index 3ac4bd765dd..c8b289118b8 100644 --- a/modules/sdk-lib-safes/test/unit/ed25519Pub.ts +++ b/modules/sdk-lib-safes/test/unit/ed25519Pub.ts @@ -2,6 +2,7 @@ import 'should'; import { DERIVABLE_ED25519_CHAIN_CODE_BYTES, DERIVABLE_ED25519_PUB_LENGTH, + algoMultisigAddress, decodeDerivableEd25519Pub, decodeEd25519ChainCode, decodeEd25519StrKeyPublicKey, @@ -106,6 +107,30 @@ describe('ed25519Pub', function () { (() => encodeAlgoKey(Buffer.alloc(31))).should.throw(/32 bytes/); }); + it('computes Algorand multisig addresses matching algosdk golden vectors', function () { + // Generated with algosdk.multisigAddress({ version: 1, threshold: 2, addrs: [encodeAddress(pk)...] }) + // so the leaf's pure preimage hash reproduces algosdk byte-for-byte. + const p1 = Buffer.alloc(32, 0x01); + const p2 = Buffer.alloc(32, 0x02); + const p3 = Buffer.alloc(32, 0x03); + algoMultisigAddress(1, 2, [p1, p2, p3]).should.equal( + 'KA6JJOIDCRLCG7TGB76BKSABV5FF43J6BOJ6ONIBLZ45KMP44NYT7AGRDI' + ); + algoMultisigAddress(1, 2, [p1, p2]).should.equal('AUFVSAUUDXNCKKFQCIFOEMGUEKRXXKT7WMOMGWLK6KUTXNNWBXJ7CKHRUE'); + algoMultisigAddress(1, 2, [p1, p2, p3]).should.match(/^[A-Z2-7]{58}$/); + }); + + it('multisig addresses are order-sensitive and fail closed on bad params', function () { + const p1 = Buffer.alloc(32, 0x01); + const p2 = Buffer.alloc(32, 0x02); + const p3 = Buffer.alloc(32, 0x03); + algoMultisigAddress(1, 2, [p1, p3, p2]).should.not.equal(algoMultisigAddress(1, 2, [p1, p2, p3])); + (() => algoMultisigAddress(2, 2, [p1, p2, p3])).should.throw(/multisig version/); + (() => algoMultisigAddress(1, 0, [p1, p2, p3])).should.throw(/multisig threshold/); + (() => algoMultisigAddress(1, 4, [p1, p2, p3])).should.throw(/multisig threshold/); + (() => algoMultisigAddress(1, 2, [Buffer.alloc(31), p2, p3])).should.throw(/32 bytes/); + }); + it('encodes Hedera public/private keys as DER-prefixed hex', function () { encodeHbarPublicKeyDer(PUB).should.equal('302a300506032b6570032100' + PUB.toString('hex')); encodeHbarPublicKeyDer(PUB).should.match(/^[0-9a-f]{88}$/);