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 +88 -0
- package/README.md +7 -4
- package/package.json +8 -4
- package/scripts/agent-identity-store.mjs +37 -9
- package/scripts/agent-identity.mjs +222 -40
- package/scripts/atomic-file-store.mjs +72 -0
- package/scripts/balance.mjs +14 -9
- package/scripts/cli-prompt.mjs +51 -8
- package/scripts/keychain.mjs +104 -0
- package/scripts/livybolt-api.mjs +14 -54
- package/scripts/livybolt.mjs +15 -10
- package/scripts/pair.mjs +3 -1
- package/scripts/pending-store.mjs +3 -15
- package/scripts/seed-unlock.mjs +3 -2
- package/scripts/spawn-exit-code.mjs +27 -0
- package/scripts/survey-create.mjs +33 -17
- package/scripts/survey-index-store.mjs +35 -11
- package/scripts/token-vault-store.mjs +13 -10
- package/scripts/token-vault.mjs +11 -32
- package/scripts/unlock.mjs +3 -1
- package/scripts/vault-decrypt.mjs +16 -2
- package/scripts/vault-read.mjs +36 -17
- package/scripts/vault-rebuild.mjs +10 -6
- package/src/lib/nip98.js +129 -0
- package/src/lib/read/fetch-responses.js +153 -0
- package/src/lib/read/rediscover.js +129 -0
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 --
|
|
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**
|
|
49
|
-
`livybolt id
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
writeFileSync
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
// --
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// --
|
|
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
|
|
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 …" --
|
|
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 {
|
|
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.
|
|
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
|
|
73
|
-
// entropy (encrypted default). `secretKind` tags the payload so the seed
|
|
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
|
|
123
|
-
const record = sealRecord(id, { plaintext: flags.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
213
|
+
// Seed words print ONLY to a terminal a human is watching. Piped/JSON output
|
|
214
|
+
// (agents — whose 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
|
-
|
|
136
|
-
|
|
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:
|
|
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:
|
|
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
|
|
178
|
-
const record = sealRecord(id, { plaintext: flags.
|
|
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:
|
|
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 --
|
|
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
|
+
}
|