livybolt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ // Pure HTTP client for the Livybolt API. Every function takes an injectable
2
+ // `fetchImpl` (defaults to global fetch) so the create/resume/read flows are
3
+ // unit-testable without a network. No filesystem, no argv — see livybolt.mjs and
4
+ // survey-create.mjs for the CLI wrappers that wire these to disk and stdout.
5
+
6
+ export const DEFAULT_HOST = process.env.LIVYBOLT_HOST ?? 'https://livybolt.com'
7
+
8
+ /** Abort a request that hangs — a stuck server must not freeze the CLI, above
9
+ * all the vault-rebuild scan loop, which fires one request per account index.
10
+ * AbortSignal.timeout uses an unref'd timer, so it never keeps the process up. */
11
+ const REQUEST_TIMEOUT_MS = 30_000
12
+ const withTimeout = (init = {}) => ({ ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
13
+
14
+ export class LivyApiError extends Error {
15
+ constructor(message, { status, body } = {}) {
16
+ super(message)
17
+ this.name = 'LivyApiError'
18
+ this.status = status
19
+ this.body = body
20
+ }
21
+ }
22
+
23
+ /** Parse `L402 macaroon="…", invoice="…"` into its parts. */
24
+ export function parseL402Challenge(header) {
25
+ if (!header) return null
26
+ const macaroon = header.match(/macaroon="([^"]+)"/)?.[1]
27
+ const invoice = header.match(/invoice="([^"]+)"/)?.[1]
28
+ if (!macaroon || !invoice) return null
29
+ return { macaroon, invoice }
30
+ }
31
+
32
+ async function readJson(res) {
33
+ try {
34
+ return await res.json()
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Phase 1 of two-phase create: POST the survey body with no payment and capture
42
+ * the L402 challenge (macaroon + invoice + sats breakdown). The caller persists
43
+ * these and pays the invoice out of band.
44
+ *
45
+ * Returns { macaroon, invoice, breakdown } on the expected 402, or throws.
46
+ */
47
+ export async function requestCreate({ host = DEFAULT_HOST, body, fetchImpl = fetch }) {
48
+ const res = await fetchImpl(`${host}/api/surveys`, withTimeout({
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(body),
52
+ }))
53
+
54
+ if (res.status === 402) {
55
+ const challenge = parseL402Challenge(res.headers.get('WWW-Authenticate'))
56
+ if (!challenge) {
57
+ throw new LivyApiError('402 without a parseable L402 challenge', { status: 402 })
58
+ }
59
+ const payload = await readJson(res)
60
+ return { ...challenge, breakdown: payload?.breakdown ?? null }
61
+ }
62
+
63
+ const payload = await readJson(res)
64
+ throw new LivyApiError(
65
+ `unexpected status ${res.status} from create (expected 402 challenge)`,
66
+ { status: res.status, body: payload }
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Phase 2 of two-phase create: re-POST the same body with the L402 credential
72
+ * (macaroon:preimage) to redeem the paid invoice and mint the survey.
73
+ *
74
+ * Returns the created-survey JSON ({ surveyId, slug, publicUrl, viewPubkey, … }).
75
+ */
76
+ export async function resumeCreate({
77
+ host = DEFAULT_HOST,
78
+ body,
79
+ macaroon,
80
+ preimage,
81
+ fetchImpl = fetch,
82
+ }) {
83
+ const res = await fetchImpl(`${host}/api/surveys`, withTimeout({
84
+ method: 'POST',
85
+ headers: {
86
+ 'Content-Type': 'application/json',
87
+ Authorization: `L402 ${macaroon}:${preimage}`,
88
+ },
89
+ body: JSON.stringify(body),
90
+ }))
91
+
92
+ const payload = await readJson(res)
93
+ if (res.status === 201) return payload
94
+ throw new LivyApiError(`create resume failed (status ${res.status})`, {
95
+ status: res.status,
96
+ body: payload,
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Phase 1 of two-phase unlock: POST the unlock request with a NIP-98
102
+ * `authorization` header (signed by the survey key, payload-bound to {count})
103
+ * but no L402 credential, and capture the L402 challenge (macaroon + invoice).
104
+ * NIP-98 authenticates ownership; the L402 credential is carried separately in
105
+ * `X-Unlock-Authorization` so it never shadows the auth header.
106
+ *
107
+ * Returns { macaroon, invoice, breakdown } on the expected 402, or throws.
108
+ */
109
+ export async function requestUnlock({
110
+ host = DEFAULT_HOST,
111
+ surveyId,
112
+ authorization,
113
+ count,
114
+ fetchImpl = fetch,
115
+ }) {
116
+ if (!authorization) {
117
+ throw new LivyApiError('unlock requires a NIP-98 authorization header (no owner token exists)')
118
+ }
119
+ const res = await fetchImpl(`${host}/api/surveys/${encodeURIComponent(surveyId)}/unlock`, withTimeout({
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json', Authorization: authorization },
122
+ body: JSON.stringify({ count }),
123
+ }))
124
+
125
+ if (res.status === 402) {
126
+ const challenge = parseL402Challenge(res.headers.get('WWW-Authenticate'))
127
+ if (!challenge) {
128
+ throw new LivyApiError('402 without a parseable L402 challenge', { status: 402 })
129
+ }
130
+ const payload = await readJson(res)
131
+ return {
132
+ ...challenge,
133
+ breakdown: payload
134
+ ? {
135
+ unlock_count: payload.unlock_count,
136
+ sats_per_unlock: payload.sats_per_unlock,
137
+ total_sats: payload.total_sats,
138
+ }
139
+ : null,
140
+ }
141
+ }
142
+
143
+ const payload = await readJson(res)
144
+ throw new LivyApiError(
145
+ `unexpected status ${res.status} from unlock (expected 402 challenge)`,
146
+ { status: res.status, body: payload }
147
+ )
148
+ }
149
+
150
+ /**
151
+ * Phase 2 of two-phase unlock: re-POST with a FRESH NIP-98 `authorization`
152
+ * (payment happens out of band, so the phase-1 signature has expired by resume
153
+ * time) plus the L402 credential in `X-Unlock-Authorization`. Promotes the N
154
+ * oldest locked responses to `unlocked` (FIFO).
155
+ *
156
+ * Returns { unlocked, ids } (or { unlocked: 0, message } when nothing is locked).
157
+ */
158
+ export async function resumeUnlock({
159
+ host = DEFAULT_HOST,
160
+ surveyId,
161
+ authorization,
162
+ count,
163
+ macaroon,
164
+ preimage,
165
+ fetchImpl = fetch,
166
+ }) {
167
+ if (!authorization) {
168
+ throw new LivyApiError('unlock requires a NIP-98 authorization header (no owner token exists)')
169
+ }
170
+ const res = await fetchImpl(`${host}/api/surveys/${encodeURIComponent(surveyId)}/unlock`, withTimeout({
171
+ method: 'POST',
172
+ headers: {
173
+ 'Content-Type': 'application/json',
174
+ Authorization: authorization,
175
+ 'X-Unlock-Authorization': `L402 ${macaroon}:${preimage}`,
176
+ },
177
+ body: JSON.stringify({ count }),
178
+ }))
179
+
180
+ const payload = await readJson(res)
181
+ if (res.status === 200) return payload
182
+ throw new LivyApiError(`unlock resume failed (status ${res.status})`, {
183
+ status: res.status,
184
+ body: payload,
185
+ })
186
+ }
187
+
188
+ /** Fetch a survey's prepaid-pool balance (NIP-98 authorization, or owner Bearer). */
189
+ export async function fetchBalance({
190
+ host = DEFAULT_HOST,
191
+ surveyId,
192
+ ownerToken,
193
+ authorization,
194
+ fetchImpl = fetch,
195
+ }) {
196
+ const res = await fetchImpl(
197
+ `${host}/api/surveys/${encodeURIComponent(surveyId)}/balance`,
198
+ withTimeout({ headers: { Authorization: authorization ?? `Bearer ${ownerToken}` } })
199
+ )
200
+
201
+ const payload = await readJson(res)
202
+ if (res.status === 200) return payload
203
+ throw new LivyApiError(`fetch balance failed (status ${res.status})`, {
204
+ status: res.status,
205
+ body: payload,
206
+ })
207
+ }
208
+
209
+ /** Fetch a survey's responses with the owner Bearer token. */
210
+ export async function fetchResponses({
211
+ host = DEFAULT_HOST,
212
+ surveyId,
213
+ ownerToken,
214
+ authorization,
215
+ since,
216
+ fetchImpl = fetch,
217
+ }) {
218
+ const url = new URL(`${host}/api/surveys/${encodeURIComponent(surveyId)}/responses`)
219
+ if (since) url.searchParams.set('since', since)
220
+
221
+ const res = await fetchImpl(url.toString(), withTimeout({
222
+ headers: { Authorization: authorization ?? `Bearer ${ownerToken}` },
223
+ }))
224
+
225
+ const payload = await readJson(res)
226
+ if (res.status === 200) return payload
227
+ throw new LivyApiError(`fetch responses failed (status ${res.status})`, {
228
+ status: res.status,
229
+ body: payload,
230
+ })
231
+ }
232
+
233
+ /**
234
+ * Resolve a survey from its (public) encryption_pubkey via the lookup endpoint
235
+ * (foundation U3). Returns the public descriptor on 200, null on 404, and throws
236
+ * on any other status — so a recovery scan aborts on a real error rather than
237
+ * mistaking it for a miss. Used by `livybolt vault rebuild`.
238
+ */
239
+ export async function lookupSurveyByPubkey({
240
+ host = DEFAULT_HOST,
241
+ encryptionPubkey,
242
+ fetchImpl = fetch,
243
+ }) {
244
+ const url = new URL(`${host}/api/surveys`)
245
+ url.searchParams.set('encryption_pubkey', encryptionPubkey)
246
+ const res = await fetchImpl(url.toString(), withTimeout())
247
+ if (res.status === 404) return null
248
+ if (res.status === 200) return readJson(res)
249
+ throw new LivyApiError(`survey lookup failed (status ${res.status})`, {
250
+ status: res.status,
251
+ body: await readJson(res),
252
+ })
253
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // Livybolt — accountless, self-custody, end-to-end-encrypted surveys over Lightning.
3
+ //
4
+ // A thin router over the focused subcommand scripts. Each subcommand is its own
5
+ // module (testable in isolation); this just dispatches argv and inherits stdio.
6
+ //
7
+ // livybolt id new | show | export | restore | unlock (agent-identity.mjs)
8
+ // livybolt create <survey.json> (survey-create.mjs)
9
+ // livybolt create --resume <handle> --preimage <hex> (survey-create.mjs)
10
+ // livybolt balance --survey <id> (balance.mjs)
11
+ // livybolt unlock --survey <id> --count <n> (unlock.mjs)
12
+ // livybolt unlock --resume <handle> --preimage <hex> (unlock.mjs)
13
+ // livybolt vault add | list (token-vault.mjs)
14
+ // livybolt vault read --survey <id> (vault-read.mjs)
15
+ // livybolt pair (pair.mjs)
16
+ //
17
+ // Host defaults to https://livybolt.com (override LIVYBOLT_HOST or --host).
18
+
19
+ import { spawnSync } from 'node:child_process'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url))
24
+
25
+ // The subcommands import `.ts` crypto modules directly, relying on Node's
26
+ // type-stripping. That emits two unconditional warnings to stderr that would
27
+ // otherwise clutter every command — silence just those, nothing else.
28
+ const QUIET = ['--disable-warning=ExperimentalWarning', '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON']
29
+ const run = (script, args) =>
30
+ spawnSync(process.execPath, [...QUIET, join(here, script), ...args], { stdio: 'inherit' })
31
+
32
+ function usage(code = 0) {
33
+ console.log(`livybolt — accountless surveys over Lightning
34
+
35
+ Commands:
36
+ livybolt id <new|show|export|restore|unlock> [flags] manage your self-custody key
37
+ livybolt create <survey.json> phase 1: get an invoice + handle
38
+ livybolt create --resume <handle> --preimage <hex> phase 2: mint after paying
39
+ livybolt balance --survey <id> [--json] prepaid-pool accounting
40
+ livybolt unlock --survey <id> --count <n> phase 1: invoice to unlock N overage
41
+ livybolt unlock --resume <handle> --preimage <hex> phase 2: unlock after paying
42
+ livybolt vault add --survey <id> --token <tok> roll a survey into the vault
43
+ livybolt vault list [--password <pw>] list vaulted surveys
44
+ livybolt vault read --survey <id> [--json] pull + decrypt responses
45
+ livybolt vault rebuild [--gap <n>] [--password <pw>] recover the vault from the seed
46
+ livybolt pair [--no-qr] pair a browser dashboard (view seed QR)
47
+
48
+ Host: $LIVYBOLT_HOST or --host (default https://livybolt.com)`)
49
+ process.exit(code)
50
+ }
51
+
52
+ const [command, ...rest] = process.argv.slice(2)
53
+
54
+ let result
55
+ switch (command) {
56
+ case 'id':
57
+ result = run('agent-identity.mjs', rest)
58
+ break
59
+ case 'create':
60
+ result = run('survey-create.mjs', rest)
61
+ break
62
+ case 'unlock':
63
+ result = run('unlock.mjs', rest)
64
+ break
65
+ case 'balance':
66
+ result = run('balance.mjs', rest)
67
+ break
68
+ case 'pair':
69
+ result = run('pair.mjs', rest)
70
+ break
71
+ case 'vault': {
72
+ const [sub, ...vrest] = rest
73
+ if (sub === 'read') result = run('vault-read.mjs', vrest)
74
+ else if (sub === 'rebuild') result = run('vault-rebuild.mjs', vrest)
75
+ else if (sub === 'add' || sub === 'list') result = run('token-vault.mjs', [sub, ...vrest])
76
+ else {
77
+ console.error(`error: unknown vault command: ${sub ?? '(none)'} (add | list | read | rebuild)`)
78
+ process.exit(1)
79
+ }
80
+ break
81
+ }
82
+ case 'help':
83
+ case '--help':
84
+ case '-h':
85
+ case undefined:
86
+ usage(0)
87
+ break
88
+ default:
89
+ console.error(`error: unknown command: ${command}`)
90
+ usage(1)
91
+ }
92
+
93
+ process.exit(result?.status ?? 0)
@@ -0,0 +1,116 @@
1
+ // `livybolt pair` — provision a browser dashboard with the VIEW seed.
2
+ //
3
+ // 1. Unlock the identity (master seed) locally.
4
+ // 2. Derive the view seed — one-way, read-only hierarchy (view-key.ts).
5
+ // 3. Choose a PAIRING password (distinct from the identity password: the
6
+ // browser must never learn a credential that opens anything but this blob).
7
+ // 4. Seal the view seed as a tagged NIP-49 blob ("LVYV" — cannot be imported
8
+ // as a master seed, and a master backup cannot be imported as this).
9
+ // 5. Render as a QR code + copyable string. The dashboard scans/pastes it and
10
+ // prompts for the pairing password.
11
+ //
12
+ // The blob is ciphertext only — the raw view seed never crosses a screen, a
13
+ // QR, or a clipboard. It is also a DURABLE, PORTABLE credential: not
14
+ // single-use, not expiring, not device-bound. It stays valid until view-seed
15
+ // rotation, so a user may store the string in a password manager and pair any
16
+ // future browser from it without this machine. Do not "harden" it with
17
+ // nonces, TTLs, or one-time codes — the password is the gate; rotation is the
18
+ // revocation.
19
+ //
20
+ // Master seed exposure: none. Only the derived view seed leaves this process,
21
+ // and only encrypted. A compromised blob + password yields read access only —
22
+ // rotatable, no identity, no creation authority.
23
+
24
+ import qrcode from 'qrcode-terminal'
25
+ import { sealViewSeed } from '../src/lib/crypto/seed-store.js'
26
+ import { loadViewSeed } from './seed-unlock.mjs'
27
+ import { readStore } from './agent-identity-store.mjs'
28
+ import { resolveIdentityPassword, promptHidden } from './cli-prompt.mjs'
29
+
30
+ function fail(msg) {
31
+ console.error(`error: ${msg}`)
32
+ process.exit(1)
33
+ }
34
+
35
+ function parseFlags(argv) {
36
+ const flags = { _: [] }
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i]
39
+ if (a === '--password') flags.password = argv[++i]
40
+ else if (a === '--pair-password') flags.pairPassword = argv[++i]
41
+ else if (a === '--no-qr') flags.noQr = true
42
+ else if (a.startsWith('--')) fail(`unknown argument: ${a}`)
43
+ else flags._.push(a)
44
+ }
45
+ return flags
46
+ }
47
+
48
+ /**
49
+ * Build the pairing blob: unlock → derive view seed → seal under the pairing
50
+ * password. Pure of terminal I/O so tests exercise the real path.
51
+ */
52
+ export async function buildPairBlob({ resolvePassword, pairPassword }) {
53
+ if (!pairPassword) throw new Error('a pairing password is required')
54
+ const viewSeed = await loadViewSeed({ resolvePassword })
55
+ return sealViewSeed(viewSeed, pairPassword)
56
+ }
57
+
58
+ /** Resolve the NEW pairing password: flag, else interactive double prompt. */
59
+ async function resolvePairPassword(flags) {
60
+ if (flags.pairPassword != null) return flags.pairPassword
61
+ if (!process.stdin.isTTY) {
62
+ fail('no pairing password. Pass --pair-password or run interactively.')
63
+ }
64
+ const first = await promptHidden('Choose a pairing password (the browser will ask for it once): ')
65
+ if (!first) fail('the pairing password must not be empty.')
66
+ const second = await promptHidden('Repeat the pairing password: ')
67
+ if (first !== second) fail('pairing passwords did not match.')
68
+ return first
69
+ }
70
+
71
+ export async function main(argv) {
72
+ const flags = parseFlags(argv)
73
+ if (!readStore()) {
74
+ fail('no identity found. Run `livybolt id new` first.')
75
+ }
76
+
77
+ // Track the identity password (when one is used) so we can refuse a pairing
78
+ // password that duplicates it — the browser must never receive a credential
79
+ // that also opens the local seed store (KTD-4).
80
+ let identityPassword
81
+ const resolvePassword = async () => {
82
+ identityPassword = await resolveIdentityPassword(flags, fail)
83
+ return identityPassword
84
+ }
85
+
86
+ // Unlock + derive first (identity prompt, when the store is encrypted),
87
+ // then choose the pairing password, then seal.
88
+ const viewSeed = await loadViewSeed({ resolvePassword })
89
+ const pairPassword = await resolvePairPassword(flags)
90
+ if (identityPassword !== undefined && pairPassword === identityPassword) {
91
+ fail('the pairing password must be different from your identity password.')
92
+ }
93
+ const blob = sealViewSeed(viewSeed, pairPassword)
94
+
95
+ console.log('Pairing blob (view seed, encrypted with your pairing password):')
96
+ console.log('')
97
+ console.log(blob)
98
+ console.log('')
99
+ if (!flags.noQr) {
100
+ // The QR encodes exactly the string above — one canonical encoding.
101
+ qrcode.generate(blob, { small: true })
102
+ console.log('')
103
+ }
104
+ console.log('On the dashboard: scan the QR (or paste the string), then enter')
105
+ console.log('the pairing password. The blob is reusable for future devices —')
106
+ console.log('you may keep it in a password manager. It grants READ access')
107
+ console.log('only, and view-seed rotation revokes it everywhere.')
108
+ }
109
+
110
+ // Run when invoked directly (node scripts/pair.mjs …).
111
+ if (import.meta.url === `file://${process.argv[1]}`) {
112
+ main(process.argv.slice(2)).catch((err) => {
113
+ console.error(`error: ${err.message}`)
114
+ process.exit(1)
115
+ })
116
+ }
@@ -0,0 +1,70 @@
1
+ // Local at-rest store for in-flight (created-but-unpaid) surveys. Between
2
+ // `livybolt create` (phase 1: POST → 402 challenge) and `livybolt create --resume`
3
+ // (phase 2: pay → redeem), the macaroon, invoice, the survey body, and the
4
+ // freshly minted Survey Decrypt Key secret must survive process exit. They live
5
+ // here, one file per handle, until resume succeeds and deletes the file.
6
+ //
7
+ // The Survey Decrypt Key secret is a real key at rest → dir 0700, files 0600.
8
+ // Default: ~/.livybolt/pending/<handle>.json (shares LIVYBOLT_IDENTITY_HOME).
9
+
10
+ import { join } from 'node:path'
11
+ import {
12
+ mkdirSync,
13
+ writeFileSync,
14
+ readFileSync,
15
+ existsSync,
16
+ chmodSync,
17
+ rmSync,
18
+ readdirSync,
19
+ } from 'node:fs'
20
+ import { storeHome } from './agent-identity-store.mjs'
21
+
22
+ export function pendingDir() {
23
+ return join(storeHome(), 'pending')
24
+ }
25
+
26
+ export function pendingPath(handle) {
27
+ return join(pendingDir(), `${handle}.json`)
28
+ }
29
+
30
+ export function writePending(handle, record) {
31
+ const dir = pendingDir()
32
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
33
+ const path = pendingPath(handle)
34
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 })
35
+ chmodSync(path, 0o600)
36
+ return path
37
+ }
38
+
39
+ export class PendingStoreError extends Error {
40
+ constructor(code, message) {
41
+ super(message ?? code)
42
+ this.name = 'PendingStoreError'
43
+ this.code = code
44
+ }
45
+ }
46
+
47
+ export function readPending(handle) {
48
+ const path = pendingPath(handle)
49
+ if (!existsSync(path)) return null
50
+ try {
51
+ return JSON.parse(readFileSync(path, 'utf8'))
52
+ } catch {
53
+ // A partially-written or corrupt handle file → fail loud and named, not a
54
+ // raw SyntaxError that hides which pending create is broken.
55
+ throw new PendingStoreError('corrupt', `pending survey "${handle}" at ${path} is not valid JSON`)
56
+ }
57
+ }
58
+
59
+ export function deletePending(handle) {
60
+ const path = pendingPath(handle)
61
+ if (existsSync(path)) rmSync(path)
62
+ }
63
+
64
+ export function listPending() {
65
+ const dir = pendingDir()
66
+ if (!existsSync(dir)) return []
67
+ return readdirSync(dir)
68
+ .filter((f) => f.endsWith('.json'))
69
+ .map((f) => f.slice(0, -'.json'.length))
70
+ }
@@ -0,0 +1,111 @@
1
+ // Unlock the agent's seed from the local identity store, and derive keys from it.
2
+ //
3
+ // The v2 store (see ./agent-identity-store.mjs) holds the SEED — plaintext words
4
+ // (`secretKind: seed-plaintext`) or a NIP-49 ncryptsec of the BIP-39 entropy
5
+ // (`secretKind: seed-entropy-nip49`). This module is the single seam that turns
6
+ // the stored seed back into:
7
+ // - the mnemonic (seedMnemonicFromRecord / loadSeedMnemonic)
8
+ // - the identity, account 0 (identityFromRecord — what the vault decryptors need)
9
+ // - a per-survey key, n>=1 (deriveSurveyKeyForIndex — the foundation-U2 seam)
10
+ //
11
+ // `resolvePassword` is an async fn the caller supplies; it is invoked ONLY when
12
+ // the store is encrypted, so a plaintext store never triggers a prompt.
13
+
14
+ import { agentIdentityFromSeedWords } from '../src/lib/crypto/agent-identity.js'
15
+ import { unlockSeed } from '../src/lib/crypto/seed-store.js'
16
+ import { deriveSurveyKeypair } from '../src/lib/crypto/survey-key.js'
17
+ import { deriveViewSeed, deriveViewKeypair } from '../src/lib/crypto/view-key.js'
18
+ import { parseRecipientSecret } from '../src/lib/crypto/recipient-key.js'
19
+ import { readStore, storePath } from './agent-identity-store.mjs'
20
+
21
+ export class SeedUnlockError extends Error {
22
+ constructor(code, message) {
23
+ super(message ?? code)
24
+ this.name = 'SeedUnlockError'
25
+ this.code = code
26
+ }
27
+ }
28
+
29
+ // Recover the seed mnemonic from a store record.
30
+ export async function seedMnemonicFromRecord(rec, resolvePassword) {
31
+ if (!rec) {
32
+ throw new SeedUnlockError(
33
+ 'no_identity',
34
+ `no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`
35
+ )
36
+ }
37
+ if (rec.secretKind === 'seed-plaintext') {
38
+ if (!rec.seedWords) {
39
+ throw new SeedUnlockError('corrupt_store', 'seed-plaintext store is missing its seedWords')
40
+ }
41
+ return rec.seedWords
42
+ }
43
+ if (rec.secretKind === 'seed-entropy-nip49') {
44
+ if (!rec.ncryptsec) {
45
+ throw new SeedUnlockError(
46
+ 'corrupt_store',
47
+ 'seed-entropy-nip49 store is missing its ncryptsec'
48
+ )
49
+ }
50
+ if (typeof resolvePassword !== 'function') {
51
+ throw new SeedUnlockError(
52
+ 'no_resolver',
53
+ 'a password resolver is required to open an encrypted seed store'
54
+ )
55
+ }
56
+ return unlockSeed(rec.ncryptsec, await resolvePassword())
57
+ }
58
+ throw new SeedUnlockError(
59
+ 'unsupported_store',
60
+ `unrecognized identity store (secretKind=${rec.secretKind ?? 'none'}) — likely an old (pre-v2) store; re-provision with \`livybolt id new\``
61
+ )
62
+ }
63
+
64
+ // The agent identity (account 0, including privkey) recovered from the stored seed.
65
+ // This is what the vault decryptors need: the vault entries are sealed to the
66
+ // identity pubkey, so opening them requires the identity privkey.
67
+ export async function identityFromRecord(rec, resolvePassword) {
68
+ return agentIdentityFromSeedWords(await seedMnemonicFromRecord(rec, resolvePassword))
69
+ }
70
+
71
+ // Read the store and recover the mnemonic.
72
+ export async function loadSeedMnemonic({ resolvePassword } = {}) {
73
+ return seedMnemonicFromRecord(readStore(), resolvePassword)
74
+ }
75
+
76
+ // Derive survey n's keypair from the stored seed (n >= 1). The seam foundation
77
+ // plan U2 (`livybolt create`) calls instead of generating a random key.
78
+ export async function deriveSurveyKeyForIndex(index, { resolvePassword } = {}) {
79
+ return deriveSurveyKeypair(await loadSeedMnemonic({ resolvePassword }), index)
80
+ }
81
+
82
+ // Derive BOTH of survey n's keypairs — encryption (survey hierarchy) and view
83
+ // (view hierarchy, same index) — from ONE unlock, so `livybolt create` registers
84
+ // the pair without prompting for the password twice.
85
+ export async function deriveSurveyKeysForIndex(index, { resolvePassword } = {}) {
86
+ const mnemonic = await loadSeedMnemonic({ resolvePassword })
87
+ return {
88
+ encryption: deriveSurveyKeypair(mnemonic, index),
89
+ view: deriveViewKeypair(deriveViewSeed(mnemonic), index),
90
+ }
91
+ }
92
+
93
+ // The view seed (a 12-word view mnemonic) recovered from the stored seed —
94
+ // what `livybolt pair` seals into the pairing blob.
95
+ export async function loadViewSeed({ resolvePassword } = {}) {
96
+ return deriveViewSeed(await loadSeedMnemonic({ resolvePassword }))
97
+ }
98
+
99
+ // The survey's private key for a vault entry, for signing NIP-98 read/unlock
100
+ // auth: derived from the seed (derived surveys carry `surveyIndex`) or parsed
101
+ // from the stored secret (legacy random-key surveys). Returns null when neither
102
+ // is present (the caller falls back to the legacy owner token).
103
+ export function surveyPrivkeyForEntry(entry, mnemonic) {
104
+ if (typeof entry.surveyIndex === 'number') {
105
+ return deriveSurveyKeypair(mnemonic, entry.surveyIndex).privkey
106
+ }
107
+ if (entry.surveyDecryptSecret) {
108
+ return parseRecipientSecret(entry.surveyDecryptSecret).privkey
109
+ }
110
+ return null
111
+ }