Skip to content

Commit af463df

Browse files
committed
Rewrite hdkey to make it more robust
1 parent 02eb64d commit af463df

File tree

2 files changed

+115
-78
lines changed

2 files changed

+115
-78
lines changed

src/hdkey.ts

Lines changed: 96 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ function modN(a: bigint, b: bigint = secp.CURVE.n): bigint {
3030
const MASTER_SECRET = utf8ToBytes("Bitcoin seed");
3131
// Bitcoin hardcoded by default
3232
const BITCOIN_VERSIONS: Versions = { private: 0x0488ade4, public: 0x0488b21e };
33+
export const HARDENED_OFFSET: number = 0x80000000;
3334

3435
export interface Versions {
3536
private: number;
@@ -44,54 +45,97 @@ const toU32 = (n: number) => {
4445
return buf;
4546
};
4647

48+
type HDKeyOpt = {
49+
versions: Versions;
50+
depth?: number;
51+
index?: number;
52+
parentFingerprint?: number;
53+
chainCode: Uint8Array;
54+
publicKey?: Uint8Array;
55+
privateKey?: Uint8Array | bigint;
56+
};
57+
4758
export class HDKey {
48-
public static HARDENED_OFFSET: number = 0x80000000;
49-
public static fromMasterSeed(seed: Uint8Array, versions?: Versions): HDKey {
59+
readonly versions: Versions;
60+
readonly depth: number = 0;
61+
readonly index: number = 0;
62+
readonly chainCode: Uint8Array | null = null;
63+
readonly parentFingerprint: number = 0;
64+
private privKey?: bigint;
65+
private privKeyBytes?: Uint8Array;
66+
private pubKey?: Uint8Array;
67+
private pubHash: Uint8Array | undefined;
68+
69+
static fromMasterSeed(
70+
seed: Uint8Array,
71+
versions: Versions = BITCOIN_VERSIONS
72+
): HDKey {
5073
const I = hmac(sha512, MASTER_SECRET, seed);
51-
const hdkey = new HDKey(versions);
52-
hdkey.chainCode = I.slice(32);
53-
hdkey.privateKey = I.slice(0, 32);
54-
return hdkey;
74+
return new HDKey({
75+
versions,
76+
chainCode: I.slice(32),
77+
privateKey: I.slice(0, 32)
78+
});
5579
}
56-
public static fromExtendedKey(base58key: string, versions?: Versions): HDKey {
80+
81+
static fromExtendedKey(
82+
base58key: string,
83+
versions: Versions = BITCOIN_VERSIONS
84+
): HDKey {
5785
// => version(4) || depth(1) || fingerprint(4) || index(4) || chain(32) || key(33)
58-
const hdkey = new HDKey(versions);
5986
const keyBuffer: Uint8Array = base58c.decode(base58key);
6087
const keyView = createView(keyBuffer);
6188
const version = keyView.getUint32(0, false);
62-
hdkey.depth = keyBuffer[4];
63-
hdkey.parentFingerprint = keyView.getUint32(5, false);
64-
hdkey.index = keyView.getUint32(9, false);
65-
hdkey.chainCode = keyBuffer.slice(13, 45);
89+
const opt = {
90+
versions,
91+
depth: keyBuffer[4],
92+
parentFingerprint: keyView.getUint32(5, false),
93+
index: keyView.getUint32(9, false),
94+
chainCode: keyBuffer.slice(13, 45)
95+
};
6696
const key = keyBuffer.slice(45);
6797
const isPriv = key[0] === 0;
68-
if (version !== hdkey.versions[isPriv ? "private" : "public"]) {
98+
if (version !== versions[isPriv ? "private" : "public"]) {
6999
throw new Error("Version mismatch");
70100
}
71101
if (isPriv) {
72-
hdkey.privateKey = key.slice(1);
102+
return new HDKey({ ...opt, privateKey: key.slice(1) });
73103
} else {
74-
hdkey.publicKey = key;
104+
return new HDKey({ ...opt, publicKey: key });
75105
}
76-
return hdkey;
77106
}
78107

79-
public static fromJSON(json: { xpriv: string }): HDKey {
108+
static fromJSON(json: { xpriv: string }): HDKey {
80109
return HDKey.fromExtendedKey(json.xpriv);
81110
}
82111

83-
public versions: Versions;
84-
public depth: number = 0;
85-
public index: number = 0;
86-
public chainCode: Uint8Array | null = null;
87-
public parentFingerprint: number = 0;
88-
private privKey?: bigint;
89-
private privKeyBytes?: Uint8Array;
90-
private pubKey?: Uint8Array;
91-
private pubHash: Uint8Array | undefined;
92-
93-
constructor(versions?: Versions) {
94-
this.versions = versions || BITCOIN_VERSIONS;
112+
constructor(opt: HDKeyOpt) {
113+
if (!opt || typeof opt !== "object") {
114+
throw new Error("HDKey.constructor must not be called directly");
115+
}
116+
this.versions = opt.versions || BITCOIN_VERSIONS;
117+
this.depth = opt.depth || 0;
118+
this.chainCode = opt.chainCode;
119+
this.index = opt.index || 0;
120+
this.parentFingerprint = opt.parentFingerprint || 0;
121+
if (opt.publicKey && opt.privateKey)
122+
throw new Error("HDKey: publicKey and privateKey at same time.");
123+
if (opt.privateKey) {
124+
if (!secp.utils.isValidPrivateKey(opt.privateKey)) {
125+
throw new Error("Invalid private key");
126+
}
127+
this.privKey =
128+
typeof opt.privateKey === "bigint"
129+
? opt.privateKey
130+
: bytesToNumber(opt.privateKey);
131+
this.privKeyBytes = numberToBytes(this.privKey);
132+
this.pubKey = secp.getPublicKey(opt.privateKey, true);
133+
} else if (opt.publicKey) {
134+
this.pubKey = secp.Point.fromHex(opt.publicKey).toRawBytes(true); // force compressed point
135+
} else {
136+
throw new Error("HDKey: no public or private key provided");
137+
}
138+
this.pubHash = hash160(this.pubKey);
95139
}
96140
get fingerprint(): number {
97141
if (!this.pubHash) {
@@ -108,34 +152,9 @@ export class HDKey {
108152
get privateKey(): Uint8Array | null {
109153
return this.privKeyBytes || null;
110154
}
111-
set privateKey(value: Uint8Array | bigint | null) {
112-
if (value == null) {
113-
this.wipePrivateData();
114-
return;
115-
}
116-
if (!secp.utils.isValidPrivateKey(value)) {
117-
throw new Error("Invalid private key");
118-
}
119-
this.privKey = typeof value === "bigint" ? value : bytesToNumber(value);
120-
this.privKeyBytes = numberToBytes(this.privKey);
121-
this.pubKey = secp.getPublicKey(value, true);
122-
this.pubHash = hash160(this.pubKey);
123-
}
124155
get publicKey(): Uint8Array | null {
125156
return this.pubKey || null;
126157
}
127-
set publicKey(value: Uint8Array | null) {
128-
let hex;
129-
try {
130-
hex = secp.Point.fromHex(value!);
131-
} catch (error) {
132-
throw new Error("Invalid public key");
133-
}
134-
this.pubKey = hex.toRawBytes(true); // force compressed point
135-
this.pubHash = hash160(this.pubKey);
136-
this.wipePrivateData();
137-
}
138-
139158
get privateExtendedKey(): string {
140159
const priv = this.privateKey;
141160
if (!priv) {
@@ -171,17 +190,18 @@ export class HDKey {
171190
throw new Error(`Invalid child index: ${c}`);
172191
}
173192
let idx = +m[1];
174-
if (!Number.isSafeInteger(idx) || idx >= HDKey.HARDENED_OFFSET) {
193+
if (!Number.isSafeInteger(idx) || idx >= HARDENED_OFFSET) {
175194
throw new Error("Invalid index");
176195
}
177196
// hardened key
178197
if (m[2] === "'") {
179-
idx += HDKey.HARDENED_OFFSET;
198+
idx += HARDENED_OFFSET;
180199
}
181200
child = child.deriveChild(idx);
182201
}
183202
return child;
184203
}
204+
185205
public deriveChild(index: number): HDKey {
186206
if (!Number.isSafeInteger(index) || index < 0 || index >= 2 ** 33) {
187207
throw new Error(
@@ -193,7 +213,7 @@ export class HDKey {
193213
}
194214
let data = new Uint8Array(4);
195215
createView(data).setUint32(0, index, false);
196-
if (index >= HDKey.HARDENED_OFFSET) {
216+
if (index >= HARDENED_OFFSET) {
197217
// Hardened
198218
const priv = this.privateKey;
199219
if (!priv) {
@@ -211,7 +231,13 @@ export class HDKey {
211231
if (!secp.utils.isValidPrivateKey(childTweak)) {
212232
throw new Error("Tweak bigger than curve order");
213233
}
214-
const child = new HDKey(this.versions);
234+
const opt: HDKeyOpt = {
235+
versions: this.versions,
236+
chainCode,
237+
depth: this.depth + 1,
238+
parentFingerprint: this.fingerprint,
239+
index
240+
};
215241
try {
216242
// Private parent key -> private child key
217243
if (this.privateKey) {
@@ -221,28 +247,29 @@ export class HDKey {
221247
"The tweak was out of range or the resulted private key is invalid"
222248
);
223249
}
224-
child.privateKey = added;
250+
opt.privateKey = added;
225251
} else {
226-
child.publicKey = secp.Point.fromHex(this.pubKey)
252+
opt.publicKey = secp.Point.fromHex(this.pubKey)
227253
.add(secp.Point.fromPrivateKey(childTweak))
228254
.toRawBytes(true);
229255
}
256+
return new HDKey(opt);
230257
} catch (err) {
231258
return this.deriveChild(index + 1);
232259
}
233-
child.chainCode = chainCode;
234-
child.depth = this.depth + 1;
235-
child.parentFingerprint = this.fingerprint;
236-
child.index = index;
237-
return child;
238260
}
261+
239262
public sign(hash: Uint8Array): Uint8Array {
240263
if (!this.privateKey) {
241264
throw new Error("No privateKey set!");
242265
}
243266
assertBytes(hash, 32);
244-
return secp.signSync(hash, this.privKey!, { canonical: true, der: false });
267+
return secp.signSync(hash, this.privKey!, {
268+
canonical: true,
269+
der: false
270+
});
245271
}
272+
246273
public verify(hash: Uint8Array, signature: Uint8Array): boolean {
247274
assertBytes(hash, 32);
248275
assertBytes(signature, 64);
@@ -257,10 +284,11 @@ export class HDKey {
257284
}
258285
return secp.verify(sig, hash, this.publicKey);
259286
}
287+
260288
public wipePrivateData(): this {
261289
this.privKey = undefined;
262-
if (this.privKeyBytes != null) {
263-
this.privKeyBytes!.fill(0);
290+
if (this.privKeyBytes) {
291+
this.privKeyBytes.fill(0);
264292
this.privKeyBytes = undefined;
265293
}
266294
return this;

test/test-vectors/hdkey.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as secp from "@noble/secp256k1";
2-
import { HDKey } from "../../src/hdkey";
2+
import { HDKey, HARDENED_OFFSET } from "../../src/hdkey";
33
import { hexToBytes, toHex } from "../../src/utils";
44
import { deepStrictEqual, throws } from "./assert";
55
// https://github.com/cryptocoinjs/hdkey/blob/42637e381bdef0c8f785b14f5b66a80dad969514/test/fixtures/hdkey.json
@@ -160,8 +160,11 @@ describe("hdkey", () => {
160160

161161
describe("- privateKey", () => {
162162
it("should throw an error if incorrect key size", () => {
163-
const hdkey = new HDKey();
163+
const seed = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542";
164+
const hdkey = HDKey.fromMasterSeed(hexToBytes(seed));
165+
// const hdkey = new HDKey.HDKey();
164166
throws(() => {
167+
// @ts-ignore
165168
hdkey.privateKey = new Uint8Array([1, 2, 3, 4]);
166169
});
167170
});
@@ -170,23 +173,29 @@ describe("hdkey", () => {
170173
describe("- publicKey", () => {
171174
it("should throw an error if incorrect key size", () => {
172175
throws(() => {
173-
const hdkey = new HDKey();
176+
const seed = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542";
177+
const hdkey = HDKey.fromMasterSeed(hexToBytes(seed));
178+
// @ts-ignore
174179
hdkey.publicKey = new Uint8Array([1, 2, 3, 4]);
175180
});
176181
});
177182

178183
it("should not throw if key is 33 bytes (compressed)", () => {
179184
const pub = secp.getPublicKey(secp.utils.randomPrivateKey(), true);
180185
deepStrictEqual(pub.length, 33);
181-
const hdkey = new HDKey();
182-
hdkey.publicKey = pub;
186+
const seed = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542";
187+
const hdkey = HDKey.fromMasterSeed(hexToBytes(seed));
188+
// @ts-ignore
189+
throws(() => { hdkey.publicKey = pub; });
183190
});
184191

185192
it("should not throw if key is 65 bytes (not compressed)", () => {
186193
const pub = secp.getPublicKey(secp.utils.randomPrivateKey(), false);
187194
deepStrictEqual(pub.length, 65);
188-
const hdkey = new HDKey();
189-
hdkey.publicKey = pub;
195+
const seed = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542";
196+
const hdkey = HDKey.fromMasterSeed(hexToBytes(seed));
197+
// @ts-ignore
198+
throws(() => { hdkey.publicKey = pub; });
190199
});
191200
});
192201

@@ -311,7 +320,7 @@ describe("hdkey", () => {
311320

312321
describe("HARDENED_OFFSET", () => {
313322
it("should be set", () => {
314-
deepStrictEqual(!!HDKey.HARDENED_OFFSET, true);
323+
deepStrictEqual(!!HARDENED_OFFSET, true);
315324
});
316325
});
317326

@@ -338,9 +347,9 @@ describe("hdkey", () => {
338347
const masterKey = HDKey.fromMasterSeed(hexToBytes(seed));
339348

340349
deepStrictEqual(!!masterKey.privateExtendedKey, true, "xpriv is truthy");
341-
(masterKey as any).privateKey = undefined;
350+
throws(() => { (masterKey as any).privateKey = undefined; });
342351

343-
throws(() => masterKey.privateExtendedKey);
352+
// throws(() => masterKey.privateExtendedKey);
344353
});
345354
});
346355

0 commit comments

Comments
 (0)