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,175 @@
1
+ // `livybolt create` — two-phase, accountless survey creation.
2
+ //
3
+ // Phase 1: livybolt create <survey.json>
4
+ // 1. Derive THIS survey's key PAIR from the agent seed at the next account
5
+ // index (deterministic; recoverable from the seed alone): the Decrypt
6
+ // Key (survey hierarchy) and the View Key (view hierarchy, same index —
7
+ // the dashboard's read recipient).
8
+ // 2. POST the survey body (with encryption_pubkey + view_pubkey) → capture
9
+ // the 402 L402 challenge (macaroon + invoice + sats breakdown).
10
+ // 3. Persist everything to a pending file (0600), keyed by a short handle.
11
+ // 4. Print the invoice + handle. The agent pays the invoice out of band.
12
+ //
13
+ // Phase 2: livybolt create --resume <handle> --preimage <hex>
14
+ // 5. Re-POST the body with L402 macaroon:preimage → mint the survey.
15
+ // 6. Seal {surveyId, surveyIndex} into the local vault.
16
+ // 7. Delete the pending file. Print the public URL.
17
+ //
18
+ // No private key ever leaves the machine; the server only ever sees the two
19
+ // public keys and stores ciphertext. There is no owner token — ownership is
20
+ // proven per-request via NIP-98 signed by a derived key.
21
+
22
+ import { randomBytes } from 'node:crypto'
23
+ import { readFileSync } from 'node:fs'
24
+ import { sealVaultEntry } from '../src/lib/crypto/token-vault.js'
25
+ import { requestCreate, resumeCreate, DEFAULT_HOST } from './livybolt-api.mjs'
26
+ import { readStore } from './agent-identity-store.mjs'
27
+ import { appendVaultEnvelope } from './token-vault-store.mjs'
28
+ import { writePending, readPending, deletePending } from './pending-store.mjs'
29
+ import { deriveSurveyKeysForIndex } from './seed-unlock.mjs'
30
+ import { peekNextIndex, setNextIndex } from './survey-index-store.mjs'
31
+ import { resolveIdentityPassword } from './cli-prompt.mjs'
32
+
33
+ function fail(msg) {
34
+ console.error(`error: ${msg}`)
35
+ process.exit(1)
36
+ }
37
+
38
+ function parseFlags(argv) {
39
+ const flags = { _: [] }
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i]
42
+ if (a === '--resume') flags.resume = argv[++i]
43
+ else if (a === '--preimage') flags.preimage = argv[++i]
44
+ else if (a === '--host') flags.host = argv[++i]
45
+ else if (a === '--password') flags.password = argv[++i]
46
+ else if (a.startsWith('--')) fail(`unknown argument: ${a}`)
47
+ else flags._.push(a)
48
+ }
49
+ return flags
50
+ }
51
+
52
+ // Resolve the identity password to unlock the seed for derivation. Only invoked
53
+ // for an encrypted store (a plaintext store needs no password).
54
+ const resolvePassword = (flags) => resolveIdentityPassword(flags, fail)
55
+
56
+ // The identity pubkey is the lock the vault entry is sealed to. It is a PUBLIC
57
+ // field — reading it never needs the identity password.
58
+ function identityPubkeyHex() {
59
+ const store = readStore()
60
+ if (!store) {
61
+ fail('no identity found. Run `livybolt id new` first (the vault seals to its pubkey).')
62
+ }
63
+ if (!store.pubkey) fail('identity store is missing pubkey; re-run `livybolt id`.')
64
+ return store.pubkey
65
+ }
66
+
67
+ async function phaseCreate(flags) {
68
+ const file = flags._[0]
69
+ if (!file) fail('usage: livybolt create <survey.json>')
70
+
71
+ let body
72
+ try {
73
+ body = JSON.parse(readFileSync(file, 'utf8'))
74
+ } catch (err) {
75
+ fail(`could not read survey JSON from ${file}: ${err.message}`)
76
+ }
77
+
78
+ // Derive this survey's key PAIR from the agent seed at the next account
79
+ // index and wire both pubkeys into the body: the encryption target and the
80
+ // view (dashboard read) recipient. Livybolt is E2E-only: the server only ever
81
+ // sees the public keys + ciphertext. The vault stores the INDEX (not a
82
+ // secret) — both keys re-derive from the seed.
83
+ //
84
+ // PEEK (don't consume) the index: a failed phase 1 (network/server error)
85
+ // must not burn the index, or repeated failures accrue permanent gaps that can
86
+ // eventually exceed the recovery gap-limit. We commit the index only after the
87
+ // request succeeds. (A concurrent create racing the same index is backstopped
88
+ // by the server's unique pubkey constraints → 409 on the loser.)
89
+ const surveyIndex = peekNextIndex()
90
+ const keys = await deriveSurveyKeysForIndex(surveyIndex, {
91
+ resolvePassword: () => resolvePassword(flags),
92
+ })
93
+ body.encryption_pubkey = keys.encryption.pubkeyHex
94
+ body.view_pubkey = keys.view.pubkeyHex
95
+
96
+ const host = flags.host ?? DEFAULT_HOST
97
+ const challenge = await requestCreate({ host, body })
98
+
99
+ // Phase 1 succeeded — now consume the index so the next create gets a fresh key.
100
+ setNextIndex(surveyIndex + 1)
101
+
102
+ const handle = randomBytes(5).toString('hex')
103
+ writePending(handle, {
104
+ handle,
105
+ host,
106
+ body,
107
+ macaroon: challenge.macaroon,
108
+ invoice: challenge.invoice,
109
+ surveyIndex,
110
+ breakdown: challenge.breakdown,
111
+ createdAt: new Date().toISOString(),
112
+ })
113
+
114
+ console.log(`handle: ${handle}`)
115
+ if (challenge.breakdown) {
116
+ console.log(`total: ${challenge.breakdown.total_sats} sats`)
117
+ }
118
+ console.log(`invoice: ${challenge.invoice}`)
119
+ console.log('')
120
+ console.log('Pay the invoice, then run:')
121
+ console.log(` livybolt create --resume ${handle} --preimage <preimage-hex>`)
122
+ }
123
+
124
+ async function phaseResume(flags) {
125
+ const handle = flags.resume
126
+ const preimage = flags.preimage
127
+ if (!preimage) fail('usage: livybolt create --resume <handle> --preimage <hex>')
128
+
129
+ const pending = readPending(handle)
130
+ if (!pending) fail(`no pending survey for handle "${handle}" (already resumed, or wrong handle).`)
131
+
132
+ const created = await resumeCreate({
133
+ host: pending.host,
134
+ body: pending.body,
135
+ macaroon: pending.macaroon,
136
+ preimage,
137
+ })
138
+
139
+ // Roll the freshly minted survey into the vault, sealed to the identity
140
+ // pubkey (no password needed). The entry carries only id + index — every
141
+ // credential (decrypt key, view key, NIP-98 signer) re-derives from the seed.
142
+ const envelope = await sealVaultEntry(
143
+ {
144
+ surveyId: created.surveyId,
145
+ surveyIndex: pending.surveyIndex,
146
+ },
147
+ identityPubkeyHex()
148
+ )
149
+ appendVaultEnvelope(envelope)
150
+ deletePending(handle)
151
+
152
+ console.log('survey created ✔')
153
+ console.log(`surveyId: ${created.surveyId}`)
154
+ console.log(`public: ${created.publicUrl}`)
155
+ console.log('')
156
+ console.log('Sealed into your vault. Read responses with:')
157
+ console.log(` livybolt vault read --survey ${created.surveyId}`)
158
+ }
159
+
160
+ export async function main(argv) {
161
+ const flags = parseFlags(argv)
162
+ if (flags.resume) {
163
+ await phaseResume(flags)
164
+ } else {
165
+ await phaseCreate(flags)
166
+ }
167
+ }
168
+
169
+ // Run when invoked directly (node scripts/survey-create.mjs …).
170
+ if (import.meta.url === `file://${process.argv[1]}`) {
171
+ main(process.argv.slice(2)).catch((err) => {
172
+ console.error(`error: ${err.message}`)
173
+ process.exit(1)
174
+ })
175
+ }
@@ -0,0 +1,88 @@
1
+ // Non-secret cache of the next NIP-06 account index to derive a survey key at.
2
+ //
3
+ // `livybolt create` derives survey n's key at account n (see src/lib/crypto/survey-key.ts);
4
+ // this counter remembers which index to use next so successive surveys get
5
+ // distinct, unlinkable keys. It holds NO secret — it is a plain high-water mark.
6
+ // Losing it is safe: `livybolt vault rebuild` (foundation U5) re-derives the surveys
7
+ // by gap-limit scanning, and the server rejects a duplicate encryption_pubkey, so
8
+ // a stale counter can never silently collide. Lives next to the identity store
9
+ // (~/.livybolt, override with LIVYBOLT_IDENTITY_HOME).
10
+
11
+ import { join } from 'node:path'
12
+ import { mkdirSync, writeFileSync, existsSync, chmodSync, readFileSync } from 'node:fs'
13
+ import { FIRST_SURVEY_ACCOUNT_INDEX } from '../src/lib/crypto/survey-key.js'
14
+ import { storeHome } from './agent-identity-store.mjs'
15
+
16
+ export class SurveyIndexError extends Error {
17
+ constructor(code, message) {
18
+ super(message ?? code)
19
+ this.name = 'SurveyIndexError'
20
+ this.code = code
21
+ }
22
+ }
23
+
24
+ function storePath() {
25
+ return join(storeHome(), 'survey-index.json')
26
+ }
27
+
28
+ /**
29
+ * Read the counter file. A MISSING file is the normal first-run state (start at
30
+ * 1). A present-but-unreadable file is CORRUPT — we must not silently treat it
31
+ * as missing and reset to 1, because that would re-derive an already-used survey
32
+ * key (a colliding encryption_pubkey). The caller decides how to react.
33
+ */
34
+ function readRaw() {
35
+ const path = storePath()
36
+ if (!existsSync(path)) return { status: 'missing' }
37
+ let parsed
38
+ try {
39
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
40
+ } catch {
41
+ return { status: 'corrupt' }
42
+ }
43
+ const next = parsed?.next
44
+ if (!Number.isInteger(next) || next < FIRST_SURVEY_ACCOUNT_INDEX) return { status: 'corrupt' }
45
+ return { status: 'ok', next }
46
+ }
47
+
48
+ /**
49
+ * The next index that would be allocated, without consuming it. Throws on a
50
+ * corrupt counter rather than silently resetting to 1 — `livybolt create` must fail
51
+ * loudly so it never re-derives a used key. `livybolt vault rebuild` repairs it.
52
+ */
53
+ export function peekNextIndex() {
54
+ const r = readRaw()
55
+ if (r.status === 'corrupt') {
56
+ throw new SurveyIndexError(
57
+ 'corrupt',
58
+ `survey-index counter at ${storePath()} is unreadable — refusing to reuse a key. ` +
59
+ 'Run `livybolt vault rebuild` to rebuild it from your seed.'
60
+ )
61
+ }
62
+ return r.status === 'ok' ? r.next : FIRST_SURVEY_ACCOUNT_INDEX
63
+ }
64
+
65
+ /** Consume and return the next index, persisting the bumped counter. */
66
+ export function allocateIndex() {
67
+ return writeNext(peekNextIndex() + 1) - 1
68
+ }
69
+
70
+ /**
71
+ * Set the counter so the next allocation is at least `n` — never lowers an
72
+ * existing higher counter. Used by `livybolt vault rebuild` after a recovery scan,
73
+ * so it must also REPAIR a missing or corrupt counter (it reads raw, not peek).
74
+ */
75
+ export function setNextIndex(n) {
76
+ const r = readRaw()
77
+ const current = r.status === 'ok' ? r.next : FIRST_SURVEY_ACCOUNT_INDEX
78
+ return writeNext(Math.max(n, current))
79
+ }
80
+
81
+ function writeNext(next) {
82
+ const home = storeHome()
83
+ mkdirSync(home, { recursive: true, mode: 0o700 })
84
+ const path = storePath()
85
+ writeFileSync(path, JSON.stringify({ next }, null, 2) + '\n', { mode: 0o600 })
86
+ chmodSync(path, 0o600)
87
+ return next
88
+ }
@@ -0,0 +1,52 @@
1
+ // Local at-rest store for the Token Vault (node-only). An array of sealed
2
+ // envelopes (see src/lib/crypto/token-vault.ts) next to the identity store.
3
+ // Default: ~/.livybolt/vault.json (shares LIVYBOLT_IDENTITY_HOME). 0600.
4
+
5
+ import { join } from 'node:path'
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from 'node:fs'
7
+ import { storeHome } from './agent-identity-store.mjs'
8
+
9
+ export class VaultStoreError extends Error {
10
+ constructor(code, message) {
11
+ super(message ?? code)
12
+ this.name = 'VaultStoreError'
13
+ this.code = code
14
+ }
15
+ }
16
+
17
+ export function vaultPath() {
18
+ return join(storeHome(), 'vault.json')
19
+ }
20
+
21
+ export function readVault() {
22
+ const path = vaultPath()
23
+ if (!existsSync(path)) return []
24
+ // A corrupt vault must NOT look like an empty one: returning [] (or letting a
25
+ // raw SyntaxError get swallowed upstream) would let appendVaultEnvelope
26
+ // overwrite it and `livybolt vault rebuild` re-add everything. Fail loud + typed.
27
+ let parsed
28
+ try {
29
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
30
+ } catch {
31
+ throw new VaultStoreError('corrupt', `vault at ${path} is not valid JSON — refusing to touch it`)
32
+ }
33
+ if (!Array.isArray(parsed)) {
34
+ throw new VaultStoreError('corrupt', `vault at ${path} is not an array of envelopes`)
35
+ }
36
+ return parsed
37
+ }
38
+
39
+ export function writeVault(envelopes) {
40
+ const home = storeHome()
41
+ mkdirSync(home, { recursive: true, mode: 0o700 })
42
+ const path = vaultPath()
43
+ writeFileSync(path, JSON.stringify(envelopes, null, 2) + '\n', { mode: 0o600 })
44
+ chmodSync(path, 0o600)
45
+ return path
46
+ }
47
+
48
+ export function appendVaultEnvelope(envelope) {
49
+ const list = readVault()
50
+ list.push(envelope)
51
+ return writeVault(list)
52
+ }
@@ -0,0 +1,94 @@
1
+ // Roll survey ownership into, and read it back out of, the local Token Vault.
2
+ //
3
+ // `add` appends a (surveyId, ownerToken, [surveyDecryptSecret]) entry sealed to
4
+ // the Agent Identity Key's PUBLIC key — it never needs the password.
5
+ // `list` opens the whole vault, which DOES need the identity unlocked.
6
+ //
7
+ // Demonstrates the core property: an agent can keep rolling surveys in all day
8
+ // without unlocking anything; the password is only needed to read/import later.
9
+ //
10
+ // Usage:
11
+ // node scripts/token-vault.mjs add --survey svy_1 --token lsc_abc [--secret <nsec-or-hex>]
12
+ // node scripts/token-vault.mjs list [--password "hunter2"]
13
+
14
+ import { AgentIdentityError } from '../src/lib/crypto/agent-identity.js'
15
+ import { sealVaultEntry, openVault, VaultError } from '../src/lib/crypto/token-vault.js'
16
+ import { readStore } from './agent-identity-store.mjs'
17
+ import { appendVaultEnvelope, readVault } from './token-vault-store.mjs'
18
+ import { identityFromRecord, SeedUnlockError } from './seed-unlock.mjs'
19
+ import { resolveIdentityPassword } from './cli-prompt.mjs'
20
+
21
+ function fail(msg) {
22
+ console.error(`error: ${msg}`)
23
+ process.exit(1)
24
+ }
25
+
26
+ function parseFlags(argv) {
27
+ const flags = {}
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i]
30
+ if (a === '--survey') flags.survey = argv[++i]
31
+ else if (a === '--token') flags.token = argv[++i]
32
+ else if (a === '--secret') flags.secret = argv[++i]
33
+ else if (a === '--password') flags.password = argv[++i]
34
+ else fail(`unknown argument: ${a}`)
35
+ }
36
+ return flags
37
+ }
38
+
39
+ function requireIdentity() {
40
+ const rec = readStore()
41
+ if (!rec) fail('no identity. Run: node scripts/agent-identity.mjs new')
42
+ return rec
43
+ }
44
+
45
+ async function cmdAdd(flags) {
46
+ if (!flags.survey || !flags.token) fail('add needs --survey <id> and --token <owner_token>')
47
+ const rec = requireIdentity()
48
+ // Append needs only the PUBLIC key — no unlock, no password.
49
+ const envelope = await sealVaultEntry(
50
+ { surveyId: flags.survey, ownerToken: flags.token, surveyDecryptSecret: flags.secret },
51
+ rec.pubkey
52
+ )
53
+ appendVaultEnvelope(envelope)
54
+ console.log(`added=${flags.survey}`)
55
+ }
56
+
57
+ async function cmdList(flags) {
58
+ const rec = requireIdentity()
59
+ // Reading the vault DOES need the identity private key, recovered from the seed.
60
+ const { privkey } = await identityFromRecord(rec, () => resolveIdentityPassword(flags, fail))
61
+
62
+ const entries = await openVault(readVault(), privkey)
63
+ for (const e of entries) {
64
+ console.log(
65
+ `entry=${e.surveyId}|${e.ownerToken ?? '-'}|secret:${e.surveyDecryptSecret ? 'yes' : 'no'}|index:${e.surveyIndex ?? '-'}`
66
+ )
67
+ }
68
+ console.log(`count=${entries.length}`)
69
+ }
70
+
71
+ const [command, ...rest] = process.argv.slice(2)
72
+ const flags = parseFlags(rest)
73
+
74
+ try {
75
+ switch (command) {
76
+ case 'add':
77
+ await cmdAdd(flags)
78
+ break
79
+ case 'list':
80
+ await cmdList(flags)
81
+ break
82
+ default:
83
+ fail(
84
+ `unknown command: ${command ?? '(none)'}\n` +
85
+ ' commands: add | list\n' +
86
+ ' e.g. node scripts/token-vault.mjs add --survey svy_1 --token lsc_abc'
87
+ )
88
+ }
89
+ } catch (err) {
90
+ if (err instanceof AgentIdentityError) fail(`${err.code}: ${err.message}`)
91
+ if (err instanceof SeedUnlockError) fail(`${err.code}: ${err.message}`)
92
+ if (err instanceof VaultError) fail(`${err.code}: ${err.message}`)
93
+ throw err
94
+ }
@@ -0,0 +1,148 @@
1
+ // `livybolt unlock` — two-phase, pay-per-response unlock of overage responses.
2
+ //
3
+ // Responses beyond the prepaid pool are stored `locked`. Unlocking pays an
4
+ // L402 invoice (count × unlock fee) to promote the N oldest locked responses
5
+ // to `unlocked` (FIFO), after which `livybolt vault read` returns them.
6
+ //
7
+ // Phase 1: livybolt unlock --survey <id> --count <n>
8
+ // 1. Open the vault → derive the survey key, sign a NIP-98 auth event
9
+ // (payload-bound to { count }).
10
+ // 2. POST the unlock request → capture the 402 L402 challenge.
11
+ // 3. Persist to a pending file (0600), keyed by a short handle.
12
+ // (No key material is persisted — only the L402 challenge.)
13
+ // 4. Print the invoice + handle. Pay it out of band.
14
+ //
15
+ // Phase 2: livybolt unlock --resume <handle> --preimage <hex>
16
+ // 5. Re-open the vault and sign a FRESH NIP-98 event (the phase-1
17
+ // signature expires within NIP-98's ~60s window while the invoice is
18
+ // being paid), then re-POST with the L402 credential in
19
+ // X-Unlock-Authorization → promote the locked rows.
20
+
21
+ import { randomBytes } from 'node:crypto'
22
+ import { requestUnlock, resumeUnlock, DEFAULT_HOST } from './livybolt-api.mjs'
23
+ import { writePending, readPending, deletePending } from './pending-store.mjs'
24
+ import { openVaultEntry } from './vault-open.mjs'
25
+ import { signNip98Token } from '../src/lib/nip98.js'
26
+
27
+ function fail(msg) {
28
+ console.error(`error: ${msg}`)
29
+ process.exit(1)
30
+ }
31
+
32
+ function parseFlags(argv) {
33
+ const flags = {}
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i]
36
+ if (a === '--survey') flags.survey = argv[++i]
37
+ else if (a === '--count') flags.count = parseInt(argv[++i], 10)
38
+ else if (a === '--resume') flags.resume = argv[++i]
39
+ else if (a === '--preimage') flags.preimage = argv[++i]
40
+ else if (a === '--host') flags.host = argv[++i]
41
+ else if (a === '--password') flags.password = argv[++i]
42
+ else fail(`unknown argument: ${a}`)
43
+ }
44
+ return flags
45
+ }
46
+
47
+ async function phaseRequest(flags) {
48
+ const count = flags.count
49
+ if (!Number.isInteger(count) || count < 1) {
50
+ fail('usage: livybolt unlock --survey <id> --count <n> (n ≥ 1)')
51
+ }
52
+
53
+ const { entry, surveyPrivkey } = await openVaultEntry(flags.survey, flags)
54
+ if (!surveyPrivkey) {
55
+ fail(
56
+ `vault entry for ${entry.surveyId} has no derivable survey key — ` +
57
+ 'unlock authenticates with NIP-98 and needs it. Run: livybolt vault rebuild'
58
+ )
59
+ }
60
+ const host = flags.host ?? DEFAULT_HOST
61
+ const unlockUrl = `${host}/api/surveys/${encodeURIComponent(entry.surveyId)}/unlock`
62
+
63
+ const challenge = await requestUnlock({
64
+ host,
65
+ surveyId: entry.surveyId,
66
+ authorization: await signNip98Token(unlockUrl, 'POST', surveyPrivkey, { count }),
67
+ count,
68
+ })
69
+
70
+ const handle = randomBytes(5).toString('hex')
71
+ writePending(handle, {
72
+ handle,
73
+ kind: 'unlock',
74
+ host,
75
+ surveyId: entry.surveyId,
76
+ count,
77
+ macaroon: challenge.macaroon,
78
+ invoice: challenge.invoice,
79
+ breakdown: challenge.breakdown,
80
+ createdAt: new Date().toISOString(),
81
+ })
82
+
83
+ console.log(`handle: ${handle}`)
84
+ if (challenge.breakdown) {
85
+ console.log(`unlock: ${challenge.breakdown.unlock_count} responses`)
86
+ console.log(`total: ${challenge.breakdown.total_sats} sats`)
87
+ }
88
+ console.log(`invoice: ${challenge.invoice}`)
89
+ console.log('')
90
+ console.log('Pay the invoice, then run:')
91
+ console.log(` livybolt unlock --resume ${handle} --preimage <preimage-hex>`)
92
+ }
93
+
94
+ async function phaseResume(flags) {
95
+ const preimage = flags.preimage
96
+ if (!preimage) fail('usage: livybolt unlock --resume <handle> --preimage <hex>')
97
+
98
+ const pending = readPending(flags.resume)
99
+ if (!pending) fail(`no pending unlock for handle "${flags.resume}" (already resumed, or wrong handle).`)
100
+ if (pending.kind !== 'unlock') {
101
+ fail(`handle "${flags.resume}" is a ${pending.kind ?? 'create'} pending, not an unlock.`)
102
+ }
103
+
104
+ // Fresh NIP-98 signature: the pending file carries no key material, so
105
+ // resume re-opens the vault (prompting for the identity password again).
106
+ const { entry, surveyPrivkey } = await openVaultEntry(pending.surveyId, flags)
107
+ if (!surveyPrivkey) {
108
+ fail(
109
+ `vault entry for ${entry.surveyId} has no derivable survey key — ` +
110
+ 'unlock authenticates with NIP-98 and needs it. Run: livybolt vault rebuild'
111
+ )
112
+ }
113
+ const unlockUrl = `${pending.host}/api/surveys/${encodeURIComponent(pending.surveyId)}/unlock`
114
+
115
+ const result = await resumeUnlock({
116
+ host: pending.host,
117
+ surveyId: pending.surveyId,
118
+ authorization: await signNip98Token(unlockUrl, 'POST', surveyPrivkey, {
119
+ count: pending.count,
120
+ }),
121
+ count: pending.count,
122
+ macaroon: pending.macaroon,
123
+ preimage,
124
+ })
125
+ deletePending(flags.resume)
126
+
127
+ if (result.unlocked === 0) {
128
+ console.log('no locked responses to unlock.')
129
+ return
130
+ }
131
+ console.log(`unlocked ${result.unlocked} response${result.unlocked === 1 ? '' : 's'} ✔`)
132
+ console.log('')
133
+ console.log('Read them with:')
134
+ console.log(` livybolt vault read --survey ${pending.surveyId}`)
135
+ }
136
+
137
+ export async function main(argv) {
138
+ const flags = parseFlags(argv)
139
+ if (flags.resume) await phaseResume(flags)
140
+ else await phaseRequest(flags)
141
+ }
142
+
143
+ if (import.meta.url === `file://${process.argv[1]}`) {
144
+ main(process.argv.slice(2)).catch((err) => {
145
+ console.error(`error: ${err.message}`)
146
+ process.exit(1)
147
+ })
148
+ }
@@ -0,0 +1,38 @@
1
+ // Decrypt a survey's responses locally through the VaultDecryptor seam.
2
+ //
3
+ // The server is content-blind: encrypted responses come back as opaque
4
+ // envelopes. This opens them with the per-survey Survey Decrypt Key via the
5
+ // seam in src/lib/crypto/envelope.ts. The raw-nsec backend is wired here; the
6
+ // VaultDecryptor interface keeps alternative key backends pluggable without
7
+ // touching this code's call shape.
8
+
9
+ import { openEnvelopeWith, rawNsecDecryptor, isEnvelope } from '../src/lib/crypto/envelope.js'
10
+ import { parseRecipientSecret } from '../src/lib/crypto/recipient-key.js'
11
+
12
+ /**
13
+ * Build the raw-nsec VaultDecryptor for a survey's decrypt secret (nsec or
14
+ * 64-char hex). Returns an object satisfying the VaultDecryptor seam.
15
+ */
16
+ export function rawNsecVaultDecryptor(secret) {
17
+ const { privkey } = parseRecipientSecret(secret)
18
+ return rawNsecDecryptor(privkey)
19
+ }
20
+
21
+ /**
22
+ * Decrypt the `responses` array from fetchResponses(). Each row whose `data` is
23
+ * an envelope is opened through `decryptor`; plaintext rows pass through. The
24
+ * decryptor is the seam — pass rawNsecVaultDecryptor(secret) for the local key
25
+ * backend, or any object implementing { pubkey, nip44Decrypt }.
26
+ */
27
+ export async function decryptResponses(responses, decryptor) {
28
+ const out = []
29
+ for (const r of responses ?? []) {
30
+ if (r.encrypted && isEnvelope(r.data)) {
31
+ const json = await openEnvelopeWith(r.data, decryptor)
32
+ out.push({ ...r, data: JSON.parse(json), decrypted: true })
33
+ } else {
34
+ out.push({ ...r, decrypted: false })
35
+ }
36
+ }
37
+ return out
38
+ }
@@ -0,0 +1,44 @@
1
+ // Shared helper: unlock the local identity and pull a survey's vault entry
2
+ // plus its derived survey private key (for NIP-98 signing). Used by
3
+ // `livybolt unlock` and `livybolt balance`.
4
+ //
5
+ // The identity private key opens the vault; the entry never leaves the machine.
6
+ // Mirrors the unlock-the-identity logic in vault-read.mjs.
7
+
8
+ import { agentIdentityFromSeedWords } from '../src/lib/crypto/agent-identity.js'
9
+ import { openVault } from '../src/lib/crypto/token-vault.js'
10
+ import { readStore } from './agent-identity-store.mjs'
11
+ import { readVault } from './token-vault-store.mjs'
12
+ import { seedMnemonicFromRecord, surveyPrivkeyForEntry } from './seed-unlock.mjs'
13
+ import { resolveIdentityPassword as resolveIdentityPasswordPrompt } from './cli-prompt.mjs'
14
+
15
+ function fail(msg) {
16
+ console.error(`error: ${msg}`)
17
+ process.exit(1)
18
+ }
19
+
20
+ const resolveIdentityPassword = (flags) => resolveIdentityPasswordPrompt(flags, fail)
21
+
22
+ /**
23
+ * Open the vault with the local identity key and return the entry for
24
+ * `surveyId` plus its survey private key (for signing NIP-98 auth). The seed is
25
+ * unlocked once: the identity key opens the vault, and the survey key is derived
26
+ * from the same mnemonic (or parsed from a legacy stored secret). Exits with a
27
+ * clear error if there is no identity or the survey is not vaulted.
28
+ *
29
+ * @returns {Promise<{ entry: object, surveyPrivkey: Uint8Array | null }>}
30
+ */
31
+ export async function openVaultEntry(surveyId, flags = {}) {
32
+ if (!surveyId) fail('a --survey <id> is required.')
33
+
34
+ const rec = readStore()
35
+ if (!rec) fail('no identity. Run: livybolt id new')
36
+
37
+ const mnemonic = await seedMnemonicFromRecord(rec, () => resolveIdentityPassword(flags))
38
+ const { privkey } = agentIdentityFromSeedWords(mnemonic)
39
+
40
+ const entries = await openVault(readVault(), privkey)
41
+ const entry = entries.find((e) => e.surveyId === surveyId)
42
+ if (!entry) fail(`survey ${surveyId} not found in the vault.`)
43
+ return { entry, surveyPrivkey: surveyPrivkeyForEntry(entry, mnemonic) }
44
+ }