livybolt 0.1.0 → 0.2.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.
package/AGENTS.md ADDED
@@ -0,0 +1,88 @@
1
+ # livybolt — agent guide
2
+
3
+ You are probably an LLM agent running headless. This file is the complete
4
+ recipe for operating `livybolt` without a human at the keyboard, and the rules
5
+ that keep the user's keys safe while you do it.
6
+
7
+ ## The one-time identity setup (macOS)
8
+
9
+ ```bash
10
+ livybolt id new --keychain --json
11
+ ```
12
+
13
+ That generates a strong random password, parks it in the macOS login keychain
14
+ (service `livybolt-cli-identity`), and seals the identity seed encrypted at
15
+ `~/.livybolt/identity.json`. Every later command finds the password by itself —
16
+ you never handle one. On Linux/Windows, generate a password, keep it in the
17
+ platform's secret store, and pass it per-invocation with `--password-stdin` or
18
+ `LIVYBOLT_IDENTITY_PASSWORD`.
19
+
20
+ **Never use `--insecure-plaintext`.** It stores the seed in cleartext and there
21
+ is no situation where you need it — `--keychain` is equally headless.
22
+
23
+ **Never print seed words.** Your transcript persists forever. `id new` already
24
+ withholds them from piped output; do not work around that. The human claims
25
+ their backup later by running `livybolt id backup` in their own terminal.
26
+
27
+ **Tell the user about `seed_backup=pending`.** Until they run `livybolt id
28
+ backup`, losing this machine loses the identity. Relay that state in your
29
+ summary — it is in the output specifically so you will.
30
+
31
+ ## Password resolution (encrypted identities)
32
+
33
+ Tried in order, for every command that unlocks the seed:
34
+
35
+ 1. `--password <pw>` (avoid: lands in argv and your transcript)
36
+ 2. `--password-stdin` (pipe it in)
37
+ 3. `LIVYBOLT_IDENTITY_PASSWORD`
38
+ 4. macOS Keychain (service `livybolt-cli-identity`, override with
39
+ `LIVYBOLT_KEYCHAIN_SERVICE`)
40
+ 5. Interactive hidden prompt — TTY only; headless runs fail fast instead of
41
+ hanging.
42
+
43
+ ## The survey lifecycle
44
+
45
+ ```bash
46
+ # 1. Create (phase 1): returns a Lightning invoice + resume handle
47
+ livybolt create survey.json # -> handle + bolt11 invoice, total sats
48
+
49
+ # 2. Pay the invoice with whatever wallet you have (e.g. Alby CLI, NWC).
50
+ # Livybolt never touches wallet secrets — payment happens outside the CLI.
51
+
52
+ # 3. Create (phase 2): finalize with the payment preimage
53
+ livybolt create --resume <handle> --preimage <hex> # -> surveyId + public URL
54
+
55
+ # 4. Accounting / reads
56
+ livybolt balance --survey <id> --json
57
+ livybolt vault read --survey <id> --json # pull + DECRYPT responses locally
58
+ livybolt vault list
59
+ livybolt vault rebuild # recover vault entries from the seed
60
+
61
+ # 5. Unlock overage responses (same two-phase pay flow as create)
62
+ livybolt unlock --survey <id> --count <n>
63
+ livybolt unlock --resume <handle> --preimage <hex>
64
+
65
+ # 6. Pair the user's browser dashboard (read-only view credential)
66
+ livybolt pair --pair-password <pw> --no-qr
67
+ ```
68
+
69
+ Prefer `--json` wherever it exists; parse fields, don't scrape prose. The
70
+ pairing blob from `pair` is ciphertext (safe to display); the pairing password
71
+ is not — generate one, store it where the user can retrieve it, never echo it.
72
+
73
+ ## Threat model, bluntly
74
+
75
+ The keychain protects the identity password **at rest**: disk images, backups,
76
+ other user accounts. It does **not** protect against code already running as
77
+ this user — unattended operation means any same-user process could do what you
78
+ do. That is inherent, not fixable with different storage. The escalation
79
+ ladder when an identity guards real value:
80
+
81
+ - `--keychain` — unattended, at-rest protection (default for agents)
82
+ - `--keychain-ask` — every keychain read pops a macOS approval dialog: the
83
+ human approves each use
84
+ - Remote signer / hardware — the key never touches this machine (not yet
85
+ supported by this CLI)
86
+
87
+ Everything is end-to-end encrypted; the server never sees plaintext responses,
88
+ private keys, or the seed. Decryption happens locally in `vault read`.
package/README.md CHANGED
@@ -19,7 +19,8 @@ Requires **Node.js ≥ 20**.
19
19
  ## Quick start
