aboutsummaryrefslogtreecommitdiffstats
path: root/test/unified-seed.test.ts
blob: 1bdf35d3f1ea03f96fe4217141d55ed9cf0daf6a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
import { describe, it, expect } from "bun:test";
import { HDKey } from "@scure/bip32";
import {
  generateMnemonic,
  mnemonicToSeed,
  mnemonicToEntropy,
  entropyToMnemonic,
  validateMnemonic,
} from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { encrypt, decrypt } from "nostr-tools/nip49";
import { privateKeyFromSeedWords } from "nostr-tools/nip06";
import { getPublicKey } from "nostr-tools";

const versions = {
  private: 0x04358394,
  public: 0x043587cf,
};

const password = "testpassword123";

describe("unified master seed", () => {
  const mnemonic = generateMnemonic(wordlist);

  it("generates a valid 12-word mnemonic", () => {
    const words = mnemonic.split(" ");
    expect(words.length).toBe(12);
    expect(validateMnemonic(mnemonic, wordlist)).toBe(true);
  });

  it("encrypts and decrypts entropy round-trip via NIP-49", async () => {
    const entropy = mnemonicToEntropy(mnemonic, wordlist);
    const encrypted = await encrypt(entropy, password);
    expect(typeof encrypted).toBe("string");
    expect(encrypted.startsWith("ncryptsec")).toBe(true);

    const decrypted = await decrypt(encrypted, password);
    const recovered = entropyToMnemonic(decrypted, wordlist);
    expect(recovered).toBe(mnemonic);
  });

  it("derives Bitcoin account at m/84'/0'/0'", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);
    const child = master.derive("m/84'/0'/0'");

    expect(child.publicExtendedKey).toBeTruthy();
    expect(child.publicExtendedKey.startsWith("tpub")).toBe(true);
    expect(child.privateKey).toBeTruthy();
    expect(child.fingerprint).toBeGreaterThan(0);
  });

  it("derives Ark key at m/86'/0'/0'/0/0", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);
    const arkChild = master.derive("m/86'/0'/0'/0/0");

    expect(arkChild.privateKey).toBeTruthy();
    expect(arkChild.privateKey!.length).toBe(32);

    const arkHex = bytesToHex(arkChild.privateKey!);
    expect(arkHex.length).toBe(64);
  });

  it("derives deterministic keys from the same seed", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master1 = HDKey.fromMasterSeed(seed, versions);
    const master2 = HDKey.fromMasterSeed(seed, versions);

    const btc1 = master1.derive("m/84'/0'/0'");
    const btc2 = master2.derive("m/84'/0'/0'");
    expect(btc1.publicExtendedKey).toBe(btc2.publicExtendedKey);

    const ark1 = master1.derive("m/86'/0'/0'/0/0");
    const ark2 = master2.derive("m/86'/0'/0'/0/0");
    expect(bytesToHex(ark1.privateKey!)).toBe(bytesToHex(ark2.privateKey!));
  });

  it("derives different keys for different Bitcoin account indices", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);

    const child0 = master.derive("m/84'/0'/0'");
    const child1 = master.derive("m/84'/0'/1'");
    const child2 = master.derive("m/84'/0'/2'");

    expect(child0.publicExtendedKey).not.toBe(child1.publicExtendedKey);
    expect(child1.publicExtendedKey).not.toBe(child2.publicExtendedKey);
    expect(child0.publicExtendedKey).not.toBe(child2.publicExtendedKey);
  });

  it("Bitcoin and Ark paths produce different keys", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);

    const btcChild = master.derive("m/84'/0'/0'");
    const arkChild = master.derive("m/86'/0'/0'/0/0");

    expect(bytesToHex(btcChild.privateKey!)).not.toBe(bytesToHex(arkChild.privateKey!));
  });
});

