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 +0 -1
- package/package.json +3 -2
- package/scripts/agent-identity.mjs +3 -3
- package/scripts/seed-unlock.mjs +1 -1
- package/scripts/token-vault.mjs +1 -1
- package/src/lib/nip98.js +129 -0
- package/src/lib/read/fetch-responses.js +104 -0
- package/src/lib/read/rediscover.js +107 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livybolt",
|
|
3
|
-
"version": "0.1.
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
package/scripts/seed-unlock.mjs
CHANGED
|
@@ -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:
|
|
34
|
+
`no identity at ${storePath()}. Run: livybolt id new`
|
|
35
35
|
)
|
|
36
36
|
}
|
|
37
37
|
if (rec.secretKind === 'seed-plaintext') {
|
package/scripts/token-vault.mjs
CHANGED
package/src/lib/nip98.js
ADDED
|
@@ -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
|
+
}
|