Skip to content

Commit 0cbf14a

Browse files
Merge pull request #5330 from BitGo/BTC-1708.verify-descriptor-signatures
feat(abstract-utxo): validate descriptor signatures
2 parents c82a4f3 + a19606a commit 0cbf14a

File tree

5 files changed

+164
-47
lines changed

5 files changed

+164
-47
lines changed

modules/abstract-utxo/src/core/descriptor/DescriptorMap.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { Descriptor } from '@bitgo/wasm-miniscript';
44
export type DescriptorMap = Map<string, Descriptor>;
55

66
/** Convert an array of descriptor name-value pairs to a descriptor map */
7-
export function toDescriptorMap(descriptors: { name: string; value: string }[]): DescriptorMap {
8-
return new Map(descriptors.map((d) => [d.name, Descriptor.fromString(d.value, 'derivable')]));
7+
export function toDescriptorMap(descriptors: { name: string; value: Descriptor | string }[]): DescriptorMap {
8+
return new Map(
9+
descriptors.map((d) => [
10+
d.name,
11+
d.value instanceof Descriptor ? d.value : Descriptor.fromString(d.value, 'derivable'),
12+
])
13+
);
914
}

modules/abstract-utxo/src/descriptor/NamedDescriptor.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ export const NamedDescriptor = t.intersection(
1616
'NamedDescriptor'
1717
);
1818

19-
export type NamedDescriptor = t.TypeOf<typeof NamedDescriptor>;
19+
export type NamedDescriptor<T = string> = {
20+
name: string;
21+
value: T;
22+
signatures?: string[];
23+
};
2024

2125
export function createNamedDescriptorWithSignature(
2226
name: string,
@@ -28,14 +32,19 @@ export function createNamedDescriptorWithSignature(
2832
return { name, value, signatures: [signature] };
2933
}
3034

31-
export function assertHasValidSignature(namedDescriptor: NamedDescriptor, key: BIP32Interface): void {
32-
if (namedDescriptor.signatures === undefined) {
33-
throw new Error(`Descriptor ${namedDescriptor.name} does not have a signature`);
35+
export function hasValidSignature(descriptor: string | Descriptor, key: BIP32Interface, signatures: string[]): boolean {
36+
if (typeof descriptor === 'string') {
37+
descriptor = Descriptor.fromString(descriptor, 'derivable');
3438
}
35-
const isValid = namedDescriptor.signatures.some((signature) => {
36-
return verifyMessage(namedDescriptor.value, key, Buffer.from(signature, 'hex'), networks.bitcoin);
39+
40+
const message = descriptor.toString();
41+
return signatures.some((signature) => {
42+
return verifyMessage(message, key, Buffer.from(signature, 'hex'), networks.bitcoin);
3743
});
38-
if (!isValid) {
44+
}
45+
46+
export function assertHasValidSignature(namedDescriptor: NamedDescriptor, key: BIP32Interface): void {
47+
if (!hasValidSignature(namedDescriptor.value, key, namedDescriptor.signatures ?? [])) {
3948
throw new Error(`Descriptor ${namedDescriptor.name} does not have a valid signature (key=${key.toBase58()})`);
4049
}
4150
}

modules/abstract-utxo/src/descriptor/validatePolicy.ts

Lines changed: 79 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,67 +4,109 @@ import * as utxolib from '@bitgo/utxo-lib';
44

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

7-
import { DescriptorBuilder, parseDescriptor } from './builder';
8-
import { NamedDescriptor } from './NamedDescriptor';
9-
10-
export type DescriptorValidationPolicy = { allowedTemplates: DescriptorBuilder['name'][] } | 'allowAll';
7+
import { parseDescriptor } from './builder';
8+
import { hasValidSignature, NamedDescriptor } from './NamedDescriptor';
119

1210
export type KeyTriple = Triple<utxolib.BIP32Interface>;
1311

14-
function isDescriptorWithTemplate(
15-
d: Descriptor,
16-
name: DescriptorBuilder['name'],
17-
walletKeys: Triple<utxolib.BIP32Interface>
18-
): boolean {
19-
const parsed = parseDescriptor(d);
20-
if (parsed.name !== name) {
21-
return false;
22-
}
23-
if (parsed.keys.length !== walletKeys.length) {
24-
return false;
12+
export interface DescriptorValidationPolicy {
13+
name: string;
14+
validate(d: Descriptor, walletKeys: KeyTriple, signatures: string[]): boolean;
15+
}
16+
17+
export const policyAllowAll: DescriptorValidationPolicy = {
18+
name: 'allowAll',
19+
validate: () => true,
20+
};
21+
22+
export function getValidatorDescriptorTemplate(name: string): DescriptorValidationPolicy {
23+
return {
24+
name: 'descriptorTemplate(' + name + ')',
25+
validate(d: Descriptor, walletKeys: KeyTriple): boolean {
26+
const parsed = parseDescriptor(d);
27+
return (
28+
parsed.name === name &&
29+
parsed.keys.length === walletKeys.length &&
30+
parsed.keys.every((k, i) => k.toBase58() === walletKeys[i].neutered().toBase58())
31+
);
32+
},
33+
};
34+
}
35+
36+
export function getValidatorEvery(validators: DescriptorValidationPolicy[]): DescriptorValidationPolicy {
37+
return {
38+
name: 'every(' + validators.map((v) => v.name).join(',') + ')',
39+
validate(d: Descriptor, walletKeys: KeyTriple, signatures: string[]): boolean {
40+
return validators.every((v) => v.validate(d, walletKeys, signatures));
41+
},
42+
};
43+
}
44+
45+
export function getValidatorSome(validators: DescriptorValidationPolicy[]): DescriptorValidationPolicy {
46+
return {
47+
name: 'some(' + validators.map((v) => v.name).join(',') + ')',
48+
validate(d: Descriptor, walletKeys: KeyTriple, signatures: string[]): boolean {
49+
return validators.some((v) => v.validate(d, walletKeys, signatures));
50+
},
51+
};
52+
}
53+
54+
export function getValidatorOneOfTemplates(names: string[]): DescriptorValidationPolicy {
55+
return getValidatorSome(names.map(getValidatorDescriptorTemplate));
56+
}
57+
58+
export function getValidatorSignedByUserKey(): DescriptorValidationPolicy {
59+
return {
60+
name: 'signedByUser',
61+
validate(d: Descriptor, walletKeys: KeyTriple, signatures: string[]): boolean {
62+
// the first key is the user key, by convention
63+
return hasValidSignature(d, walletKeys[0], signatures);
64+
},
65+
};
66+
}
67+
68+
export class DescriptorPolicyValidationError extends Error {
69+
constructor(descriptor: Descriptor, policy: DescriptorValidationPolicy) {
70+
super(`Descriptor ${descriptor.toString()} does not match policy ${policy.name}`);
2571
}
26-
return parsed.keys.every((k, i) => k.toBase58() === walletKeys[i].toBase58());
2772
}
2873

2974
export function assertDescriptorPolicy(
3075
descriptor: Descriptor,
3176
policy: DescriptorValidationPolicy,
32-
walletKeys: Triple<utxolib.BIP32Interface>
77+
walletKeys: KeyTriple,
78+
signatures: string[]
3379
): void {
34-
if (policy === 'allowAll') {
35-
return;
36-
}
37-
38-
if ('allowedTemplates' in policy) {
39-
const allowed = policy.allowedTemplates;
40-
if (!allowed.some((t) => isDescriptorWithTemplate(descriptor, t, walletKeys))) {
41-
throw new Error(`Descriptor ${descriptor.toString()} does not match any allowed template`);
42-
}
80+
if (!policy.validate(descriptor, walletKeys, signatures)) {
81+
throw new DescriptorPolicyValidationError(descriptor, policy);
4382
}
44-
45-
throw new Error(`Unknown descriptor validation policy: ${policy}`);
4683
}
4784

4885
export function toDescriptorMapValidate(
4986
descriptors: NamedDescriptor[],
5087
walletKeys: KeyTriple,
5188
policy: DescriptorValidationPolicy
5289
): DescriptorMap {
53-
const map = toDescriptorMap(descriptors);
54-
for (const descriptor of map.values()) {
55-
assertDescriptorPolicy(descriptor, policy, walletKeys);
56-
}
57-
return map;
90+
return toDescriptorMap(
91+
descriptors.map((namedDescriptor) => {
92+
const d = Descriptor.fromString(namedDescriptor.value, 'derivable');
93+
assertDescriptorPolicy(d, policy, walletKeys, namedDescriptor.signatures ?? []);
94+
return { name: namedDescriptor.name, value: d };
95+
})
96+
);
5897
}
5998

6099
export function getPolicyForEnv(env: EnvironmentName): DescriptorValidationPolicy {
61100
switch (env) {
62101
case 'adminProd':
63102
case 'prod':
64-
return {
65-
allowedTemplates: ['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop'],
66-
};
103+
return getValidatorSome([
104+
// allow all 2-of-3-ish descriptors where the keys match the wallet keys
105+
getValidatorOneOfTemplates(['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']),
106+
// allow all descriptors signed by the user key
107+
getValidatorSignedByUserKey(),
108+
]);
67109
default:
68-
return 'allowAll';
110+
return policyAllowAll;
69111
}
70112
}

modules/abstract-utxo/test/descriptor/descriptorWallet.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getDescriptorMapFromWallet, isDescriptorWallet } from '../../src/descri
44
import { UtxoWallet } from '../../src/wallet';
55
import { getDefaultXPubs, getDescriptorMap } from '../core/descriptor/descriptor.utils';
66
import { toBip32Triple } from '../../src/keychains';
7+
import { policyAllowAll } from '../../src/descriptor/validatePolicy';
78

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

2223
assert(isDescriptorWallet(wallet));
2324
assert.strictEqual(
24-
getDescriptorMapFromWallet(wallet, toBip32Triple(getDefaultXPubs()), 'allowAll').size,
25+
getDescriptorMapFromWallet(wallet, toBip32Triple(getDefaultXPubs()), policyAllowAll).size,
2526
descriptorMap.size
2627
);
2728
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from 'assert';
2+
3+
import { Descriptor } from '@bitgo/wasm-miniscript';
4+
import { Triple } from '@bitgo/sdk-core';
5+
import { BIP32Interface } from '@bitgo/utxo-lib';
6+
7+
import {
8+
assertDescriptorPolicy,
9+
DescriptorPolicyValidationError,
10+
DescriptorValidationPolicy,
11+
getPolicyForEnv,
12+
getValidatorDescriptorTemplate,
13+
} from '../../../src/descriptor/validatePolicy';
14+
import { DescriptorTemplate, getDescriptor } from '../../core/descriptor/descriptor.utils';
15+
import { getKeyTriple } from '../../core/key.utils';
16+
import { NamedDescriptor } from '../../../src/descriptor';
17+
import { createNamedDescriptorWithSignature } from '../../../src/descriptor/NamedDescriptor';
18+
19+
function testAssertDescriptorPolicy(
20+
d: NamedDescriptor<string>,
21+
p: DescriptorValidationPolicy,
22+
k: Triple<BIP32Interface>,
23+
expectedError: DescriptorPolicyValidationError | null
24+
) {
25+
const f = () => assertDescriptorPolicy(Descriptor.fromString(d.value, 'derivable'), p, k, d.signatures ?? []);
26+
if (expectedError) {
27+
assert.throws(f);
28+
} else {
29+
assert.doesNotThrow(f);
30+
}
31+
}
32+
33+
describe('assertDescriptorPolicy', function () {
34+
const keys = getKeyTriple();
35+
function getNamedDescriptor(name: DescriptorTemplate): NamedDescriptor {
36+
return createNamedDescriptorWithSignature(name, getDescriptor(name), keys[0]);
37+
}
38+
function stripSignature(d: NamedDescriptor): NamedDescriptor {
39+
return { ...d, signatures: undefined };
40+
}
41+
42+
it('has expected result', function () {
43+
testAssertDescriptorPolicy(getNamedDescriptor('Wsh2Of3'), getValidatorDescriptorTemplate('Wsh2Of3'), keys, null);
44+
45+
// prod does only allow Wsh2Of3-ish descriptors
46+
testAssertDescriptorPolicy(getNamedDescriptor('Wsh2Of3'), getPolicyForEnv('prod'), keys, null);
47+
48+
// prod only allows other descriptors if they are signed by the user key
49+
testAssertDescriptorPolicy(getNamedDescriptor('Wsh2Of2'), getPolicyForEnv('prod'), keys, null);
50+
testAssertDescriptorPolicy(
51+
stripSignature(getNamedDescriptor('Wsh2Of2')),
52+
getPolicyForEnv('prod'),
53+
keys,
54+
new DescriptorPolicyValidationError(getDescriptor('Wsh2Of2'), getPolicyForEnv('prod'))
55+
);
56+
57+
// test is very permissive by default
58+
testAssertDescriptorPolicy(stripSignature(getNamedDescriptor('Wsh2Of2')), getPolicyForEnv('test'), keys, null);
59+
});
60+
});

0 commit comments

Comments
 (0)