Skip to content

Commit 480d383

Browse files
authored
test(shared): pin cross-app seed derivation vectors shared with Linky (#73)
1 parent 26ab101 commit 480d383

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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

Comments
 (0)