Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(abstract-utxo): add descriptor signing support for offline vault #5381

Merged
merged 1 commit into from
Jan 16, 2025
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
13 changes: 13 additions & 0 deletions modules/abstract-utxo/src/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ export function getChainFromNetwork(n: utxolib.Network): string {
}
}

/**
* @param coinName - the name of the coin (e.g. 'btc', 'bch', 'ltc'). Also called 'chain' in some contexts.
* @returns the network for a coin. This is the mainnet network for the coin.
*/
export function getNetworkFromChain(coinName: string): utxolib.Network {
for (const network of utxolib.getNetworkList()) {
if (getChainFromNetwork(network) === coinName) {
return network;
}
}
throw new Error(`Unknown chain ${coinName}`);
}

export function getFullNameFromNetwork(n: utxolib.Network): string {
const name = getNetworkName(n);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as utxolib from '@bitgo/utxo-lib';
import { BIP32Interface } from '@bitgo/utxo-lib';

import { getNetworkFromChain } from '../names';

import { OfflineVaultUnsigned } from './OfflineVaultUnsigned';
import { DescriptorTransaction, getHalfSignedPsbt } from './descriptor';

export type OfflineVaultHalfSigned = {
halfSigned: { txHex: string };
};

function createHalfSignedFromPsbt(psbt: utxolib.Psbt): OfflineVaultHalfSigned {
return { halfSigned: { txHex: psbt.toHex() } };
}

export function createHalfSigned(coin: string, prv: string | BIP32Interface, tx: unknown): OfflineVaultHalfSigned {
const network = getNetworkFromChain(coin);
if (typeof prv === 'string') {
prv = utxolib.bip32.fromBase58(prv);
}
if (!OfflineVaultUnsigned.is(tx)) {
throw new Error('unsupported transaction type');
}
if (DescriptorTransaction.is(tx)) {
return createHalfSignedFromPsbt(getHalfSignedPsbt(tx, prv, network));
}
throw new Error('unsupported transaction type');
}
35 changes: 35 additions & 0 deletions modules/abstract-utxo/src/offlineVault/OfflineVaultUnsigned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as utxolib from '@bitgo/utxo-lib';
import { Triple } from '@bitgo/sdk-core';
import * as t from 'io-ts';

export const XPubWithDerivationPath = t.intersection(
[t.type({ xpub: t.string }), t.partial({ derivationPath: t.string })],
'XPubWithDerivationPath'
);

export type XPubWithDerivationPath = t.TypeOf<typeof XPubWithDerivationPath>;

/**
* This is the transaction payload that is sent to the offline vault to sign.
*/
export const OfflineVaultUnsigned = t.type(
{
xpubsWithDerivationPath: t.type({
user: XPubWithDerivationPath,
backup: XPubWithDerivationPath,
bitgo: XPubWithDerivationPath,
}),
coinSpecific: t.type({ txHex: t.string }),
},
'BaseTransaction'
);

export type OfflineVaultUnsigned = t.TypeOf<typeof OfflineVaultUnsigned>;

type WithXpub = { xpub: string };
type NamedKeys = { user: WithXpub; backup: WithXpub; bitgo: WithXpub };
export function toKeyTriple(xpubs: NamedKeys): Triple<utxolib.BIP32Interface> {
return [xpubs.user.xpub, xpubs.backup.xpub, xpubs.bitgo.xpub].map((xpub) =>
utxolib.bip32.fromBase58(xpub)
) as Triple<utxolib.BIP32Interface>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './transaction';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as utxolib from '@bitgo/utxo-lib';
import * as t from 'io-ts';

import { NamedDescriptor } from '../../descriptor';
import { OfflineVaultUnsigned, toKeyTriple } from '../OfflineVaultUnsigned';
import {
getValidatorOneOfTemplates,
getValidatorSignedByUserKey,
getValidatorSome,
toDescriptorMapValidate,
} from '../../descriptor/validatePolicy';
import { DescriptorMap } from '../../core/descriptor';
import { signPsbt } from '../../transaction/descriptor';

export const DescriptorTransaction = t.intersection(
[OfflineVaultUnsigned, t.type({ descriptors: t.array(NamedDescriptor) })],
'DescriptorTransaction'
);

export type DescriptorTransaction = t.TypeOf<typeof DescriptorTransaction>;

export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransaction): DescriptorMap {
const { descriptors, xpubsWithDerivationPath } = tx;
const pubkeys = toKeyTriple(xpubsWithDerivationPath);
const policy = getValidatorSome([
// allow all 2-of-3-ish descriptors where the keys match the wallet keys
getValidatorOneOfTemplates(['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']),
// allow all descriptors signed by the user key
getValidatorSignedByUserKey(),
]);
return toDescriptorMapValidate(descriptors, pubkeys, policy);
}

export function getHalfSignedPsbt(
tx: DescriptorTransaction,
prv: utxolib.BIP32Interface,
network: utxolib.Network
): utxolib.Psbt {
const psbt = utxolib.bitgo.createPsbtDecode(tx.coinSpecific.txHex, network);
const descriptorMap = getDescriptorsFromDescriptorTransaction(tx);
signPsbt(psbt, descriptorMap, prv, { onUnknownInput: 'throw' });
return psbt;
}
2 changes: 2 additions & 0 deletions modules/abstract-utxo/src/offlineVault/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as descriptor from './descriptor';
export * from './OfflineVaultHalfSigned';
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"walletKeys": [
"xprv9zXudaVgkhXsjTeXgpJ3K62R6bZDYQ5L5TVYfhGPLDDLNhfLtCYwHm4R4aMnMMeRpHSiM5Krxxbrux7iz99f7rSmZyddtBogiSch4PRVVXZ",
"xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b",
"xprv9s21ZrQH143K3Kh6W9VDkrpUSDrikEYKbbEKyB5Xn9bJeBPRSRSyWqQ5Fzoujj4eFmRKrxFPipYtfVqyu3aNYH4Lojrdhemi4aUdX8CjD8W"
],
"response": {
"txBase64": "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",
"descriptors": [
{
"name": "external",
"value": "wsh(multi(2,xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV/0/*,xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b/0/*,xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd/0/*))#gw7wwcku",
"signatures": [],
"lastIndex": 1
},
{
"name": "internal",
"value": "wsh(multi(2,xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV/1/*,xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b/1/*,xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd/1/*))#sw3k4yqr",
"signatures": [],
"lastIndex": 0
}
],
"formatVersion": 1,
"coin": "btc",
"pubs": [
"xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV",
"xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b",
"xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd"
],
"xpubsWithDerivationPath": {
"user": {
"xpub": "xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV",
"derivedFromParentWithSeed": "143700591154482"
},
"backup": {
"xpub": "xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b",
"derivedFromParentWithSeed": "210593312354420"
},
"bitgo": {
"xpub": "xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd"
}
},
"walletId": "6788d3cf8eaf7aa1db27d4575218103c",
"walletLabel": "UtxoWalletClient",
"amount": "199995579",
"address": "bc1q9cp8uk0qpal0h4hzrp2gvjy5hmwune773pnp2qtfwap22ryrnuxsehzwgh",
"pendingApprovalId": "6788d3cf8eaf7aa1db27d4f563e0e411",
"creatorId": "6788d3ce8eaf7aa1db27d18bc5baa93d",
"creatorEmail": "[email protected]",
"createDate": "2025-01-16T09:39:27.667Z",
"enterpriseId": "6788d3ce8eaf7aa1db27d2a664daec54",
"enterpriseName": "Foo Enterprises",
"enterpriseFeatureFlags": [
"enableMMI"
],
"videoId": {
"approver": "",
"date": "",
"link": "",
"exception": "",
"waived": true
},
"coinSpecific": {
"txHex": "70736274ff0100dd020000000201010101010101010101010101010101010101010101010101010101010101010000000000fdffffff01010101010101010101010101010101010101010101010101010101010101010100000000fdffffff0340420f00000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0d80841e00000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0dfbe9bd0b0000000022002072cba07a4f95a936089851622eea3b63232de777a4a627b72a3c4958f06f33a1000000000001012b00e1f505000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0d010569522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2206023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220602d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220602f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e800000000000000000001012b00e1f505000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0d010569522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2206023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220602d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220602f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e8000000000000000000010169522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2202023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220202d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220202f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e8000000000000000000010169522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2202023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220202d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220202f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e800000000000000000001016952210344a042f622eb71a19f80ef8bfb864f2068ed2d0643d165ba6ce674134d9cb9462103fd3ced6d7adb7b6efb9e07a8e60425f73fb882a6a2eae41bf303aa110c0e85712102b4b094b52276da9686a9a037964d9b637bba5c2b3f2f7b8521fba94851029a1653ae220202b4b094b52276da9686a9a037964d9b637bba5c2b3f2f7b8521fba94851029a160c59e62a71000000000100000022020344a042f622eb71a19f80ef8bfb864f2068ed2d0643d165ba6ce674134d9cb9460c2a1493e80000000001000000220203fd3ced6d7adb7b6efb9e07a8e60425f73fb882a6a2eae41bf303aa110c0e85710c1508470b000000000100000000",
"inputIds": [
"0101010101010101010101010101010101010101010101010101010101010101:0",
"0101010101010101010101010101010101010101010101010101010101010101:1"
]
},
"recipientsInfo": [],
"keyDerivationPath": "143700591154482"
}
}
80 changes: 80 additions & 0 deletions modules/abstract-utxo/test/offlineVault/halfSigned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as fs from 'fs';
import assert from 'assert';
import crypto from 'crypto';