20
20
 
21
21
  ```bash
22
- livybolt id new --password "<a strong passphrase>" # self-custody key, shown once
22
+ livybolt id new --keychain # self-custody key (macOS: password kept in Keychain)
23
+ # or: livybolt id new --password "<a strong passphrase>"
23
24
  livybolt create survey.json # → Lightning invoice + handle
24
25
  # ...pay the invoice with any wallet...
25
26
  livybolt create --resume <handle> --preimage <hex> # → public URL, sealed to your vault
@@ -45,10 +46,12 @@ A minimal `survey.json`:
45
46
  ## Self-custody
46
47
 
47
48
  Your keys live only in `~/.livybolt/`. They are never sent to the server, which
48
- stores ciphertext and public keys only. **Back up the seed words** printed by
49
- `livybolt id new` there is no recovery.
49
+ stores ciphertext and public keys only. **Back up the seed words** run
50
+ `livybolt id backup` in a terminal to see them once and confirm; until then
51
+ every command reminds you with `seed_backup=pending`. There is no recovery
52
+ without them.
50
53
 
51
54
  ## Docs
52
55
 
53
56
  - CLI reference: https://livybolt.com/docs/cli
54
- - HTTP API reference: https://livybolt.com/docs/api
57
+ - Running headless / as an LLM agent: see `AGENTS.md` in this package
package/package.json CHANGED
@@ -1,21 +1,25 @@
1
1
  {
2
2
  "name": "livybolt",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "livybolt — accountless, self-custody, end-to-end-encrypted surveys over Lightning. CLI client for Livybolt (Nostr edition).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "livybolt": "scripts/livybolt.mjs"
8
8
  },
9
9
  "engines": {
10
- "node": ">=20.0.0"
10
+ "node": ">=20.11.0"
11
11
  },
12
12
  "files": [
13
13
  "scripts",
14
14
  "src",
15
- "README.md"
15
+ "README.md",
16
+ "AGENTS.md"
16
17
  ],
17
18
  "dependencies": {
18
- "nostr-tools": "^2.23.5"
19
+ "@noble/hashes": "^2.0.1",
20
+ "@scure/bip39": "^2.0.1",
21
+ "nostr-tools": "^2.23.5",
22
+ "qrcode-terminal": "^0.12.0"
19
23
  },
20
24
  "scripts": {
21
25
  "prepack": "node prepack.mjs"
@@ -12,7 +12,8 @@
12
12
 
13
13
  import { homedir } from 'node:os'
14
14
  import { join } from 'node:path'
15
- import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from 'node:fs'
15
+ import { readFileSync, existsSync } from 'node:fs'
16
+ import { atomicWriteFileSync } from './atomic-file-store.mjs'
16
17
 
17
18
  // v2: seed-bearing store (v1 stored the account-0 privkey, which could not derive
18
19
  // per-survey keys). Greenfield — no v1 stores exist to migrate.
@@ -29,15 +30,42 @@ export function storePath() {
29
30
  export function readStore() {
30
31
  const path = storePath()
31
32
  if (!existsSync(path)) return null
32
- return JSON.parse(readFileSync(path, 'utf8'))
33
+ const raw = readFileSync(path, 'utf8')
34
+ try {
35
+ return JSON.parse(raw)
36
+ } catch (err) {
37
+ // A torn/corrupt identity.json (e.g. a pre-atomic-write tear) must fail with
38
+ // an actionable message, not a bare SyntaxError on every command — this file
39
+ // holds the seed, so the user needs to know what happened and where.
40
+ throw new Error(
41
+ `identity store at ${path} is corrupt and could not be parsed (${err.message}). ` +
42
+ 'If you have your seed words, remove the file and run `livybolt id restore --seed`; ' +
43
+ 'otherwise this identity may be unrecoverable.'
44
+ )
45
+ }
33
46
  }
34
47
 
35
48
  export function writeStore(record) {
36
- const home = storeHome()
37
- mkdirSync(home, { recursive: true, mode: 0o700 })
38
- const path = storePath()
39
- writeFileSync(path, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 })
40
- // writeFileSync only applies mode on create force it on overwrite too.
41
- chmodSync(path, 0o600)
42
- return path
49
+ // Atomic temp-write + rename: this file holds the seed, so a process killed
50
+ // mid-write (OOM, sleep, disk full) must leave either the old file intact or
51
+ // the new one fully written — never a torn hybrid. Previously a plain
52
+ // writeFileSync could tear identity.json, and in headless mode (seed words
53
+ // never shown) the torn ncryptsec was the only copy identity lost.
54
+ return atomicWriteFileSync(storePath(), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 })
55
+ }
56
+
57
+ // A record is "unbacked" until a human has seen the seed words and confirmed
58
+ // (`livybolt id backup` sets backupConfirmedAt). Records created before this
59
+ // field existed count as unbacked — the nag is how their owners find out.
60
+ export function backupPending(record) {
61
+ return record != null && !record.backupConfirmedAt
62
+ }
63
+
64
+ // One stderr line, printed by every command that unlocks the identity while
65
+ // the seed is unbacked. Deliberately on stderr (never corrupts JSON output)
66
+ // and deliberately in the output agents relay to humans: losing this machine
67
+ // while the state is pending loses the identity.
68
+ export function warnIfBackupPending(record) {
69
+ if (!backupPending(record)) return
70
+ console.error('⚠ seed not backed up — run `livybolt id backup` in a terminal (lose this machine before then and this identity is unrecoverable)')
43
71
  }
@@ -9,38 +9,66 @@
9
9
  // so the CLI and any inline reimplementation produce byte-identical keys.
10
10
  //
11
11
  // Commands:
12
- // new Generate a fresh identity. Prints npub + seed words ONCE, seals it
13
- // to the local store (encrypted by default), prints the ncryptsec backup.
14
- // show Print the stored npub/pubkey (never the secret).
12
+ // new Generate a fresh identity, sealed encrypted. On a terminal the
13
+ // seed words print ONCE; piped/JSON output never carries them
14
+ // (agents' transcripts persist run `id backup` to see the words).
15
+ // show Print the stored npub/pubkey + backup state (never the secret).
16
+ // backup TTY-only ceremony: print the seed words, confirm they are written
17
+ // down, and clear the seed_backup=pending state.
15
18
  // export Print the ncryptsec backup blob (for off-machine backup).
16
19
  // restore Rebuild the store from --seed "<words>" or --ncryptsec <blob>.
17
20
  // unlock Verify the store opens (password if encrypted); prints npub.
18
21
  //
19
22
  // Flags:
20
- // --plaintext Store the bare seed words at 0600 instead of an ncryptsec
21
- // (zero-friction for headless agents; secret rests in cleartext).
22
- // --password <pw> Password for the ncryptsec. Else LIVYBOLT_IDENTITY_PASSWORD,
23
- // else an interactive (hidden) prompt.
24
- // --seed "<words>" BIP-39 mnemonic (restore).
25
- // --ncryptsec <blob> ncryptsec1… backup blob (restore).
23
+ // --keychain (new/restore, macOS) Generate a random password, park it
24
+ // in the login keychain, seal encrypted. The zero-setup
25
+ // headless path later commands find the password
26
+ // themselves. At-rest protection only: any process running
27
+ // as this user can read the entry.
28
+ // --keychain-ask Like --keychain, but every keychain read pops a macOS
29
+ // allow/deny dialog — per-use human approval.
30
+ // --password <pw> Password for the ncryptsec. Else --password-stdin, else
31
+ // LIVYBOLT_IDENTITY_PASSWORD, else keychain (unlock only),
32
+ // else an interactive (hidden) prompt.
33
+ // --password-stdin Read the password from stdin (keeps it out of argv/env).
34
+ // --insecure-plaintext Store the bare seed words at 0600. The name is the
35
+ // warning; prefer --keychain, which is equally headless.
36
+ // --json Machine-readable output (never includes seed words).
37
+ // --seed "<words>" BIP-39 mnemonic (restore).
38
+ // --ncryptsec <blob> ncryptsec1… backup blob (restore).
26
39
  //
27
40
  // Store location: ~/.livybolt/identity.json (override LIVYBOLT_IDENTITY_HOME).
41
+ // Keychain service: livybolt-cli-identity (override LIVYBOLT_KEYCHAIN_SERVICE).
28
42
  //
29
43
  // Usage:
44
+ // node scripts/agent-identity.mjs new --keychain
30
45
  // node scripts/agent-identity.mjs new --password "hunter2"
31
- // node scripts/agent-identity.mjs new --plaintext
32
- // node scripts/agent-identity.mjs show
46
+ // node scripts/agent-identity.mjs backup
33
47
  // node scripts/agent-identity.mjs export > backup.ncryptsec
34
- // node scripts/agent-identity.mjs restore --seed "word1 word2 …" --password "hunter2"
48
+ // node scripts/agent-identity.mjs restore --seed "word1 word2 …" --keychain
35
49
 
50
+ import { createInterface } from 'node:readline'
36
51
  import {
37
52
  generateAgentIdentity,
38
53
  agentIdentityFromSeedWords,
39
54
  AgentIdentityError,
40
55
  } from '../src/lib/crypto/agent-identity.js'
41
56
  import { sealSeed, unlockSeed, SeedStoreError } from '../src/lib/crypto/seed-store.js'
42
- import { readStore, writeStore, storePath, STORE_VERSION } from './agent-identity-store.mjs'
57
+ import {
58
+ readStore,
59
+ writeStore,
60
+ storePath,
61
+ STORE_VERSION,
62
+ backupPending,
63
+ warnIfBackupPending,
64
+ } from './agent-identity-store.mjs'
43
65
  import { resolveIdentityPassword } from './cli-prompt.mjs'
66
+ import {
67
+ keychainAvailable,
68
+ keychainService,
69
+ generateKeychainPassword,
70
+ storeKeychainPassword,
71
+ } from './keychain.mjs'
44
72
 
45
73
  function fail(msg) {
46
74
  console.error(`error: ${msg}`)
@@ -52,8 +80,14 @@ function parseFlags(argv) {
52
80
  const flags = {}
53
81
  for (let i = 0; i < argv.length; i++) {
54
82
  const a = argv[i]
55
- if (a === '--plaintext') flags.plaintext = true
83
+ if (a === '--insecure-plaintext') flags.insecurePlaintext = true
84
+ else if (a === '--plaintext')
85
+ fail('--plaintext is now --insecure-plaintext (the name is the warning). Prefer --keychain: equally headless, encrypted at rest.')
86
+ else if (a === '--keychain') flags.keychain = true
87
+ else if (a === '--keychain-ask') flags.keychainAsk = true
56
88
  else if (a === '--password') flags.password = argv[++i]
89
+ else if (a === '--password-stdin') flags.passwordStdin = true
90
+ else if (a === '--json') flags.json = true
57
91
  else if (a === '--seed') flags.seed = argv[++i]
58
92
  else if (a === '--ncryptsec') flags.ncryptsec = argv[++i]
59
93
  else fail(`unknown argument: ${a}`)
@@ -61,17 +95,66 @@ function parseFlags(argv) {
61
95
  return flags
62
96
  }
63
97
 
64
- async function resolvePassword(flags, purpose) {
65
- const pw = await resolveIdentityPassword(flags, fail, purpose)
98
+ async function resolvePassword(flags, purpose, opts) {
99
+ const pw = await resolveIdentityPassword(flags, fail, purpose, opts)
66
100
  if (!pw) fail('empty password')
67
101
  return pw
68
102
  }
69
103
 
104
+ /**
105
+ * Choose the password that will seal a NEW secret. --keychain/--keychain-ask
106
+ * generate one and park it in the macOS keychain (so the identity is usable
107
+ * headless with zero password handling); otherwise the explicit ladder runs
108
+ * with the keychain rung OFF — a stale keychain entry must never silently
109
+ * become the password of a fresh identity.
110
+ */
111
+ async function passwordForNewSecret(flags, purpose) {
112
+ const useKeychain = flags.keychain || flags.keychainAsk
113
+ if (useKeychain && flags.insecurePlaintext) {
114
+ fail('--keychain seals the seed encrypted — it cannot combine with --insecure-plaintext')
115
+ }
116
+ if (flags.insecurePlaintext) return { password: null }
117
+ if (!useKeychain) return { password: await resolvePassword(flags, purpose, { skipKeychain: true }) }
118
+ if (flags.password != null || flags.passwordStdin) {
119
+ fail('--keychain generates its own random password — do not also pass one')
120
+ }
121
+ if (!keychainAvailable()) {
122
+ fail('--keychain needs the macOS keychain. Use --password / --password-stdin / LIVYBOLT_IDENTITY_PASSWORD instead.')
123
+ }
124
+ const password = generateKeychainPassword()
125
+ // Keychain first: a store sealed to a password that never reached the
126
+ // keychain would be sealed to a password nobody has.
127
+ storeKeychainPassword(password, { ask: flags.keychainAsk })
128
+ return { password, keychain: true }
129
+ }
130
+
131
+ /** Visible-echo confirmation prompt (the backup ceremony's "yes"). EOF on
132
+ * stdin resolves to '' (→ "not confirmed") instead of leaving the promise —
133
+ * and the whole process — hanging on an unsettled top-level await. */
134
+ function promptVisible(query) {
135
+ return new Promise((resolve) => {
136
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
137
+ let settled = false
138
+ const settle = (answer) => {
139
+ if (settled) return
140
+ settled = true
141
+ resolve(answer.trim())
142
+ }
143
+ rl.question(query, (answer) => {
144
+ // Settle BEFORE close: rl.close() emits 'close' synchronously, and the
145
+ // EOF fallback below must not win the race against a real answer.
146
+ settle(answer)
147
+ rl.close()
148
+ })
149
+ rl.on('close', () => settle(''))
150
+ })
151
+ }
152
+
70
153
  // --- store sealing + self-check ----------------------------------------------
71
154
  // The persisted secret is the SEED, so per-survey keys can derive from it. We
72
- // store the seed words (plaintext mode) or a NIP-49 ncryptsec of the BIP-39
73
- // entropy (encrypted default). `secretKind` tags the payload so the seed entropy
74
- // is never misread as an nsec.
155
+ // store the seed words (insecure-plaintext mode) or a NIP-49 ncryptsec of the
156
+ // BIP-39 entropy (encrypted default). `secretKind` tags the payload so the seed
157
+ // entropy is never misread as an nsec.
75
158
  function sealRecord(id, { plaintext, password }) {
76
159
  if (!id.seedWords) {
77
160
  fail('cannot store a seed-bearing identity without seed words (use restore --seed or --ncryptsec)')
@@ -119,35 +202,127 @@ async function cmdNew(flags) {
119
202
  fail(`an identity already exists at ${storePath()} — refusing to overwrite. Move it aside first.`)
120
203
  }
121
204
  const id = generateAgentIdentity()
122
- const password = flags.plaintext ? null : await resolvePassword(flags, 'encrypt the new identity')
123
- const record = sealRecord(id, { plaintext: flags.plaintext, password })
205
+ const sealed = await passwordForNewSecret(flags, 'encrypt the new identity')
206
+ const record = sealRecord(id, { plaintext: flags.insecurePlaintext, password: sealed.password })
207
+ // Verify the seal round-trips BEFORE persisting — a mis-sealed identity must
208
+ // never reach disk, where `id new` would then refuse to overwrite it (it
209
+ // "exists") and leave the user stuck with an unusable store.
210
+ selfCheck(record, sealed.password)
124
211
  writeStore(record)
125
- selfCheck(record, password)
126
212
 
127
- console.error('')
128
- console.error(' ⚠ BACK UP THESE SEED WORDS NOW shown once, never sent to the server.')
129
- console.error(' They are the canonical recovery for this identity.')
130
- console.error(` Stored ${flags.plaintext ? 'in CLEARTEXT (--plaintext)' : 'encrypted'} at ${storePath()} (0600).`)
131
- console.error('')
213
+ // Seed words print ONLY to a terminal a human is watching. Piped/JSON output
214
+ // (agentswhose transcripts persist forever) gets seed_backup=pending and
215
+ // the `id backup` pointer instead.
216
+ const humanTerminal = process.stdout.isTTY && !flags.json
217
+
218
+ // The scrypt-sealed ncryptsec is safe to print to a persistent transcript ONLY
219
+ // when sealed with a keychain-generated RANDOM password (infeasible to grind).
220
+ // With a user-supplied password the ncryptsec is offline-grindable — and the
221
+ // password is often on the same invocation line — so leaking it to a piped/JSON
222
+ // transcript is a master-seed compromise. Gate it like the seed words: emit to
223
+ // a human terminal or a keychain seal; otherwise withhold and point at `id
224
+ // export` (the deliberate retrieval path, run in a terminal).
225
+ const canEmitNcryptsec = !!record.ncryptsec && (sealed.keychain || humanTerminal)
226
+
227
+ if (flags.json) {
228
+ console.log(
229
+ JSON.stringify({
230
+ npub: id.npub,
231
+ pubkey: id.pubkeyHex,
232
+ mode: flags.insecurePlaintext ? 'plaintext' : 'encrypted',
233
+ store: storePath(),
234
+ seed_backup: 'pending',
235
+ ...(sealed.keychain ? { keychain_service: keychainService() } : {}),
236
+ ...(canEmitNcryptsec ? { ncryptsec: record.ncryptsec } : {}),
237
+ })
238
+ )
239
+ return
240
+ }
132
241
 