describe("full encrypt-derive-sign flow", () => {
  const mnemonic = generateMnemonic(wordlist);

  it("simulates the complete unified seed flow", async () => {
    // 1. User generates mnemonic and encrypts it as user.seed
    const entropy = mnemonicToEntropy(mnemonic, wordlist);
    const userSeed = await encrypt(entropy, password);

    // 2. Derive Bitcoin account (what pass page does)
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);
    const btcChild = master.derive("m/84'/0'/0'");
    const pubkey = btcChild.publicExtendedKey;
    const fingerprint = btcChild.fingerprint.toString(16).padStart(8, "0");

    expect(pubkey).toBeTruthy();
    expect(fingerprint.length).toBe(8);

    // 3. Later: decrypt user.seed to sign a transaction (what signTx does)
    const decryptedEntropy = await decrypt(userSeed, password);
    const recoveredMnemonic = entropyToMnemonic(decryptedEntropy, wordlist);
    const recoveredSeed = await mnemonicToSeed(recoveredMnemonic, password);
    const recoveredMaster = HDKey.fromMasterSeed(recoveredSeed, versions);
    const recoveredChild = recoveredMaster.derive("m/84'/0'/0'");

    expect(recoveredChild.publicExtendedKey).toBe(pubkey);
    expect(bytesToHex(recoveredChild.privateKey!)).toBe(bytesToHex(btcChild.privateKey!));
  });

  it("simulates Ark unlock from user.seed", async () => {
    // 1. Encrypt mnemonic as user.seed
    const entropy = mnemonicToEntropy(mnemonic, wordlist);
    const userSeed = await encrypt(entropy, password);

    // 2. Original Ark key derivation (during account creation)
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);
    const arkChild = master.derive("m/86'/0'/0'/0/0");
    const originalArkKey = bytesToHex(arkChild.privateKey!);

    // 3. Later: unlock Ark from user.seed (what tryUnlockArk does)
    const decryptedEntropy = await decrypt(userSeed, password);
    const recoveredMnemonic = entropyToMnemonic(decryptedEntropy, wordlist);
    const recoveredSeed = await mnemonicToSeed(recoveredMnemonic, password);
    const recoveredMaster = HDKey.fromMasterSeed(recoveredSeed, versions);
    const recoveredArkChild = recoveredMaster.derive("m/86'/0'/0'/0/0");
    const recoveredArkKey = bytesToHex(recoveredArkChild.privateKey!);

    expect(recoveredArkKey).toBe(originalArkKey);
  });

  it("dynamic accountIndex path works correctly", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);

    // Simulate what signTx does with accountIndex
    const accountIndex = 0;
    const child = master.derive(`m/84'/0'/${accountIndex}'`);

    // Should match the hardcoded path
    const hardcoded = master.derive("m/84'/0'/0'");
    expect(child.publicExtendedKey).toBe(hardcoded.publicExtendedKey);

    // accountIndex 1 should differ
    const child1 = master.derive(`m/84'/0'/${1}'`);
    expect(child1.publicExtendedKey).not.toBe(child.publicExtendedKey);
  });

  it("undefined accountIndex defaults to 0", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);

    // Simulates the ?? 0 fallback in signTx
    const accountIndex = undefined;
    const child = master.derive(`m/84'/0'/${accountIndex ?? 0}'`);
    const expected = master.derive("m/84'/0'/0'");

    expect(child.publicExtendedKey).toBe(expected.publicExtendedKey);
  });
});

describe("backward compatibility", () => {
  it("legacy per-account seed (raw hex) decrypts differently than mnemonic entropy", async () => {
    // Legacy: encrypt raw 32-byte hex key
    const rawKey = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
    const legacySeed = await encrypt(rawKey, password);

    // Decrypt legacy seed — get raw bytes back
    const decrypted = await decrypt(legacySeed, password);
    expect(bytesToHex(decrypted)).toBe(
      "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
    );

    // Master seed: encrypt mnemonic entropy (16 bytes for 12 words)
    const mnemonic = generateMnemonic(wordlist);
    const entropy = mnemonicToEntropy(mnemonic, wordlist);
    const masterSeed = await encrypt(entropy, password);

    // Decrypt master seed — get entropy back, convert to mnemonic
    const decryptedEntropy = await decrypt(masterSeed, password);
    const recovered = entropyToMnemonic(decryptedEntropy, wordlist);
    expect(recovered).toBe(mnemonic);
  });

  it("NIP-49 encrypted strings start with ncryptsec", async () => {
    const data = new Uint8Array(32);
    const encrypted = await encrypt(data, password);
    expect(encrypted.startsWith("ncryptsec")).toBe(true);
  });

  it("wrong password fails to decrypt", async () => {
    const mnemonic = generateMnemonic(wordlist);
    const entropy = mnemonicToEntropy(mnemonic, wordlist);
    const encrypted = await encrypt(entropy, password);

    expect(() => decrypt(encrypted, "wrongpassword")).toThrow();
  });
});

describe("NIP-06 nostr key derivation", () => {
  const mnemonic = generateMnemonic(wordlist);

  it("derives a deterministic nostr key from mnemonic via NIP-06 path", () => {
    const sk = privateKeyFromSeedWords(mnemonic);
    const pubkey = getPublicKey(sk);

    expect(sk).toBeInstanceOf(Uint8Array);
    expect(sk.length).toBe(32);
    expect(pubkey).toMatch(/^[0-9a-f]{64}$/);

    // Same mnemonic produces same key
    const sk2 = privateKeyFromSeedWords(mnemonic);
    expect(bytesToHex(sk2)).toBe(bytesToHex(sk));
  });

  it("derives nostr key from entropy round-trip", () => {
    const entropy = mnemonicToEntropy(mnemonic, wordlist);
    const recovered = entropyToMnemonic(entropy, wordlist);
    expect(recovered).toBe(mnemonic);

    const sk = privateKeyFromSeedWords(mnemonic);
    const skRecovered = privateKeyFromSeedWords(recovered);
    expect(bytesToHex(skRecovered)).toBe(bytesToHex(sk));
  });

  it("nostr NIP-06 key differs from Bitcoin and Ark keys", async () => {
    const seed = await mnemonicToSeed(mnemonic, password);
    const master = HDKey.fromMasterSeed(seed, versions);

    const btcKey = bytesToHex(master.derive("m/84'/0'/0'").privateKey!);
    const arkKey = bytesToHex(master.derive("m/86'/0'/0'/0/0").privateKey!);
    const nostrKey = bytesToHex(privateKeyFromSeedWords(mnemonic));

    expect(nostrKey).not.toBe(btcKey);
    expect(nostrKey).not.toBe(arkKey);
  });
});