Skip to content

Commit f51c859

Browse files
Merge pull request #5250 from BitGo/BTC-1450.refactor-verifyTx
refactor(abstract-utxo): factor verifyTransaction
2 parents 2703464 + 6a4aa19 commit f51c859

File tree

3 files changed

+325
-278
lines changed

3 files changed

+325
-278
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 7 additions & 278 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import { randomBytes } from 'crypto';
33
import _ from 'lodash';
44
import * as utxolib from '@bitgo/utxo-lib';
55
import { bip32, BIP32Interface, bitgo, getMainnet, isMainnet, isTestnet } from '@bitgo/utxo-lib';
6-
import * as bitcoinMessage from 'bitcoinjs-message';
76
import debugLib from 'debug';
8-
import BigNumber from 'bignumber.js';
97

108
import {
119
backupKeyRecovery,
@@ -27,7 +25,6 @@ import {
2725
BaseCoin,
2826
BitGoBase,
2927
CreateAddressFormat,
30-
decryptKeychainPrivateKey,
3128
ExtraPrebuildParamsOptions,
3229
HalfSignedUtxoTransaction,
3330
IBaseCoin,
@@ -70,8 +67,6 @@ import {
7067
assertValidTransactionRecipient,
7168
explainTx,
7269
fromExtendedAddressFormat,
73-
getPsbtTxInputs,
74-
getTxInputs,
7570
isScriptRecipient,
7671
} from './transaction';
7772
import { assertDescriptorWalletAddress } from './descriptor';
@@ -83,6 +78,8 @@ import { NamedKeychains } from './keychains';
8378
const debug = debugLib('bitgo:v2:utxo');
8479

8580
import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
81+
import { verifyTransaction } from './transaction/fixedScript/verifyTransaction';
82+
import { verifyKeySignature, verifyUserPublicKey } from './verifyKey';
8683

8784
type UtxoCustomSigningFunction<TNumber extends number | bigint> = {
8885
(params: {
@@ -612,150 +609,17 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
612609
}
613610

614611
/**
615-
* Decrypt the wallet's user private key and verify that the claimed public key matches
616-
* @param {VerifyUserPublicKeyOptions} params
617-
* @return {boolean}
618-
* @protected
612+
* @deprecated - use function verifyUserPublicKey instead
619613
*/
620614
protected verifyUserPublicKey(params: VerifyUserPublicKeyOptions): boolean {
621-
const { userKeychain, txParams, disableNetworking } = params;
622-
if (!userKeychain) {
623-
throw new Error('user keychain is required');
624-
}
625-
626-
const userPub = userKeychain.pub;
627-
628-
// decrypt the user private key, so we can verify that the claimed public key is a match
629-
let userPrv = userKeychain.prv;
630-
if (!userPrv && txParams.walletPassphrase) {
631-
userPrv = decryptKeychainPrivateKey(this.bitgo, userKeychain, txParams.walletPassphrase);
632-
}
633-
634-
if (!userPrv) {
635-
const errorMessage = 'user private key unavailable for verification';
636-
if (disableNetworking) {
637-
console.log(errorMessage);
638-
return false;
639-
} else {
640-
throw new Error(errorMessage);
641-
}
642-
} else {
643-
const userPrivateKey = bip32.fromBase58(userPrv);
644-
if (userPrivateKey.toBase58() === userPrivateKey.neutered().toBase58()) {
645-
throw new Error('user private key is only public');
646-
}
647-
if (userPrivateKey.neutered().toBase58() !== userPub) {
648-
throw new Error('user private key does not match public key');
649-
}
650-
}
651-
652-
return true;
615+
return verifyUserPublicKey(this.bitgo, params);
653616
}
654617

655618
/**
656-
* Verify signatures produced by the user key over the backup and bitgo keys.
657-
*
658-
* If set, these signatures ensure that the wallet keys cannot be changed after the wallet has been created.
659-
* @param {VerifyKeySignaturesOptions} params
660-
* @return {{backup: boolean, bitgo: boolean}}
619+
* @deprecated - use function verifyKeySignature instead
661620
*/
662621
public verifyKeySignature(params: VerifyKeySignaturesOptions): boolean {
663-
// first, let's verify the integrity of the user key, whose public key is used for subsequent verifications
664-
const { userKeychain, keychainToVerify, keySignature } = params;
665-
if (!userKeychain) {
666-
throw new Error('user keychain is required');
667-
}
668-
669-
if (!keychainToVerify) {
670-
throw new Error('keychain to verify is required');
671-
}
672-
673-
if (!keySignature) {
674-
throw new Error('key signature is required');
675-
}
676-
677-
// verify the signature against the user public key
678-
assert(userKeychain.pub);
679-
const publicKey = bip32.fromBase58(userKeychain.pub).publicKey;
680-
// Due to interface of `bitcoinMessage`, we need to convert the public key to an address.
681-
// Note that this address has no relationship to on-chain transactions. We are
682-
// only interested in the address as a representation of the public key.
683-
const signingAddress = utxolib.address.toBase58Check(
684-
utxolib.crypto.hash160(publicKey),
685-
utxolib.networks.bitcoin.pubKeyHash,
686-
// we do not pass `this.network` here because it would fail for zcash
687-
// the bitcoinMessage library decodes the address and throws away the first byte
688-
// because zcash has a two-byte prefix, verify() decodes zcash addresses to an invalid pubkey hash
689-
utxolib.networks.bitcoin
690-
);
691-
692-
// BG-5703: use BTC mainnet prefix for all key signature operations
693-
// (this means do not pass a prefix parameter, and let it use the default prefix instead)
694-
assert(keychainToVerify.pub);
695-
try {
696-
return bitcoinMessage.verify(keychainToVerify.pub, signingAddress, Buffer.from(keySignature, 'hex'));
697-
} catch (e) {
698-
debug('error thrown from bitcoinmessage while verifying key signature', e);
699-
return false;
700-
}
701-
}
702-
703-
/**
704-
* Verify signatures against the user private key over the change wallet extended keys
705-
* @param {ParsedTransaction} tx
706-
* @param {Keychain} userKeychain
707-
* @return {boolean}
708-
* @protected
709-
*/
710-
protected verifyCustomChangeKeySignatures<TNumber extends number | bigint>(
711-
tx: ParsedTransaction<TNumber>,
712-
userKeychain: Keychain
713-
): boolean {
714-
if (!tx.customChange) {
715-
throw new Error('parsed transaction is missing required custom change verification data');
716-
}
717-
718-
if (!Array.isArray(tx.customChange.keys) || !Array.isArray(tx.customChange.signatures)) {
719-
throw new Error('customChange property is missing keys or signatures');
720-
}
721-
722-
for (const keyIndex of [KeyIndices.USER, KeyIndices.BACKUP, KeyIndices.BITGO]) {
723-
const keychainToVerify = tx.customChange.keys[keyIndex];
724-
const keySignature = tx.customChange.signatures[keyIndex];
725-
if (!keychainToVerify) {
726-
throw new Error(`missing required custom change ${KeyIndices[keyIndex].toLowerCase()} keychain public key`);
727-
}
728-
if (!keySignature) {
729-
throw new Error(`missing required custom change ${KeyIndices[keyIndex].toLowerCase()} keychain signature`);
730-
}
731-
if (
732-
!this.verifyKeySignature({
733-
userKeychain: userKeychain as { pub: string },
734-
keychainToVerify: keychainToVerify as { pub: string },
735-
keySignature,
736-
})
737-
) {
738-
debug('failed to verify custom change %s key signature!', KeyIndices[keyIndex].toLowerCase());
739-
return false;
740-
}
741-
}
742-
743-
return true;
744-
}
745-
746-
/**
747-
* Get the maximum percentage limit for pay-as-you-go outputs
748-
*
749-
* @protected
750-
*/
751-
protected getPayGoLimit(allowPaygoOutput?: boolean): number {
752-
// allowing paygo outputs needs to be the default behavior, so only disallow paygo outputs if the
753-
// relevant verification option is both set and false
754-
if (!_.isNil(allowPaygoOutput) && !allowPaygoOutput) {
755-
return 0;
756-
}
757-
// 150 basis points is the absolute permitted maximum if paygo outputs are allowed
758-
return 0.015;
622+
return verifyKeySignature(params);
759623
}
760624

761625
/**
@@ -775,142 +639,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
775639
async verifyTransaction<TNumber extends number | bigint = number>(
776640
params: VerifyTransactionOptions<TNumber>
777641
): Promise<boolean> {
778-
const { txParams, txPrebuild, wallet, verification = { allowPaygoOutput: true }, reqId } = params;
779-
const isPsbt = txPrebuild.txHex && bitgo.isPsbt(txPrebuild.txHex);
780-
if (isPsbt && txPrebuild.txInfo?.unspents) {
781-
throw new Error('should not have unspents in txInfo for psbt');
782-
}
783-
784-
const disableNetworking = !!verification.disableNetworking;
785-
const parsedTransaction: ParsedTransaction<TNumber> = await this.parseTransaction<TNumber>({
786-
txParams,
787-
txPrebuild,
788-
wallet,
789-
verification,
790-
reqId,
791-
});
792-
793-
const keychains = parsedTransaction.keychains;
794-
795-
// verify that the claimed user public key corresponds to the wallet's user private key
796-
let userPublicKeyVerified = false;
797-
try {
798-
// verify the user public key matches the private key - this will throw if there is no match
799-
userPublicKeyVerified = this.verifyUserPublicKey({ userKeychain: keychains.user, disableNetworking, txParams });
800-
} catch (e) {
801-
debug('failed to verify user public key!', e);
802-
}
803-
804-
// let's verify these keychains
805-
const keySignatures = parsedTransaction.keySignatures;
806-
if (!_.isEmpty(keySignatures)) {
807-
const verify = (key, pub) => {
808-
if (!keychains.user || !keychains.user.pub) {
809-
throw new Error('missing user keychain');
810-
}
811-
return this.verifyKeySignature({
812-
userKeychain: keychains.user as { pub: string },
813-
keychainToVerify: key,
814-
keySignature: pub,
815-
});
816-
};
817-
const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub);
818-
const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub);
819-
if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) {
820-
throw new Error('secondary public key signatures invalid');
821-
}
822-
debug('successfully verified backup and bitgo key signatures');
823-
} else if (!disableNetworking) {
824-
// these keys were obtained online and their signatures were not verified
825-
// this could be dangerous
826-
console.log('unsigned keys obtained online are being used for address verification');
827-
}
828-
829-
if (parsedTransaction.needsCustomChangeKeySignatureVerification) {
830-
if (!keychains.user || !userPublicKeyVerified) {
831-
throw new Error('transaction requires verification of user public key, but it was unable to be verified');
832-
}
833-
const customChangeKeySignaturesVerified = this.verifyCustomChangeKeySignatures(parsedTransaction, keychains.user);
834-
if (!customChangeKeySignaturesVerified) {
835-
throw new Error(
836-
'transaction requires verification of custom change key signatures, but they were unable to be verified'
837-
);
838-
}
839-
debug('successfully verified user public key and custom change key signatures');
840-
}
841-
842-
const missingOutputs = parsedTransaction.missingOutputs;
843-
if (missingOutputs.length !== 0) {
844-
// there are some outputs in the recipients list that have not made it into the actual transaction
845-
throw new Error('expected outputs missing in transaction prebuild');
846-
}
847-
848-
const intendedExternalSpend = parsedTransaction.explicitExternalSpendAmount;
849-
850-
// this is a limit we impose for the total value that is amended to the transaction beyond what was originally intended
851-
const payAsYouGoLimit = new BigNumber(this.getPayGoLimit(verification.allowPaygoOutput)).multipliedBy(
852-
intendedExternalSpend.toString()
853-
);
854-
855-
/*
856-
Some explanation for why we're doing what we're doing:
857-
Some customers will have an output to BitGo's PAYGo wallet added to their transaction, and we need to account for
858-
it here. To protect someone tampering with the output to make it send more than it should to BitGo, we define a
859-
threshold for the output's value above which we'll throw an error, because the paygo output should never be that
860-
high.
861-
*/
862-
863-
// make sure that all the extra addresses are change addresses
864-
// get all the additional external outputs the server added and calculate their values
865-
const nonChangeAmount = new BigNumber(parsedTransaction.implicitExternalSpendAmount.toString());
866-
867-
debug(
868-
'Intended spend is %s, Non-change amount is %s, paygo limit is %s',
869-
intendedExternalSpend.toString(),
870-
nonChangeAmount.toString(),
871-
payAsYouGoLimit.toString()
872-
);
873-
874-
// There are two instances where we will get into this point here
875-
if (nonChangeAmount.gt(payAsYouGoLimit)) {
876-
if (isPsbt && parsedTransaction.customChange) {
877-
// In the case that we have a custom change address on a wallet and we are building the transaction
878-
// with a PSBT, we do not have the metadata to verify the address from the custom change wallet, nor
879-
// can we fetch that information from the other wallet because we may not have the credentials. Therefore,
880-
// we will not throw an error here, but we will log a warning.
881-
debug(`cannot verify some of the addresses because it belongs to a separate wallet`);
882-
} else {
883-
// the additional external outputs can only be BitGo's pay-as-you-go fee, but we cannot verify the wallet address
884-
// there are some addresses that are outside the scope of intended recipients that are not change addresses
885-
throw new Error('prebuild attempts to spend to unintended external recipients');
886-
}
887-
}
888-
889-
const allOutputs = parsedTransaction.outputs;
890-
if (!txPrebuild.txHex) {
891-
throw new Error(`txPrebuild.txHex not set`);
892-
}
893-
const inputs = isPsbt
894-
? getPsbtTxInputs(txPrebuild.txHex, this.network).map((v) => ({
895-
...v,
896-
value: bitgo.toTNumber(v.value, this.amountType),
897-
}))
898-
: await getTxInputs({ txPrebuild, bitgo: this.bitgo, coin: this, disableNetworking, reqId });
899-
// coins (doge) that can exceed number limits (and thus will use bigint) will have the `valueString` field
900-
const inputAmount = inputs.reduce(
901-
(sum: bigint, i) => sum + BigInt(this.amountType === 'bigint' ? i.valueString : i.value),
902-
BigInt(0)
903-
);
904-
const outputAmount = allOutputs.reduce((sum: bigint, o: Output) => sum + BigInt(o.amount), BigInt(0));
905-
const fee = inputAmount - outputAmount;
906-
907-
if (fee < 0) {
908-
throw new Error(
909-
`attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}`
910-
);
911-
}
912-
913-
return true;
642+
return verifyTransaction(this, this.bitgo, params);
914643
}
915644

916645
/**

0 commit comments

Comments
 (0)