Skip to content

Commit

Permalink
refactor(abstract-utxo): make descriptor validation more flexible
Browse files Browse the repository at this point in the history
Use a validator interface that allows for more complex validation and
composition of validation rules.

Add tests.

Issue: BTC-1708
  • Loading branch information
OttoAllmendinger committed Jan 3, 2025
1 parent 9680124 commit d5b0b60
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 32 deletions.
86 changes: 55 additions & 31 deletions modules/abstract-utxo/src/descriptor/validatePolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,71 @@ import * as utxolib from '@bitgo/utxo-lib';

import { DescriptorMap, toDescriptorMap } from '../core/descriptor';

import { DescriptorBuilder, parseDescriptor } from './builder';
import { parseDescriptor } from './builder';
import { NamedDescriptor } from './NamedDescriptor';

export type DescriptorValidationPolicy = { allowedTemplates: DescriptorBuilder['name'][] } | 'allowAll';

export type KeyTriple = Triple<utxolib.BIP32Interface>;

function isDescriptorWithTemplate(
d: Descriptor,
name: DescriptorBuilder['name'],
walletKeys: Triple<utxolib.BIP32Interface>
): boolean {
const parsed = parseDescriptor(d);
if (parsed.name !== name) {
return false;
}
if (parsed.keys.length !== walletKeys.length) {
return false;
export interface DescriptorValidationPolicy {
name: string;
validate(d: Descriptor, walletKeys: KeyTriple): boolean;
}

export const policyAllowAll: DescriptorValidationPolicy = {
name: 'allowAll',
validate: () => true,
};

export function getValidatorDescriptorTemplate(name: string): DescriptorValidationPolicy {
return {
name: 'descriptorTemplate(' + name + ')',
validate(d: Descriptor, walletKeys: KeyTriple): boolean {
const parsed = parseDescriptor(d);
return (
parsed.name === name &&
parsed.keys.length === walletKeys.length &&
parsed.keys.every((k, i) => k.toBase58() === walletKeys[i].neutered().toBase58())
);
},
};
}

export function getValidatorEvery(validators: DescriptorValidationPolicy[]): DescriptorValidationPolicy {
return {
name: 'every(' + validators.map((v) => v.name).join(',') + ')',
validate(d: Descriptor, walletKeys: KeyTriple): boolean {
return validators.every((v) => v.validate(d, walletKeys));
},
};
}

export function getValidatorSome(validators: DescriptorValidationPolicy[]): DescriptorValidationPolicy {
return {
name: 'some(' + validators.map((v) => v.name).join(',') + ')',
validate(d: Descriptor, walletKeys: KeyTriple): boolean {
return validators.some((v) => v.validate(d, walletKeys));
},
};
}

export function getValidatorOneOfTemplates(names: string[]): DescriptorValidationPolicy {
return getValidatorSome(names.map(getValidatorDescriptorTemplate));
}

export class DescriptorPolicyValidationError extends Error {
constructor(descriptor: Descriptor, policy: DescriptorValidationPolicy) {
super(`Descriptor ${descriptor.toString()} does not match policy ${policy.name}`);
}
return parsed.keys.every((k, i) => k.toBase58() === walletKeys[i].toBase58());
}

export function assertDescriptorPolicy(
descriptor: Descriptor,
policy: DescriptorValidationPolicy,
walletKeys: Triple<utxolib.BIP32Interface>
walletKeys: KeyTriple
): void {
if (policy === 'allowAll') {
return;
}

if ('allowedTemplates' in policy) {
const allowed = policy.allowedTemplates;
if (!allowed.some((t) => isDescriptorWithTemplate(descriptor, t, walletKeys))) {
throw new Error(`Descriptor ${descriptor.toString()} does not match any allowed template`);
}
if (!policy.validate(descriptor, walletKeys)) {
throw new DescriptorPolicyValidationError(descriptor, policy);
}

throw new Error(`Unknown descriptor validation policy: ${policy}`);
}

export function toDescriptorMapValidate(
Expand All @@ -61,10 +87,8 @@ export function getPolicyForEnv(env: EnvironmentName): DescriptorValidationPolic
switch (env) {
case 'adminProd':
case 'prod':
return {
allowedTemplates: ['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop'],
};
return getValidatorOneOfTemplates(['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']);
default:
return 'allowAll';
return policyAllowAll;
}
}
3 changes: 2 additions & 1 deletion modules/abstract-utxo/test/descriptor/descriptorWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getDescriptorMapFromWallet, isDescriptorWallet } from '../../src/descri
import { UtxoWallet } from '../../src/wallet';
import { getDefaultXPubs, getDescriptorMap } from '../core/descriptor/descriptor.utils';
import { toBip32Triple } from '../../src/keychains';
import { policyAllowAll } from '../../src/descriptor/validatePolicy';

describe('isDescriptorWalletData', function () {
const descriptorMap = getDescriptorMap('Wsh2Of3');
Expand All @@ -21,7 +22,7 @@ describe('isDescriptorWalletData', function () {

assert(isDescriptorWallet(wallet));
assert.strictEqual(
getDescriptorMapFromWallet(wallet, toBip32Triple(getDefaultXPubs()), 'allowAll').size,
getDescriptorMapFromWallet(wallet, toBip32Triple(getDefaultXPubs()), policyAllowAll).size,
descriptorMap.size
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from 'assert';

import { Descriptor } from '@bitgo/wasm-miniscript';
import { Triple } from '@bitgo/sdk-core';
import { BIP32Interface } from '@bitgo/utxo-lib';

import {
assertDescriptorPolicy,
DescriptorPolicyValidationError,
DescriptorValidationPolicy,
getPolicyForEnv,
getValidatorDescriptorTemplate,
} from '../../../src/descriptor/validatePolicy';
import { getDescriptor } from '../../core/descriptor/descriptor.utils';
import { getKeyTriple } from '../../core/key.utils';

function testAssertDescriptorPolicy(
d: Descriptor,
p: DescriptorValidationPolicy,
k: Triple<BIP32Interface>,
expectedError: DescriptorPolicyValidationError | null
) {
const f = () => assertDescriptorPolicy(d, p, k);
if (expectedError) {
assert.throws(f);
} else {
assert.doesNotThrow(f);
}
}

describe('assertDescriptorPolicy', function () {
it('has expected result', function () {
const keys = getKeyTriple();
testAssertDescriptorPolicy(getDescriptor('Wsh2Of3', keys), getValidatorDescriptorTemplate('Wsh2Of3'), keys, null);

// prod does only allow Wsh2Of3-ish descriptors
testAssertDescriptorPolicy(getDescriptor('Wsh2Of3', keys), getPolicyForEnv('prod'), keys, null);
testAssertDescriptorPolicy(
getDescriptor('Wsh2Of2', keys),
getPolicyForEnv('prod'),
keys,
new DescriptorPolicyValidationError(getDescriptor('Wsh2Of2', keys), getPolicyForEnv('prod'))
);

// test is very permissive by default
testAssertDescriptorPolicy(getDescriptor('Wsh2Of2', keys), getPolicyForEnv('test'), keys, null);
});
});

0 comments on commit d5b0b60

Please sign in to comment.