133
242
  console.log(`npub=${id.npub}`)
134
243
  console.log(`pubkey=${id.pubkeyHex}`)
135
- console.log(`seed_words=${id.seedWords}`)
136
- if (record.ncryptsec) console.log(`ncryptsec=${record.ncryptsec}`)
244
+ if (humanTerminal) {
245
+ console.error('')
246
+ console.error(' ⚠ BACK UP THESE SEED WORDS NOW — shown once, never sent to the server.')
247
+ console.error(' They are the canonical recovery for this identity.')
248
+ console.error(` Stored ${flags.insecurePlaintext ? 'in CLEARTEXT (--insecure-plaintext)' : 'encrypted'} at ${storePath()} (0600).`)
249
+ console.error('')
250
+ console.log(`seed_words=${id.seedWords}`)
251
+ console.error('')
252
+ console.error(' Run `livybolt id backup` once they are written down to confirm the backup.')
253
+ } else {
254
+ console.log('seed_backup=pending')
255
+ console.error('seed words withheld from non-terminal output — run `livybolt id backup` in a terminal to see them once and confirm the backup')
256
+ }
257
+ if (sealed.keychain) console.log(`keychain_service=${keychainService()}`)
258
+ if (record.ncryptsec) {
259
+ if (canEmitNcryptsec) {
260
+ console.log(`ncryptsec=${record.ncryptsec}`)
261
+ } else {
262
+ console.error('ncryptsec (encrypted seed backup) withheld from non-terminal output — run `livybolt id export` in a terminal to retrieve it')
263
+ }
264
+ }
137
265
  }