import * as t from 'io-ts';
import * as utxolib from '@bitgo/utxo-lib';

import { createHalfSigned } from '../../src/offlineVault';
import { DescriptorTransaction } from '../../src/offlineVault/descriptor';

function getFixturesNames(): string[] {
// I'm using sync here because mocha cannot do async setup
// eslint-disable-next-line no-sync
return fs.readdirSync(__dirname + '/fixtures').filter((f) => f.endsWith('.json'));
}

const Fixture = t.type({
walletKeys: t.array(t.string),
response: t.unknown,
});

type Fixture = t.TypeOf<typeof Fixture>;

async function readFixture(name: string): Promise<Fixture> {
const data = JSON.parse(await fs.promises.readFile(__dirname + '/fixtures/' + name, 'utf-8'));
if (!Fixture.is(data)) {
throw new Error(`Invalid fixture ${name}`);
}
return data;
}

function withRotatedXpubs(tx: DescriptorTransaction): DescriptorTransaction {
const { user, backup, bitgo } = tx.xpubsWithDerivationPath;
return {
...tx,
xpubsWithDerivationPath: {
user: bitgo,
backup: user,
bitgo: backup,
},
};
}

function withRandomXpubs(tx: DescriptorTransaction) {
function randomXpub() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return utxolib.bip32.fromSeed(Buffer.from(bytes)).neutered().toBase58();
}
return {
...tx,
xpubsWithDerivationPath: {
user: randomXpub(),
backup: randomXpub(),
bitgo: randomXpub(),
},
};
}

function withoutDescriptors(tx: DescriptorTransaction): DescriptorTransaction {
return {
...tx,
descriptors: [],
};
}

describe('OfflineVaultHalfSigned', function () {
for (const fixtureName of getFixturesNames()) {
it(`can sign fixture ${fixtureName}`, async function () {
const { walletKeys, response } = await readFixture(fixtureName);
const prv = utxolib.bip32.fromBase58(walletKeys[0]);
createHalfSigned('btc', prv, response);

assert(DescriptorTransaction.is(response));
const mutations = [withRotatedXpubs(response), withRandomXpubs(response), withoutDescriptors(response)];
for (const mutation of mutations) {
assert.throws(() => createHalfSigned('btc', prv, mutation));
}
});
}
});
Loading