Skip to content

Commit bcd7dfa

Browse files
committed
feat(account): derive recovery keys from master secret
1 parent 8374bd5 commit bcd7dfa

41 files changed

Lines changed: 686 additions & 382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,49 @@ Domain modules generally use this structure:
164164
- `*-utils.ts` for pure helpers.
165165
- `*.test.ts` beside the module it covers.
166166

167+
## Account Seed and Key Derivation
168+
169+
Each device account has a 128-bit master secret `S` (the `MasterKey`). For a
170+
path `P`, derive a child key and independent 32-byte entropy:
171+
172+
```text
173+
R = BIP32.MasterKey(S)
174+
K = BIP32.Derive(R, P)
175+
E = HMAC-SHA512(
176+
key = "bip-entropy-from-k",
177+
message = K.privateKey
178+
)[0:32]
179+
```
180+
181+
Payky paths follow [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki)
182+
with the BIP-39 application: `m/83696968'/39'/{language}'/{words}'/{index}'`,
183+
language `0'` = English. The word count encodes how much of `E` the consumer
184+
uses (24 words = 32 bytes, 12 words = 16 bytes). The paths must never change
185+
after accounts exist.
186+
187+
| Consumer | Path `P` | Result |
188+
| --- | --- | --- |
189+
| Evolu master owner | `m/83696968'/39'/0'/24'/0'` | `E` as the 32-byte Evolu owner secret |
190+
| Default Spark wallet | `m/83696968'/39'/0'/12'/0'` | `E[0:16]` as the 16-byte Spark wallet secret |
191+
| Nostr profile `i` | `m/44'/1237'/i'/0/0` | `K.privateKey` as the Nostr private key |
192+
193+
Nostr profile `0` follows NIP-06 and deliberately uses `K.privateKey` directly;
194+
using `E` would break NIP-06 compatibility. The Spark wallet secret is stored
195+
as hex and used as BIP-39 entropy: wallet initialization and the settings UI
196+
encode it as a 12-word mnemonic (never the raw secret), so the wallet can also
197+
be restored in any BIP-39-compatible Spark client.
198+
199+
`S` itself is backed up as a single [SLIP-39](https://github.com/satoshilabs/slips/blob/master/slip-0039.md)
200+
20-word recovery mnemonic (`src/core/modules/shared/key-derivation.ts`, via
201+
the `slip39-ts` library), encoded as one group with a 1-of-1 threshold — there
202+
is currently no multi-share Shamir splitting, so the phrase is the sole backup
203+
of `S` and must be treated with the same care as a BIP-39 seed phrase. SLIP-39
204+
mnemonics use their own wordlist and checksum and are not interchangeable with
205+
BIP-39 mnemonics. The mnemonic's identifier is derived deterministically (see
206+
table above) rather than randomized, so encoding the same `S` always produces
207+
the same recovery phrase. 256-bit master keys and their 33-word recovery
208+
mnemonics are not supported.
209+
167210
## CLI
168211

169212
The CLI reads `.env` files automatically. Environment variables are validated at