138
266
 
139
- function cmdShow() {
267
+ function cmdShow(flags) {
140
268
  const rec = readStore()
141
- if (!rec) fail(`no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`)
269
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
270
+ const backup = backupPending(rec) ? 'pending' : `confirmed ${rec.backupConfirmedAt}`
271
+ if (flags.json) {
272
+ console.log(
273
+ JSON.stringify({
274
+ npub: rec.npub,
275
+ pubkey: rec.pubkey,
276
+ mode: rec.secretKind === 'seed-plaintext' ? 'plaintext' : 'encrypted',
277
+ created: rec.createdAt,
278
+ store: storePath(),
279
+ seed_backup: backupPending(rec) ? 'pending' : 'confirmed',
280
+ ...(backupPending(rec) ? {} : { backup_confirmed_at: rec.backupConfirmedAt }),
281
+ })
282
+ )
283
+ return
284
+ }
142
285
  console.log(`npub=${rec.npub}`)
143
286
  console.log(`pubkey=${rec.pubkey}`)
144
287
  console.log(`mode=${rec.secretKind === 'seed-plaintext' ? 'plaintext' : 'encrypted'}`)
145
288
  console.log(`created=${rec.createdAt}`)
289
+ console.log(`seed_backup=${backup}`)
290
+ }
291
+
292
+ // The one sanctioned way to see the seed words after creation: a terminal-only
293
+ // ceremony that ends by recording the human's confirmation, which clears the
294
+ // seed_backup=pending nag everywhere.
295
+ async function cmdBackup(flags) {
296
+ const rec = readStore()
297
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
298
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
299
+ fail('refusing to print seed words to a non-terminal (transcripts persist). Run `livybolt id backup` in an interactive terminal.')
300
+ }
301
+ const password =
302
+ rec.secretKind === 'seed-plaintext'
303
+ ? undefined
304
+ : await resolvePassword(flags, 'unlock the identity')
305
+ const words = seedFromRecord(rec, password)
306
+
307
+ console.log('')
308
+ console.log(' Your seed words — the canonical recovery for this identity.')
309
+ console.log(' Write them down somewhere that is not this machine.')
310
+ console.log('')
311
+ console.log(` ${words}`)
312
+ console.log('')
313
+ const answer = await promptVisible(' Type "yes" once they are written down: ')
314
+ if (answer.toLowerCase() !== 'yes') {
315
+ fail('not confirmed — seed backup is still pending')
316
+ }
317
+ rec.backupConfirmedAt = new Date().toISOString()
318
+ writeStore(rec)
319
+ console.log(`seed_backup=confirmed ${rec.backupConfirmedAt}`)
146
320
  }
