diff options
| author | Adam Soltys <[email protected]> | 2026-02-06 08:56:22 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-11 15:34:34 -0800 |
| commit | f85f5bebc49f566eaeb7bd81a285b6516dbefa9f (patch) | |
| tree | b8652c1fac15134f6774e7f47fd7d29963432da6 | |
| parent | a418477c3ee9bdd567cb1625f23abbd94758983d (diff) | |
| download | coinos-ui-f85f5bebc49f566eaeb7bd81a285b6516dbefa9f.tar.gz coinos-ui-f85f5bebc49f566eaeb7bd81a285b6516dbefa9f.zip | |
seed tests
| -rw-r--r-- | test/unified-seed.test.ts | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/test/unified-seed.test.ts b/test/unified-seed.test.ts new file mode 100644 index 00000000..e3c4dd31 --- /dev/null +++ b/test/unified-seed.test.ts @@ -0,0 +1,225 @@ +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"; + +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(); + }); +}); |
