livybolt 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -51,4 +51,3 @@ stores ciphertext and public keys only. **Back up the seed words** printed by
51
51
  ## Docs
52
52
 
53
53
  - CLI reference: https://livybolt.com/docs/cli
54
- - HTTP API reference: https://livybolt.com/docs/api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livybolt",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {
@@ -15,7 +15,8 @@
15
15
  "README.md"
16
16
  ],
17
17
  "dependencies": {
18
- "nostr-tools": "^2.23.5"
18
+ "nostr-tools": "^2.23.5",
19
+ "qrcode-terminal": "^0.12.0"
19
20
  },
20
21
  "scripts": {
21
22
  "prepack": "node prepack.mjs"
@@ -138,7 +138,7 @@ async function cmdNew(flags) {
138
138
 
139
139
  function cmdShow() {
140
140
  const rec = readStore()
141
- if (!rec) fail(`no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`)
141
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
142
142
  console.log(`npub=${rec.npub}`)
143
143
  console.log(`pubkey=${rec.pubkey}`)
144
144
  console.log(`mode=${rec.secretKind === 'seed-plaintext' ? 'plaintext' : 'encrypted'}`)
@@ -147,7 +147,7 @@ function cmdShow() {
147
147
 
148
148
  async function cmdExport(flags) {
149
149
  const rec = readStore()
150
- if (!rec) fail(`no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`)
150
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
151
151
  // The exported blob is a NIP-49 ncryptsec of the seed entropy — restore it with
152
152
  // `restore --ncryptsec`. It is NOT a portable nsec; the seed words are the
153
153
  // canonical cross-tool backup.
@@ -183,7 +183,7 @@ async function cmdRestore(flags) {
183
183
 
184
184
  async function cmdUnlock(flags) {
185
185
  const rec = readStore()
186
- if (!rec) fail(`no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`)
186
+ if (!rec) fail(`no identity at ${storePath()}. Run: livybolt id new`)
187
187
  const password =
188
188
  rec.secretKind === 'seed-plaintext'
189
189
  ? undefined
@@ -31,7 +31,7 @@ export async function seedMnemonicFromRecord(rec, resolvePassword) {
31
31
  if (!rec) {
32
32
  throw new SeedUnlockError(
33
33
  'no_identity',
34
- `no identity at ${storePath()}. Run: node scripts/agent-identity.mjs new`
34
+ `no identity at ${storePath()}. Run: livybolt id new`
35
35
  )
36
36
  }
37
37
  if (rec.secretKind === 'seed-plaintext') {
@@ -38,7 +38,7 @@ function parseFlags(argv) {
38
38
 
39
39
  function requireIdentity() {
40
40
  const rec = readStore()
41
- if (!rec) fail('no identity. Run: node scripts/agent-identity.mjs new')
41
+ if (!rec) fail('no identity. Run: livybolt id new')
42
42
  return rec
43
43
  }
44
44
 
@@ -0,0 +1,129 @@
1
+ // NIP-98 HTTP Auth (kind 27235) for Livybolt's owner read/unlock path.
2
+ //
3
+ // A client proves it holds a survey's per-survey key by signing a short-lived
4
+ // event bound to the request, sent as `Authorization: Nostr <base64-event>`.
5
+ // The server verifies the signature resolves to the survey's (already public)
6
+ // `encryption_pubkey` — so the IDENTITY key signs nothing to the server, and no
7
+ // owner-token secret needs storing. Replaces the random owner token (foundation
8
+ // U4); the per-survey pubkey is the public encryption target, so no new
9
+ // correlation is leaked.
10
+ //
11
+ // We wrap nostr-tools' nip98 primitives but supply our own freshness + URL
12
+ // policy: nostr-tools' validateEventTimestamp has no future-skew bound and its
13
+ // URL check is exact-host-strict (fragile behind a proxy). We bind to the
14
+ // request PATH (which carries the surveyId + action) rather than the host.
15
+
16
+ import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
17
+ import { getToken, unpackEventFromToken, hashPayload } from 'nostr-tools/nip98'
18
+
19
+ const HTTP_AUTH_KIND = 27235
20
+ /** Max age of a NIP-98 event the server will accept (replay window). */
21
+ export const DEFAULT_MAX_AGE_SECONDS = 60
22
+ /** Tolerated clock skew for events dated slightly in the future. */
23
+ const FUTURE_SKEW_SECONDS = 5
24
+
25
+
26
+
27
+ /**
28
+ * CLIENT: sign a NIP-98 `Authorization` token for (url, method[, payload]) with
29
+ * the survey's secret key. Returns the full header value (`Nostr <base64>`).
30
+ */
31
+ export async function signNip98Token(
32
+ url ,
33
+ method ,
34
+ secretKey ,
35
+ payload
36
+ ) {
37
+ return getToken(
38
+ url,
39
+ method,
40
+ (event) => finalizeEvent(event, secretKey),
41
+ true,
42
+ // hashPayload runs only when payload is truthy & non-empty (nostr-tools).
43
+ payload
44
+ )
45
+ }
46
+
47
+ /**
48
+ * SERVER: verify a NIP-98 token against the request and the expected signer
49
+ * SET. Binds method, request path (proxy-robust), payload hash (when a body is
50
+ * present), a bounded freshness window, and `event.pubkey ∈ expectedPubkeys` —
51
+ * a survey accepts its encryption key (CLI) or its view key (dashboard).
52
+ */
53
+ export async function verifyNip98Token(params
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+ ) {
67
+ const { token, method, path, body } = params
68
+ const maxAge = params.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS
69
+ const now = params.now ?? Math.round(Date.now() / 1000)
70
+
71
+ // No pubkey to verify against (a misconfigured row): fail closed and
72
+ // explicitly, rather than relying on the signer-set membership check below
73
+ // to coincidentally reject every signer.
74
+ const expected = new Set(params.expectedPubkeys.filter((p) => !!p))
75
+ if (expected.size === 0) return { valid: false, reason: 'no_expected_pubkey' }
76
+
77
+ let event
78
+ try {
79
+ event = await unpackEventFromToken(token)
80
+ } catch {
81
+ return { valid: false, reason: 'malformed_token' }
82
+ }
83
+ if (!event || event.kind !== HTTP_AUTH_KIND) return { valid: false, reason: 'wrong_kind' }
84
+ if (!verifyEvent(event)) return { valid: false, reason: 'bad_signature' }
85
+
86
+ // Freshness: reject too-old and too-far-future (clock-skew abuse).
87
+ const age = now - event.created_at
88
+ if (age > maxAge || age < -FUTURE_SKEW_SECONDS) return { valid: false, reason: 'expired' }
89
+
90
+ const methodTag = event.tags.find((t) => t[0] === 'method')?.[1]
91
+ if (!methodTag || methodTag.toLowerCase() !== method.toLowerCase()) {
92
+ return { valid: false, reason: 'method_mismatch' }
93
+ }
94
+
95
+ const urlTag = event.tags.find((t) => t[0] === 'u')?.[1]
96
+ if (!urlTag || !pathMatches(urlTag, path)) return { valid: false, reason: 'url_mismatch' }
97
+
98
+ if (hasBody(body)) {
99
+ const payloadTag = event.tags.find((t) => t[0] === 'payload')?.[1]
100
+ if (!payloadTag || payloadTag !== hashPayload(body )) {
101
+ return { valid: false, reason: 'payload_mismatch' }
102
+ }
103
+ }
104
+
105
+ if (!expected.has(event.pubkey)) return { valid: false, reason: 'wrong_signer' }
106
+
107
+ return { valid: true, pubkey: event.pubkey }
108
+ }
109
+
110
+ function hasBody(body ) {
111
+ if (body === undefined || body === null) return false
112
+ if (typeof body === 'object') return Object.keys(body ).length > 0
113
+ return true
114
+ }
115
+
116
+ function pathMatches(urlTag , expectedPath ) {
117
+ let tagPath
118
+ try {
119
+ tagPath = new URL(urlTag).pathname
120
+ } catch {
121
+ // The `u` tag was not an absolute URL — compare the raw path (sans query).
122
+ tagPath = urlTag.split('?')[0]
123
+ }
124
+ return normalizePath(tagPath) === normalizePath(expectedPath)
125
+ }
126
+
127
+ function normalizePath(p ) {
128
+ return p.replace(/\/+$/, '') || '/'
129
+ }
@@ -0,0 +1,104 @@
1
+ // Authenticated response fetching — sealed envelopes only, key injected as a
2
+ // TOKEN PROVIDER so this module never touches key material.
3
+ //
4
+ // The caller supplies `tokenProvider(method, url)`, which returns a full
5
+ // `Authorization` header value (`Nostr <base64-event>` — see ../nip98.ts
6
+ // signNip98Token). The dashboard signs with the per-survey VIEW key; the CLI
7
+ // signs with the per-survey encryption key; this module is identical for
8
+ // both. Responses come back as the server stores them: sealed envelopes the
9
+ // server cannot open.
10
+ //
11
+ // Isomorphic (no Node built-ins, injected fetch) so the same code runs in the
12
+ // browser, the CLI, and tests.
13
+
14
+
15
+
16
+ export class FetchResponsesError extends Error {
17
+ code
18
+ status
19
+ constructor(code , message , status ) {
20
+ super(message ?? code)
21
+ this.name = 'FetchResponsesError'
22
+ this.code = code
23
+ this.status = status
24
+ }
25
+ }
26
+
27
+ /** One stored response row: `data` is a sealed envelope when `encrypted`. */
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+ const DEFAULT_TIMEOUT_MS = 15_000
48
+
49
+ /**
50
+ * Fetch one page of a survey's responses, authenticated by the injected
51
+ * NIP-98 token provider. The token is signed over the EXACT URL requested
52
+ * (query string included) — the server binds method + path.
53
+ */
54
+ export async function fetchResponses(options
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+ ) {
64
+ const host = options.host.replace(/\/+$/, '')
65
+ const fetchImpl = options.fetchImpl ?? fetch
66
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
67
+
68
+ const query = new URLSearchParams()
69
+ if (options.since) query.set('since', options.since)
70
+ if (options.limit !== undefined) query.set('limit', String(options.limit))
71
+ const qs = query.size > 0 ? `?${query}` : ''
72
+ const url = `${host}/api/surveys/${encodeURIComponent(options.surveyId)}/responses${qs}`
73
+
74
+ const token = await options.tokenProvider('GET', url)
75
+
76
+ const controller = new AbortController()
77
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
78
+ let res
79
+ try {
80
+ res = await fetchImpl(url, {
81
+ headers: { Authorization: token },
82
+ signal: controller.signal,
83
+ })
84
+ } catch (err) {
85
+ if (controller.signal.aborted) {
86
+ throw new FetchResponsesError('timeout', `responses fetch timed out after ${timeoutMs}ms`)
87
+ }
88
+ throw new FetchResponsesError(
89
+ 'network',
90
+ `responses fetch failed: ${err instanceof Error ? err.message : String(err)}`
91
+ )
92
+ } finally {
93
+ clearTimeout(timer)
94
+ }
95
+
96
+ if (!res.ok) {
97
+ throw new FetchResponsesError(
98
+ 'http_error',
99
+ `responses fetch failed with status ${res.status}`,
100
+ res.status
101
+ )
102
+ }
103
+ return (await res.json())
104
+ }
@@ -0,0 +1,107 @@
1
+ // Gap-limit survey rediscovery — the HD-wallet scan, hierarchy-agnostic.
2
+ //
3
+ // The server is accountless: nothing links surveys to an owner. Any client
4
+ // holding a seed re-discovers its surveys the way a restored wallet finds its
5
+ // funds — derive the pubkey at account n, ask the public lookup to resolve
6
+ // it, stop after `gapLimit` consecutive misses. `livybolt vault rebuild` runs
7
+ // this over ENCRYPTION pubkeys (master-seed hierarchy); the dashboard runs
8
+ // the identical algorithm over VIEW pubkeys (view-seed hierarchy). The
9
+ // hierarchy difference is one injected function — the key-provider seam —
10
+ // so the scan logic exists exactly once.
11
+ //
12
+ // Isomorphic (no Node built-ins, injected fetch) so the same code runs in the
13
+ // browser, the CLI, and tests.
14
+
15
+ import { FIRST_SURVEY_ACCOUNT_INDEX } from '../crypto/survey-key.js'
16
+
17
+ /** The public, auth-free survey descriptor the lookup endpoint returns. */
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+ export class RediscoverError extends Error {
37
+ code
38
+ status
39
+ constructor(code , message , status ) {
40
+ super(message ?? code)
41
+ this.name = 'RediscoverError'
42
+ this.code = code
43
+ this.status = status
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Walk account indexes from FIRST_SURVEY_ACCOUNT_INDEX, resolving each derived
49
+ * pubkey, until `gapLimit` consecutive misses (default 20 — parity with
50
+ * `livybolt vault rebuild`). A hit resets the gap. Returns every hit plus the next
51
+ * free index (server-truth: max occupied index + 1).
52
+ *
53
+ * `lookup` must return null ONLY for a definitive miss (404). A transport or
54
+ * server error must throw — swallowing it as a miss could prematurely satisfy
55
+ * the gap limit and silently truncate rediscovery.
56
+ */
57
+ export async function rediscoverSurveys(options
58
+
59
+
60
+
61
+
62
+
63
+ ) {
64
+ const gapLimit = options.gapLimit ?? 20
65
+ const found = []
66
+ let gap = 0
67
+ let n = FIRST_SURVEY_ACCOUNT_INDEX
68
+ let maxFound = 0
69
+ while (gap < gapLimit) {
70
+ const descriptor = await options.lookup(options.derivePubkey(n))
71
+ if (descriptor && descriptor.surveyId) {
72
+ gap = 0
73
+ maxFound = n
74
+ found.push({ surveyId: descriptor.surveyId, accountIndex: n, descriptor })
75
+ } else {
76
+ gap += 1
77
+ }
78
+ n += 1
79
+ }
80
+ return { found, nextIndex: Math.max(maxFound + 1, FIRST_SURVEY_ACCOUNT_INDEX) }
81
+ }
82
+
83
+ /**
84
+ * Build a `lookup` over the public resolver endpoint
85
+ * (`GET /api/surveys?view_pubkey=` or `?encryption_pubkey=`).
86
+ * 404 → null (a miss); any other non-OK status → RediscoverError (abort).
87
+ */
88
+ export function makeDescriptorLookup(options
89
+
90
+
91
+
92
+ ) {
93
+ const host = options.host.replace(/\/+$/, '')
94
+ const fetchImpl = options.fetchImpl ?? fetch
95
+ return async (pubkeyHex ) => {
96
+ const res = await fetchImpl(`${host}/api/surveys?${options.param}=${pubkeyHex}`)
97
+ if (res.status === 404) return null
98
+ if (!res.ok) {
99
+ throw new RediscoverError(
100
+ 'lookup_failed',
101
+ `survey lookup failed with status ${res.status}`,
102
+ res.status
103
+ )
104
+ }
105
+ return (await res.json())
106
+ }
107
+ }