147
321
 
148
322
  async function cmdExport(flags) {
149
323
  const rec = readStore()
150
- if (!rec) fail(`no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`)
324
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
325
+ warnIfBackupPending(rec)
151
326
  // The exported blob is a NIP-49 ncryptsec of the seed entropy — restore it with
152
327
  // `restore --ncryptsec`. It is NOT a portable nsec; the seed words are the
153
328
  // canonical cross-tool backup.
@@ -156,7 +331,7 @@ async function cmdExport(flags) {
156
331
  return
157
332
  }
158
333
  // Plaintext store: derive an encrypted seed backup on demand.
159
- const password = await resolvePassword(flags, 'encrypt the backup')
334
+ const password = await resolvePassword(flags, 'encrypt the backup', { skipKeychain: true })
160
335
  console.log(sealSeed(rec.seedWords, password))
161
336
  }
162
337
 
@@ -174,16 +349,20 @@ async function cmdRestore(flags) {
174
349
  } else {
175
350
  fail('restore needs --seed "<words>" or --ncryptsec <blob>')
176
351
  }
177
- const password = flags.plaintext ? null : await resolvePassword(flags, 'encrypt the restored identity')
178
- const record = sealRecord(id, { plaintext: flags.plaintext, password })
352
+ const sealed = await passwordForNewSecret(flags, 'encrypt the restored identity')
353
+ const record = sealRecord(id, { plaintext: flags.insecurePlaintext, password: sealed.password })
354
+ // The human restoring from a backup self-evidently holds that backup.
355
+ record.backupConfirmedAt = new Date().toISOString()
179
356
  writeStore(record)
