|
| 1 | +import { hmac } from "@noble/hashes/hmac.js" |
| 2 | +import { sha512 } from "@noble/hashes/sha2.js" |
| 3 | +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" |
| 4 | +import { HDKey } from "@scure/bip32" |
| 5 | +import { entropyToMnemonic } from "@scure/bip39" |
| 6 | +import { wordlist } from "@scure/bip39/wordlists/english.js" |
| 7 | +import { Slip39 } from "slip39-ts" |
| 8 | +import { describe, expect, test } from "vitest" |
| 9 | +import { |
| 10 | + deriveDefaultSparkWalletSecret, |
| 11 | + deriveEvoluOwnerSecret, |
| 12 | + MasterKey, |
| 13 | + masterKeyToMnemonic, |
| 14 | + mnemonicToMasterKey, |
| 15 | + RecoveryMnemonic, |
| 16 | + sparkSecretToMnemonic, |
| 17 | +} from "./key-derivation.ts" |
| 18 | + |
| 19 | +/** |
| 20 | + * Cross-app seed and key derivation vectors shared between Linky and Payky. |
| 21 | + * |
| 22 | + * The identical vector table lives in both repos: |
| 23 | + * linky: packages/core/src/identity/crossAppDerivation.test.ts |
| 24 | + * payky: src/core/modules/shared/cross-app-derivation.test.ts |
| 25 | + * |
| 26 | + * Both apps derive everything from a 16-byte master secret carried by a |
| 27 | + * 20-word SLIP-39 share (empty passphrase) via BIP-32 plus BIP-85-style |
| 28 | + * entropy (HMAC-SHA512 keyed "bip-entropy-from-k" over the derived node's |
| 29 | + * private key). A user's share must recover the same master secret in both |
| 30 | + * apps, and the shared cashu path must yield the same wallet. |
| 31 | + * |
| 32 | + * If a change here is intentional, update BOTH copies in the same way — |
| 33 | + * a mismatch means one app broke seed compatibility with the other. |
| 34 | + */ |
| 35 | +const MASTER_SECRET_HEX = "000102030405060708090a0b0c0d0e0f" |
| 36 | +const SLIP39_SHARE = |
| 37 | + "item lilac academic academic armed dress review premium imply typical dominant daisy voting random agency bike dive being coastal rocky" |
| 38 | + |
| 39 | +const NOSTR_VECTOR = { |
| 40 | + path: "m/44'/1237'/0'/0/0", |
| 41 | + privateKeyHex: |
| 42 | + "8fc4e797ec285ba10169312dfcf0f321ef77f0d1f0fc0cb708b88eadfb7b7025", |
| 43 | +} |
| 44 | + |
| 45 | +interface Bip85Vector { |
| 46 | + readonly path: string |
| 47 | + readonly entropyBytes: 16 | 32 |
| 48 | + readonly entropyHex: string |
| 49 | + readonly mnemonic?: string |
| 50 | +} |
| 51 | + |
| 52 | +const BIP85_VECTORS: Record<string, Bip85Vector> = { |
| 53 | + sharedCashuWallet: { |
| 54 | + path: "m/83696968'/39'/0'/24'/0'", |
| 55 | + entropyBytes: 32, |
| 56 | + entropyHex: |
| 57 | + "f95d13e97d1737c7ecb5bb6ef02c18d0f2fb4bbd22af12399677362dd44ba1ba", |
| 58 | + mnemonic: |
| 59 | + "welcome trigger where when inflict token ready resist humble lift alert peanut cook place virus field banana smile oven hobby tail chair manage easily", |
| 60 | + }, |
| 61 | + linkyMetaOwner: { |
| 62 | + path: "m/83696968'/39'/0'/24'/1'/0'", |
| 63 | + entropyBytes: 16, |
| 64 | + entropyHex: "3dbb7bf6df96a48234e4a8746c7400a7", |
| 65 | + mnemonic: |
| 66 | + "diet swift world sand heart donate squeeze never inner glove ability exist", |
| 67 | + }, |
| 68 | + linkyContactsOwner0: { |
| 69 | + path: "m/83696968'/39'/0'/24'/2'/0'", |
| 70 | + entropyBytes: 16, |
| 71 | + entropyHex: "8c911ddc3d91b03a92b6a9e808ec494f", |
| 72 | + mnemonic: |
| 73 | + "milk material jacket kite brand bubble enlist steel trend electric banner oyster", |
| 74 | + }, |
| 75 | + linkyContactsOwner1: { |
| 76 | + path: "m/83696968'/39'/0'/24'/2'/1'", |
| 77 | + entropyBytes: 16, |
| 78 | + entropyHex: "7f6d342c3ece8f341e9867e33b9504cf", |
| 79 | + mnemonic: |
| 80 | + "legal have arch laugh trophy old kidney artefact tobacco syrup donkey palace", |
| 81 | + }, |
| 82 | + linkyCashuOwner0: { |
| 83 | + path: "m/83696968'/39'/0'/24'/3'/0'", |
| 84 | + entropyBytes: 16, |
| 85 | + entropyHex: "a6cc79dbf34bbc2cef1c8c398e8689a5", |
| 86 | + mnemonic: |
| 87 | + "plug glow ivory track rookie biology round muscle define injury pen engine", |
| 88 | + }, |
| 89 | + linkyCashuOwner1: { |
| 90 | + path: "m/83696968'/39'/0'/24'/3'/1'", |
| 91 | + entropyBytes: 16, |
| 92 | + entropyHex: "b69981c9b80febf6125bc7a285327673", |
| 93 | + mnemonic: |
| 94 | + "repair slot include hybrid wrong wild enact jump penalty civil outside travel", |
| 95 | + }, |
| 96 | + linkyMessagesOwner0: { |
| 97 | + path: "m/83696968'/39'/0'/24'/4'/0'", |
| 98 | + entropyBytes: 16, |
| 99 | + entropyHex: "20173da4ad3f07eff8cf23eb01c8a393", |
| 100 | + mnemonic: |
| 101 | + "cactus rigid hard foil vacant wave tobacco tongue twelve atom cigar chase", |
| 102 | + }, |
| 103 | + linkyMessagesOwner1: { |
| 104 | + path: "m/83696968'/39'/0'/24'/4'/1'", |
| 105 | + entropyBytes: 16, |
| 106 | + entropyHex: "df6a9d56250629b57c98faaf7844c5f6", |
| 107 | + mnemonic: |
| 108 | + "term female few energy glad survey venue butter quarter season cousin undo", |
| 109 | + }, |
| 110 | + linkyTransactionsOwner0: { |
| 111 | + path: "m/83696968'/39'/0'/24'/5'/0'", |
| 112 | + entropyBytes: 16, |
| 113 | + entropyHex: "a108cfb256968b6424dd022203178e8a", |
| 114 | + mnemonic: |
| 115 | + "patient edit uncle pudding hamster rare nature park capital board together bench", |
| 116 | + }, |
| 117 | + linkyTransactionsOwner1: { |
| 118 | + path: "m/83696968'/39'/0'/24'/5'/1'", |
| 119 | + entropyBytes: 16, |
| 120 | + entropyHex: "43d4e0d9ec58c93686745ca21492d139", |
| 121 | + mnemonic: |
| 122 | + "duck poem cushion suffer milk opera border merit pear pig reform indicate", |
| 123 | + }, |
| 124 | + linkyIdentityOwner: { |
| 125 | + path: "m/83696968'/39'/0'/24'/6'/0'", |
| 126 | + entropyBytes: 16, |
| 127 | + entropyHex: "81223ecef72d70f78a7fe39ecfd9f1d8", |
| 128 | + mnemonic: |
| 129 | + "license ball recipe unusual strike knock clarify wise paddle learn ladder rack", |
| 130 | + }, |
| 131 | + paykyEvoluOwner: { |
| 132 | + path: "m/83696968'/39'/0'/24'/1'", |
| 133 | + entropyBytes: 32, |
| 134 | + entropyHex: |
| 135 | + "752f4f00e250861b5bd7c79077eb20cae8a18bb3c8cb7f54be5c6a1de8e48b0c", |
| 136 | + }, |
| 137 | + paykySparkWallet: { |
| 138 | + path: "m/83696968'/39'/0'/12'/0'", |
| 139 | + entropyBytes: 16, |
| 140 | + entropyHex: "a8117f2ba9ed92d57c35e5997ecf9ca8", |
| 141 | + mnemonic: |
| 142 | + "pool message slab fatigue summer height valid royal offer wait transfer expand", |
| 143 | + }, |
| 144 | +} |
| 145 | + |
| 146 | +const BIP85_HMAC_KEY = new TextEncoder().encode("bip-entropy-from-k") |
| 147 | + |
| 148 | +const deriveEntropy = (path: string, entropyBytes: 16 | 32): string => { |
| 149 | + const root = HDKey.fromMasterSeed(hexToBytes(MASTER_SECRET_HEX)) |
| 150 | + const privateKey = root.derive(path).privateKey |
| 151 | + if (privateKey === null) throw new Error(`no private key at ${path}`) |
| 152 | + return bytesToHex( |
| 153 | + hmac(sha512, BIP85_HMAC_KEY, privateKey).slice(0, entropyBytes) |
| 154 | + ) |
| 155 | +} |
| 156 | + |
| 157 | +describe("cross-app derivation vectors (shared with Linky)", () => { |
| 158 | + test("the SLIP-39 share recovers the master secret with an empty passphrase", async () => { |
| 159 | + expect(Slip39.validateMnemonic(SLIP39_SHARE)).toBe(true) |
| 160 | + const recovered = new Uint8Array( |
| 161 | + await Slip39.recoverSecret([SLIP39_SHARE], "") |
| 162 | + ) |
| 163 | + expect(bytesToHex(recovered)).toBe(MASTER_SECRET_HEX) |
| 164 | + }) |
| 165 | + |
| 166 | + test("the nostr signing key derives from the master secret via plain BIP-32", () => { |
| 167 | + const root = HDKey.fromMasterSeed(hexToBytes(MASTER_SECRET_HEX)) |
| 168 | + const privateKey = root.derive(NOSTR_VECTOR.path).privateKey |
| 169 | + expect(privateKey && bytesToHex(privateKey)).toBe( |
| 170 | + NOSTR_VECTOR.privateKeyHex |
| 171 | + ) |
| 172 | + }) |
| 173 | + |
| 174 | + test.each(Object.entries(BIP85_VECTORS))( |
| 175 | + "path %s yields its pinned BIP-85 entropy and mnemonic", |
| 176 | + (_name, vector) => { |
| 177 | + expect(deriveEntropy(vector.path, vector.entropyBytes)).toBe( |
| 178 | + vector.entropyHex |
| 179 | + ) |
| 180 | + if (vector.mnemonic !== undefined) { |
| 181 | + expect(entropyToMnemonic(hexToBytes(vector.entropyHex), wordlist)).toBe( |
| 182 | + vector.mnemonic |
| 183 | + ) |
| 184 | + } |
| 185 | + } |
| 186 | + ) |
| 187 | +}) |
| 188 | + |
| 189 | +// ── Payky-specific binding: the public key-derivation API must land on the |
| 190 | +// shared vectors above. Linky's copy binds its @linky/core identity API here. |
| 191 | + |
| 192 | +describe("Payky key-derivation API matches the cross-app vectors", () => { |
| 193 | + const masterKey = MasterKey(MASTER_SECRET_HEX) |
| 194 | + |
| 195 | + test("encodes the master key as the pinned SLIP-39 share", async () => { |
| 196 | + expect(await masterKeyToMnemonic(masterKey)).toBe(SLIP39_SHARE) |
| 197 | + }) |
| 198 | + |
| 199 | + test("recovers the master key from the pinned SLIP-39 share", async () => { |
| 200 | + expect(await mnemonicToMasterKey(RecoveryMnemonic(SLIP39_SHARE))).toBe( |
| 201 | + MASTER_SECRET_HEX |
| 202 | + ) |
| 203 | + }) |
| 204 | + |
| 205 | + test("derives the Evolu owner secret from its pinned vector", () => { |
| 206 | + expect(bytesToHex(deriveEvoluOwnerSecret(masterKey))).toBe( |
| 207 | + BIP85_VECTORS.paykyEvoluOwner?.entropyHex |
| 208 | + ) |
| 209 | + }) |
| 210 | + |
| 211 | + test("derives the Spark wallet secret and mnemonic from their pinned vectors", () => { |
| 212 | + const sparkSecret = deriveDefaultSparkWalletSecret(masterKey) |
| 213 | + expect(sparkSecret).toBe(BIP85_VECTORS.paykySparkWallet?.entropyHex) |
| 214 | + expect(sparkSecretToMnemonic(sparkSecret)).toBe( |
| 215 | + BIP85_VECTORS.paykySparkWallet?.mnemonic |
| 216 | + ) |
| 217 | + }) |
| 218 | +}) |
0 commit comments