diff --git a/modules/utxo-core/src/paygo/attestation.ts b/modules/utxo-core/src/paygo/attestation.ts new file mode 100644 index 0000000000..edaf6f0ee3 --- /dev/null +++ b/modules/utxo-core/src/paygo/attestation.ts @@ -0,0 +1,13 @@ +export const NILL_UUID = '00000000-0000-0000-0000-000000000000'; + +/** This function reconstructs the proof
+ * given the address and entropy. + * + * @param address + * @param entropy + * @returns + */ +export function createPayGoAttestationBuffer(address: string, entropy: Buffer): Buffer { + const addressBuffer = Buffer.from(address); + return Buffer.concat([entropy, addressBuffer, Buffer.from(NILL_UUID)]); +} diff --git a/modules/utxo-core/src/paygo/index.ts b/modules/utxo-core/src/paygo/index.ts index 2dd3b9d8b9..1b130416cf 100644 --- a/modules/utxo-core/src/paygo/index.ts +++ b/modules/utxo-core/src/paygo/index.ts @@ -1 +1,2 @@ export * from './ExtractAddressPayGoAttestation'; +export * from './psbt'; diff --git a/modules/utxo-core/src/paygo/psbt/Errors.ts b/modules/utxo-core/src/paygo/psbt/Errors.ts new file mode 100644 index 0000000000..ab57780bd8 --- /dev/null +++ b/modules/utxo-core/src/paygo/psbt/Errors.ts @@ -0,0 +1,29 @@ +export class ErrorNoPayGoProof extends Error { + constructor(public outputIndex: number) { + super(`There is no paygo address proof encoded in the PSBT at output ${outputIndex}.`); + } +} + +export class ErrorMultiplePayGoProof extends Error { + constructor() { + super('There are multiple paygo address proofs encoded in the PSBT. Something went wrong.'); + } +} + +export class ErrorPayGoAddressProofFailedVerification extends Error { + constructor() { + super('Cannot verify the paygo address signature with the provided pubkey.'); + } +} + +export class ErrorOutputIndexOutOfBounds extends Error { + constructor(public outputIndex: number) { + super(`Output index ${outputIndex} is out of bounds for PSBT outputs.`); + } +} + +export class ErrorMultiplePayGoProofAtPsbtIndex extends Error { + constructor(public outputIndex: number) { + super(`There are multiple PayGo addresses in the PSBT output ${outputIndex}.`); + } +} diff --git a/modules/utxo-core/src/paygo/psbt/index.ts b/modules/utxo-core/src/paygo/psbt/index.ts new file mode 100644 index 0000000000..bea5042737 --- /dev/null +++ b/modules/utxo-core/src/paygo/psbt/index.ts @@ -0,0 +1 @@ +export * from './payGoAddressProof'; diff --git a/modules/utxo-core/src/paygo/psbt/payGoAddressProof.ts b/modules/utxo-core/src/paygo/psbt/payGoAddressProof.ts new file mode 100644 index 0000000000..228d9c92aa --- /dev/null +++ b/modules/utxo-core/src/paygo/psbt/payGoAddressProof.ts @@ -0,0 +1,110 @@ +import * as utxolib from '@bitgo/utxo-lib'; +import { checkForOutput } from 'bip174/src/lib/utils'; + +import { verifyMessage } from '../../bip32utils'; +import { createPayGoAttestationBuffer } from '../attestation'; + +import { + ErrorMultiplePayGoProof, + ErrorMultiplePayGoProofAtPsbtIndex, + ErrorNoPayGoProof, + ErrorOutputIndexOutOfBounds, + ErrorPayGoAddressProofFailedVerification, +} from './Errors'; + +/** This function adds the entropy and signature into the PSBT output unknown key vals. + * We store the entropy so that we reconstruct the message
+ * to later verify. + * + * @param psbt - PSBT that we need to encode our paygo address into + * @param outputIndex - the index of the address in our output + * @param sig - the signature that we want to encode + */ +export function addPayGoAddressProof( + psbt: utxolib.bitgo.UtxoPsbt, + outputIndex: number, + sig: Buffer, + entropy: Buffer +): void { + utxolib.bitgo.addProprietaryKeyValuesFromUnknownKeyValues(psbt, 'output', outputIndex, { + key: { + identifier: utxolib.bitgo.PSBT_PROPRIETARY_IDENTIFIER, + subtype: utxolib.bitgo.ProprietaryKeySubtype.PAYGO_ADDRESS_ATTESTATION_PROOF, + keydata: entropy, + }, + value: sig, + }); +} + +/** Verify the paygo address signature is valid using verification pub key. + * + * @param psbt - PSBT we want to verify that the paygo address is in + * @param outputIndex - we have the output index that address is in + * @param uuid + * @returns + */ +export function verifyPayGoAddressProof( + psbt: utxolib.bitgo.UtxoPsbt, + outputIndex: number, + verificationPubkey: Buffer +): void { + const psbtOutputs = checkForOutput(psbt.data.outputs, outputIndex); + const stored = utxolib.bitgo.getProprietaryKeyValuesFromUnknownKeyValues(psbtOutputs, { + identifier: utxolib.bitgo.PSBT_PROPRIETARY_IDENTIFIER, + subtype: utxolib.bitgo.ProprietaryKeySubtype.PAYGO_ADDRESS_ATTESTATION_PROOF, + }); + + // assert stored length is 0 or 1 + if (stored.length === 0) { + throw new ErrorNoPayGoProof(outputIndex); + } else if (stored.length > 1) { + throw new ErrorMultiplePayGoProof(); + } + + // We get the signature and entropy from our PSBT unknown key vals + const signature = stored[0].value; + const entropy = stored[0].key.keydata; + + // Get the the PayGo address from the txOutputs + const txOutputs = psbt.txOutputs; + if (outputIndex >= txOutputs.length) { + throw new ErrorOutputIndexOutOfBounds(outputIndex); + } + const output = txOutputs[outputIndex]; + const addressFromOutput = utxolib.address.fromOutputScript(output.script, psbt.network); + + // We construct our message
+ const message = createPayGoAttestationBuffer(addressFromOutput, entropy); + + if (!verifyMessage(message.toString(), verificationPubkey, signature, utxolib.networks.bitcoin)) { + throw new ErrorPayGoAddressProofFailedVerification(); + } +} + +/** Get the output index of the paygo output if there is one. It does this by + * checking if the metadata is on one of the outputs of the PSBT. If there is + * no paygo output, return undefined + * + * @param psbt + * @returns number - the index of the output address + */ +export function getPayGoAddressProofOutputIndex(psbt: utxolib.bitgo.UtxoPsbt): number | undefined { + const res = psbt.data.outputs.flatMap((output, outputIndex) => { + const proprietaryKeyVals = utxolib.bitgo.getPsbtOutputProprietaryKeyVals(output, { + identifier: utxolib.bitgo.PSBT_PROPRIETARY_IDENTIFIER, + subtype: utxolib.bitgo.ProprietaryKeySubtype.PAYGO_ADDRESS_ATTESTATION_PROOF, + }); + + if (proprietaryKeyVals.length > 1) { + throw new ErrorMultiplePayGoProofAtPsbtIndex(outputIndex); + } + + return proprietaryKeyVals.length === 0 ? [] : [outputIndex]; + }); + + return res.length === 0 ? undefined : res[0]; +} + +export function psbtOutputIncludesPaygoAddressProof(psbt: utxolib.bitgo.UtxoPsbt): boolean { + return getPayGoAddressProofOutputIndex(psbt) !== undefined; +} diff --git a/modules/utxo-core/src/testutil/index.ts b/modules/utxo-core/src/testutil/index.ts index 8d0a2dc45f..e0489f5195 100644 --- a/modules/utxo-core/src/testutil/index.ts +++ b/modules/utxo-core/src/testutil/index.ts @@ -2,3 +2,4 @@ export * from './fixtures.utils'; export * from './key.utils'; export * from './toPlainObject.utils'; export * from './generatePayGoAttestationProof.utils'; +export * from './parseVaspProof'; diff --git a/modules/utxo-core/src/testutil/parseVaspProof.ts b/modules/utxo-core/src/testutil/parseVaspProof.ts new file mode 100644 index 0000000000..8b9e32e02f --- /dev/null +++ b/modules/utxo-core/src/testutil/parseVaspProof.ts @@ -0,0 +1,25 @@ +import * as utxolib from '@bitgo/utxo-lib'; + +/** We receive a proof in the form: + * 0x18Bitcoin Signed Message:\n
+ * and when verifying our message in our PayGo utils we want to only verify + * the message portion of our proof. This helps to pare our proof in that format, + * and returns a Buffer. + * + * @param proof + * @returns + */ +export function parseVaspProof(proof: Buffer): Buffer { + const prefix = '\u0018Bitcoin Signed Message:\n'; + if (proof.toString().startsWith(prefix)) { + proof = proof.slice(Buffer.from(prefix).length); + utxolib.bufferutils.varuint.decode(proof, 0); + // Determines how many bytes were consumed during our last varuint.decode(Buffer, offset) + // So if varuint.decode(0xfd) then varuint.decode.bytes = 3 + // varuint.decode(0xfe) then varuint.decode.bytes = 5, etc. + const varintBytesLength = utxolib.bufferutils.varuint.decode.bytes; + + proof.slice(varintBytesLength); + } + return proof; +} diff --git a/modules/utxo-core/test/paygo/psbt/payGoAddressProof.ts b/modules/utxo-core/test/paygo/psbt/payGoAddressProof.ts new file mode 100644 index 0000000000..7876541c21 --- /dev/null +++ b/modules/utxo-core/test/paygo/psbt/payGoAddressProof.ts @@ -0,0 +1,151 @@ +import assert from 'assert'; +import crypto from 'crypto'; + +import * as utxolib from '@bitgo/utxo-lib'; +import { decodeProprietaryKey } from 'bip174/src/lib/proprietaryKeyVal'; +import { KeyValue } from 'bip174/src/lib/interfaces'; +import { checkForOutput } from 'bip174/src/lib/utils'; + +import { + addPayGoAddressProof, + getPayGoAddressProofOutputIndex, + psbtOutputIncludesPaygoAddressProof, + verifyPayGoAddressProof, +} from '../../../src/paygo/psbt/payGoAddressProof'; +import { generatePayGoAttestationProof } from '../../../src/testutil/generatePayGoAttestationProof.utils'; +import { parseVaspProof } from '../../../src/testutil/parseVaspProof'; +import { signMessage } from '../../../src/bip32utils'; +import { createPayGoAttestationBuffer, NILL_UUID } from '../../../src/paygo/attestation'; + +// To construct our PSBTs +const network = utxolib.networks.bitcoin; +const keys = [1, 2, 3].map((v) => utxolib.bip32.fromSeed(Buffer.alloc(16, `test/2/${v}`), network)); +const rootWalletKeys = new utxolib.bitgo.RootWalletKeys([keys[0], keys[1], keys[2]]); + +// PSBT INPUTS AND OUTPUTS +const psbtInputs = utxolib.testutil.inputScriptTypes.map((scriptType) => ({ + scriptType, + value: BigInt(1000), +})); +const psbtOutputs = utxolib.testutil.outputScriptTypes.map((scriptType) => ({ + scriptType, + value: BigInt(900), +})); + +// wallet pub and priv key for tbtc +const dummyPub1 = rootWalletKeys.deriveForChainAndIndex(50, 200); +const attestationPubKey = dummyPub1.user.publicKey; +const attestationPrvKey = dummyPub1.user.privateKey!; + +// our xpub converted to base58 address +const addressToVerify = utxolib.address.toBase58Check( + utxolib.crypto.hash160(Buffer.from(dummyPub1.backup.publicKey)), + utxolib.networks.bitcoin.pubKeyHash, + utxolib.networks.bitcoin +); + +// this should be retuning a Buffer +const addressProofBuffer = generatePayGoAttestationProof(NILL_UUID, Buffer.from(addressToVerify)); +const addressProofMsgBuffer = parseVaspProof(addressProofBuffer); +// We know that that the entropy is a set 64 bytes. +const addressProofEntropy = addressProofMsgBuffer.subarray(0, 65); + +// signature with the given msg addressProofBuffer +const sig = signMessage(addressProofMsgBuffer.toString(), attestationPrvKey!, network); + +function getTestPsbt() { + return utxolib.testutil.constructPsbt(psbtInputs, psbtOutputs, network, rootWalletKeys, 'unsigned'); +} + +describe('addPaygoAddressProof and verifyPaygoAddressProof', () => { + function getPaygoProprietaryKey(proprietaryKeyVals: KeyValue[]) { + return proprietaryKeyVals + .map(({ key, value }) => { + return { key: decodeProprietaryKey(key), value }; + }) + .filter((keyValue) => { + return ( + keyValue.key.identifier === utxolib.bitgo.PSBT_PROPRIETARY_IDENTIFIER && + keyValue.key.subtype === utxolib.bitgo.ProprietaryKeySubtype.PAYGO_ADDRESS_ATTESTATION_PROOF + ); + }); + } + + it('should add and verify a valid paygo address proof on the PSBT', () => { + const psbt = getTestPsbt(); + psbt.addOutput({ script: utxolib.address.toOutputScript(addressToVerify, network), value: BigInt(10000) }); + const outputIndex = psbt.data.outputs.length - 1; + addPayGoAddressProof(psbt, outputIndex, sig, addressProofEntropy); + verifyPayGoAddressProof(psbt, outputIndex, attestationPubKey); + }); + + it('should throw an error if there are multiple PayGo proprietary keys in the PSBT', () => { + const outputIndex = 0; + const psbt = getTestPsbt(); + addPayGoAddressProof(psbt, outputIndex, sig, addressProofEntropy); + addPayGoAddressProof(psbt, outputIndex, Buffer.from('signature2'), crypto.randomBytes(64)); + const output = checkForOutput(psbt.data.outputs, outputIndex); + const proofInPsbt = getPaygoProprietaryKey(output.unknownKeyVals!); + assert(proofInPsbt.length !== 0); + assert(proofInPsbt.length > 1); + assert.throws( + () => verifyPayGoAddressProof(psbt, outputIndex, attestationPubKey), + (e: any) => e.message === 'There are multiple paygo address proofs encoded in the PSBT. Something went wrong.' + ); + }); +}); + +describe('verifyPaygoAddressProof', () => { + it('should throw an error if there is no PayGo address in PSBT', () => { + const psbt = getTestPsbt(); + assert.throws( + () => verifyPayGoAddressProof(psbt, 0, attestationPubKey), + (e: any) => e.message === 'There is no paygo address proof encoded in the PSBT at output 0.' + ); + }); +}); + +describe('getPaygoAddressProofIndex', () => { + it('should get PayGo address proof index from PSBT if there is one', () => { + const psbt = getTestPsbt(); + const outputIndex = 0; + addPayGoAddressProof(psbt, outputIndex, sig, Buffer.from(attestationPubKey)); + assert(psbtOutputIncludesPaygoAddressProof(psbt)); + assert(getPayGoAddressProofOutputIndex(psbt) === 0); + }); + + it('should return undefined if there is no PayGo address proof in PSBT', () => { + const psbt = getTestPsbt(); + assert(getPayGoAddressProofOutputIndex(psbt) === undefined); + assert(!psbtOutputIncludesPaygoAddressProof(psbt)); + }); + + it('should return an error and fail if we have multiple PayGo address in the PSBT in the same output index', () => { + const psbt = getTestPsbt(); + const outputIndex = 0; + addPayGoAddressProof(psbt, outputIndex, sig, addressProofEntropy); + addPayGoAddressProof(psbt, outputIndex, sig, crypto.randomBytes(64)); + assert.throws( + () => getPayGoAddressProofOutputIndex(psbt), + (e: any) => e.message === 'There are multiple PayGo addresses in the PSBT output 0.' + ); + }); +}); + +describe('createPayGoAttestationBuffer', () => { + it('should create a PayGo Attestation proof matching with original proof', () => { + const payGoAttestationProof = createPayGoAttestationBuffer(addressToVerify, addressProofEntropy); + assert.strictEqual(payGoAttestationProof.toString(), addressProofMsgBuffer.toString()); + assert(Buffer.compare(payGoAttestationProof, addressProofMsgBuffer) === 0); + }); + + it('should create a PayGo Attestation proof that does not match with different uuid', () => { + const addressProofBufferDiffUuid = generatePayGoAttestationProof( + '00000000-0000-0000-0000-000000000001', + Buffer.from(addressToVerify) + ); + const payGoAttestationProof = createPayGoAttestationBuffer(addressToVerify, addressProofEntropy); + assert.notStrictEqual(payGoAttestationProof.toString(), addressProofBufferDiffUuid.toString()); + assert(Buffer.compare(payGoAttestationProof, addressProofBufferDiffUuid) !== 0); + }); +}); diff --git a/modules/utxo-lib/src/bitgo/PsbtUtil.ts b/modules/utxo-lib/src/bitgo/PsbtUtil.ts index 2aa36733a9..fbbd187bc8 100644 --- a/modules/utxo-lib/src/bitgo/PsbtUtil.ts +++ b/modules/utxo-lib/src/bitgo/PsbtUtil.ts @@ -20,6 +20,7 @@ export enum ProprietaryKeySubtype { MUSIG2_PARTICIPANT_PUB_KEYS = 0x01, MUSIG2_PUB_NONCE = 0x02, MUSIG2_PARTIAL_SIG = 0x03, + PAYGO_ADDRESS_ATTESTATION_PROOF = 0x04, } /** diff --git a/modules/utxo-lib/src/bitgo/zcash/address.ts b/modules/utxo-lib/src/bitgo/zcash/address.ts index e03e00f22f..4943d4cf60 100644 --- a/modules/utxo-lib/src/bitgo/zcash/address.ts +++ b/modules/utxo-lib/src/bitgo/zcash/address.ts @@ -23,7 +23,7 @@ export function toBase58Check(hash: Buffer, version: number): string { } export function fromOutputScript(outputScript: Buffer, network: Network): string { - assert(isZcash(network)); + assert.ok(isZcash(network)); let o; let prefix; try { @@ -41,7 +41,7 @@ export function fromOutputScript(outputScript: Buffer, network: Network): string } export function toOutputScript(address: string, network: Network): Buffer { - assert(isZcash(network)); + assert.ok(isZcash(network)); const { version, hash } = fromBase58Check(address); if (version === network.pubKeyHash) { return payments.p2pkh({ hash }).output as Buffer;