Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/sdk-coin-algo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
70 changes: 51 additions & 19 deletions modules/sdk-coin-algo/src/algo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
BaseCoin,
BitGoBase,
ErrorNoInputToRecover,
assertValidExternalEd25519Signature,
ExternalEd25519Signature,
assertValidExternalEd25519Signer,
ExternalEd25519Signer,
InvalidAddressError,
InvalidKey,
KeyIndices,
Expand All @@ -22,6 +22,7 @@ import {
ParseTransactionOptions,
RecoveryKeyMismatchError,
RecoveryProviderError,
signWithExternalEd25519Signer,
SignedTransaction,
SignTransactionOptions as BaseSignTransactionOptions,
TokenManagementType,
Expand Down Expand Up @@ -150,7 +151,7 @@ export interface VerifiedTransactionParameters {
}

export interface RecoveryOptions {
backupKey: string;
backupKey?: string;
userKey: string;
rootAddress: string;
recoveryDestination: string;
Expand All @@ -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 {
Expand Down Expand Up @@ -855,7 +861,8 @@ export class Algo extends BaseCoin {
}

public async recover(params: RecoveryOptions): Promise<RecoveryInfo | OfflineVaultTxInfo> {
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');
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -994,14 +1010,17 @@ 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;

return {
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(),
Expand All @@ -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;

Expand Down
101 changes: 97 additions & 4 deletions modules/sdk-coin-algo/test/unit/algo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -832,29 +833,121 @@ 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,
bitgoKey: bitgoPub,
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 () {
Expand Down
Loading
Loading