180
- selfCheck(record, password)
357
+ selfCheck(record, sealed.password)
181
358
  console.log(`npub=${id.npub}`)
359
+ if (sealed.keychain) console.log(`keychain_service=${keychainService()}`)
182
360
  }
183
361
 
184
362
  async function cmdUnlock(flags) {
185
363
  const rec = readStore()
186
- if (!rec) fail(`no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`)
364
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
365
+ warnIfBackupPending(rec)
187
366
  const password =
188
367
  rec.secretKind === 'seed-plaintext'
189
368
  ? undefined
@@ -204,7 +383,10 @@ try {
204
383
  await cmdNew(flags)
205
384
  break
206
385
  case 'show':
207
- cmdShow()
386
+ cmdShow(flags)
387
+ break
388
+ case 'backup':
389
+ await cmdBackup(flags)
208
390
  break
209
391
  case 'export':
210
392
  await cmdExport(flags)
@@ -218,8 +400,8 @@ try {
218
400
  default:
219
401
  fail(
220
402
  `unknown command: ${command ?? '(none)'}\n` +
221
- ' commands: new | show | export | restore | unlock\n' +
222
- ' e.g. node scripts/agent-identity.mjs new --password "hunter2"'
403
+ ' commands: new | show | backup | export | restore | unlock\n' +
404
+ ' e.g. node scripts/agent-identity.mjs new --keychain'
223
405
  )
224
406
  }
225
407
  } catch (err) {
@@ -0,0 +1,72 @@
1
+ // Shared local at-rest file helpers for the CLI's JSON stores (vault,
2
+ // pending-create, survey-index counter): atomic writes and a same-machine
3
+ // advisory lock. Extracted because all three stores need the identical fix
4
+ // for the identical failure mode — a process killed mid-write (OOM, container
5
+ // eviction, laptop sleep, disk full) leaving a torn file, or two concurrent
6
+ // CLI invocations racing a read-modify-write.
7
+
8
+ import { writeFileSync, renameSync, chmodSync, unlinkSync, mkdirSync, openSync, closeSync } from 'node:fs'
9
+ import { dirname } from 'node:path'
10
+ import { randomBytes } from 'node:crypto'
11
+
12
+ /**
13
+ * Write `data` to `path` atomically: write to a uniquely-named temp file in
14
+ * the same directory, then rename(2) over the target. rename is atomic on a
15
+ * given filesystem, so a process killed mid-write leaves either the old file
16
+ * intact or the new one fully written — never a torn hybrid that a JSON
17
+ * parser chokes on.
18
+ */
19
+ export function atomicWriteFileSync(path, data, { mode = 0o600 } = {}) {
20
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
21
+ const tmpPath = `${path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`
22
+ writeFileSync(tmpPath, data, { mode })
23
+ chmodSync(tmpPath, mode)
24
+ renameSync(tmpPath, path)
25
+ return path
26
+ }
27
+
28
+ /** Synchronous blocking sleep (Atomics.wait on a throwaway SharedArrayBuffer)
29
+ * — fine for a short-lived CLI's lock-retry backoff; never use this in a
30
+ * long-running server process. */
31
+ function sleepSyncMs(ms) {
32
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
33
+ }
34
+
35
+ /**
36
+ * Run `fn` while holding a same-machine advisory lock at `${path}.lock`,
37
+ * serializing concurrent CLI invocations that read-modify-write the same
38
+ * file. The lock file is created with the exclusive `wx` flag, so only one
39
+ * of two racing processes can create it; the loser retries with a fixed
40
+ * backoff until the lock is released or `timeoutMs` elapses.
41
+ *
42
+ * Not a general-purpose distributed lock — it assumes a single machine and a
43
+ * cooperative process that always reaches the `finally` (a SIGKILL mid-`fn`
44
+ * leaves a stale lock file; a human deleting it, or waiting for the next
45
+ * `livybolt vault rebuild`, are the recovery paths).
46
+ */
47
+ export function withFileLockSync(path, fn, { timeoutMs = 5000, retryMs = 20 } = {}) {
48
+ const lockPath = `${path}.lock`
49
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
50
+ const deadline = Date.now() + timeoutMs
51
+ for (;;) {
52
+ try {
53
+ closeSync(openSync(lockPath, 'wx'))
54
+ break
55
+ } catch (err) {
56
+ if (err.code !== 'EEXIST') throw err
57
+ if (Date.now() > deadline) {
58
+ throw new Error(`timed out waiting for lock at ${lockPath} — held by another livybolt process?`)
59
+ }
60
+ sleepSyncMs(retryMs)
61
+ }
62
+ }
63
+ try {
64
+ return fn()
65
+ } finally {
66
+ try {
67
+ unlinkSync(lockPath)
68
+ } catch {
69
+ // Already released (or never fully created) — nothing to clean up.
70
+ }
71
+ }
72
+ }