bin/cli-accounts.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import {
1414
} from "../src/core/modules/account/account-actions"
1515
import { AccountId } from "../src/core/modules/account/account-types"
1616
import { DeviceId } from "../src/core/modules/device/device-types"
17+
import {
18+
createMasterKey,
19+
deriveDefaultSparkWalletSecret,
20+
SparkSecretSchema,
21+
sparkSecretToMnemonic,
22+
} from "../src/core/modules/shared/key-derivation"
1723
import {
1824
AccountKindSchema,
1925
FiatCurrencySchema,
@@ -42,7 +48,7 @@ const accountsWithDetailsQuery = createQuery((db) =>
4248
evoluJsonObjectFrom(
4349
eb
4450
.selectFrom("accountSpark")
45-
.select(["accountSpark.mnemonic"])
51+
.select(["accountSpark.secret"])
4652
.whereRef("accountSpark.id", "=", "account.id")
4753
).as("spark"),
4854
evoluJsonObjectFrom(
@@ -77,7 +83,7 @@ const accountWithDetailsByIdQuery = (id: AccountId) =>
7783
evoluJsonObjectFrom(
7884
eb
7985
.selectFrom("accountSpark")
80-
.select(["accountSpark.mnemonic"])
86+
.select(["accountSpark.secret"])
8187
.whereRef("accountSpark.id", "=", "account.id")
8288
).as("spark"),
8389
evoluJsonObjectFrom(
@@ -159,8 +165,8 @@ export const registerAccountsCommand =
159165
currency: FiatCurrencySchema.optional().describe(
160166
"c;Currency for IBAN or cash register accounts"
161167
),
162-
mnemonic: NonEmptyString255Schema.optional().describe(
163-
"m;Spark wallet mnemonic"
168+
secret: SparkSecretSchema.optional().describe(
169+
"s;Spark wallet secret as 16-byte hex"
164170
),
165171
},
166172
async action(_, options) {
@@ -194,16 +200,16 @@ export const registerAccountsCommand =
194200
}
195201

196202
if (options.kind === "spark") {
197-
if (options.mnemonic === undefined) {
198-
printInvalidAccountInput("Spark account requires --mnemonic.")
203+
if (options.secret === undefined) {
204+
printInvalidAccountInput("Spark account requires --secret.")
199205
return
200206
}
201207

202208
const id = await run.orThrow(
203209
createAccount({
204210
...root,
205211
spark: {
206-
mnemonic: options.mnemonic,
212+
secret: options.secret,
207213
},
208214
})
209215
)
@@ -253,27 +259,21 @@ export const registerAccountsCommand =
253259
},
254260
})
255261

256-
const { wallet, mnemonic } = await SparkWallet.initialize({
262+
const secret = deriveDefaultSparkWalletSecret(createMasterKey())
263+
const { wallet } = await SparkWallet.initialize({
264+
mnemonicOrSeed: sparkSecretToMnemonic(secret),
257265
options: {
258266
network,
259267
},
260268
})
261269

262270
try {
263-
if (mnemonic === undefined) {
264-
printInvalidAccountInput(
265-
"Spark wallet did not return a mnemonic."
266-
)
267-
return
268-
}
269-
270-
const accountMnemonic = NonEmptyString255Schema.parse(mnemonic)
271271
const id = await run.orThrow(
272272
createAccount({
273273
deviceId: options.deviceId ?? null,
274274
name: options.name,
275275
spark: {
276-
mnemonic: accountMnemonic,
276+
secret,
277277
},
278278
})
279279
)
@@ -283,7 +283,8 @@ export const registerAccountsCommand =
283283
id,
284284
name: options.name,
285285
network,
286-
mnemonic,
286+
secret,
287+
mnemonic: sparkSecretToMnemonic(secret),
287288
})}`
288289
)
289290
} finally {
@@ -309,8 +310,8 @@ export const registerAccountsCommand =
309310
currency: FiatCurrencySchema.optional().describe(
310311
"c;Currency for IBAN or cash register accounts"
311312
),
312-
mnemonic: NonEmptyString255Schema.optional().describe(
313-
"m;Spark wallet mnemonic"
313+
secret: SparkSecretSchema.optional().describe(
314+
"s;Spark wallet secret as 16-byte hex"
314315
),
315316
},
316317
async action(_, options) {
@@ -339,7 +340,7 @@ export const registerAccountsCommand =
339340
deviceId: options.deviceId,
340341
name: options.name,
341342
spark: {
342-
mnemonic: options.mnemonic,
343+
secret: options.secret,
343344
},
344345
})
345346
)

bun.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"@evolu/web": "3.0.0-next.1",
4343
"@faker-js/faker": "10.5.0",
4444
"@fontsource-variable/geist": "5.2.9",
45+
"@noble/hashes": "2.0.1",
46+
"@scure/bip32": "1.7.0",
4547
"@scure/bip39": "2.2.0",
4648
"@scure/btc-signer": "2.2.0",
4749
"@sentry/react": "10.63.0",
@@ -68,6 +70,7 @@
6870
"react": "19.2.7",
6971
"react-dom": "19.2.7",
7072
"shadcn": "4.13.0",
73+
"slip39-ts": "0.1.13",
7174
"sonner": "2.0.7",
7275
"tailwind-merge": "3.6.0",
7376
"tailwindcss": "4.3.2",

src/atoms/account.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { UAParser } from "ua-parser-js"
55
import { deviceEvoluAtom } from "@/atoms/device-evolu"
66
import { evoluCounterAtom } from "@/atoms/evolu-counter.ts"
77
import {
8-
createAccountMnemonic,
8+
createAccountMasterKey,
99
insertAccount,
1010
loadActiveAccountRow,
1111
} from "@/core/evolu/device-account.ts"
1212
import type { DeviceId } from "@/core/modules/device/device-types.ts"
13+
import { masterKeyToMnemonic } from "@/core/modules/shared/key-derivation.ts"
1314
import { NonEmptyString255 } from "@/core/modules/shared/schema.ts"
1415

1516
const getDeviceId = () => {
@@ -49,7 +50,9 @@ const activeAccountRowAtom = atom(async (get) => {
4950
const deviceEvolu = await get(deviceEvoluAtom)
5051
const activeAccountRow = await loadActiveAccountRow(deviceEvolu)
5152

52-
return activeAccountRow ?? insertAccount(deviceEvolu, createAccountMnemonic())
53+
return (
54+
activeAccountRow ?? insertAccount(deviceEvolu, createAccountMasterKey())
55+
)
5356
})
5457

5558
export const accountAtom = atom(async (get) => {
@@ -72,9 +75,20 @@ export const accountAtom = atom(async (get) => {
7275

7376
return {
7477
id: row.id,
75-
mnemonic: row.mnemonic,
78+
masterKey: row.masterKey,
7679
name: row.name,
7780
transports: row.transports,
7881
device,
7982
}
8083
})
84+
85+
/**
86+
* Split out from `accountAtom` because deriving the SLIP-39 recovery phrase
87+
* runs a costly PBKDF2-based encoding — only the screens that actually
88+
* display it should pay for it, not every consumer of the account (Evolu
89+
* bootstrap, `useAppRun`, ...).
90+
*/
91+
export const recoveryMnemonicAtom = atom(async (get) => {
92+
const account = await get(accountAtom)
93+
return masterKeyToMnemonic(account.masterKey)
94+
})

src/atoms/evolu.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const evoluAtom = atom(async (get) => {
1111
const run = get(runAtom)
1212
const evolu = await run.orThrow(
1313
createAppEvolu({
14-
mnemonic: account.mnemonic,
14+
masterKey: account.masterKey,
1515
transports: [],
1616
})
1717
)
@@ -25,11 +25,5 @@ export const evoluAtom = atom(async (get) => {
2525
evolu.useOwner(evolu.appOwner, account.transports)
2626
: undefined
2727

28-
const appOwner = evolu.appOwner
29-
if (appOwner.mnemonic === null || appOwner.mnemonic === undefined)
30-
throw new Error(
31-
"App owner mnemonic is not set. Please create a new account."
32-
)
33-
3428
return evolu
3529
})

src/components/payment-detail.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type KyselyNotNull, sqliteTrue } from "@evolu/common"
22
import { Link } from "@tanstack/react-router"
33
import { ReceiptIcon } from "lucide-react"
4-
import { useMemo } from "react"
4+
import { type ReactNode, useMemo } from "react"
55
import { Badge } from "@/components/ui/badge.tsx"
66
import { Button } from "@/components/ui/button.tsx"
77
import {
@@ -464,17 +464,36 @@ function PaymentDetailOptionalRow({
464464
return <PaymentDetailRow label={label} value={value} />
465465
}
466466

467-
function PaymentDetailRow({
467+
export function PaymentDetailRow({
468468
label,
469469
value,
470+
stacked,
471+
emphasize,
472+
children,
470473
}: {
471474
readonly label: string
472-
readonly value: string
475+
readonly value?: string
476+
readonly stacked?: boolean
477+
readonly emphasize?: boolean
478+
readonly children?: ReactNode
473479
}) {
474480
return (
475-
<div className="flex items-start justify-between gap-4 text-sm">
481+
<div
482+
className={
483+
stacked
484+
? "flex flex-col gap-1 text-sm"
485+
: "flex items-start justify-between gap-4 text-sm"
486+
}
487+
>
476488
<span className="text-muted-foreground">{label}</span>
477-
<span className="max-w-56 break-all text-right font-medium">{value}</span>
489+
<span
490+
className={cn(
491+
stacked ? undefined : "max-w-56 break-all text-right",
492+
emphasize ? "font-semibold" : "font-medium"
493+
)}
494+
>
495+
{children ?? value}
496+
</span>
478497
</div>
479498
)
480499
}

0 commit comments

Comments
 (0)