Skip to content

Commit 6a4aa19

Browse files
refactor(abstract-utxo): factor verifyTransaction
Make room for descriptor-specific implementations Issue: BTC-1450
1 parent 9e36b9a commit 6a4aa19

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: {
@@ -608,150 +605,17 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
608605
}
609606

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

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

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

912641
/**

0 commit comments

Comments
 (0)