livybolt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ // `livybolt vault read --survey <id>` — pull a survey's responses and decrypt them
2
+ // locally through the VaultDecryptor seam.
3
+ //
4
+ // 1. Unlock the identity (password if encrypted) and open the vault.
5
+ // 2. Find the entry for <id> → ownerToken + surveyDecryptSecret.
6
+ // 3. GET /api/surveys/:id/responses with the owner Bearer token.
7
+ // 4. Open each encrypted envelope with the per-survey decrypt key.
8
+ //
9
+ // The decrypt secret lives only in the local vault; the server never holds a
10
+ // key that can open the envelopes it stored.
11
+
12
+ import { AgentIdentityError, agentIdentityFromSeedWords } from '../src/lib/crypto/agent-identity.js'
13
+ import { deriveSurveyKeypair } from '../src/lib/crypto/survey-key.js'
14
+ import { openVault, VaultError } from '../src/lib/crypto/token-vault.js'
15
+ import { readStore } from './agent-identity-store.mjs'
16
+ import { seedMnemonicFromRecord, surveyPrivkeyForEntry, SeedUnlockError } from './seed-unlock.mjs'
17
+ import { resolveIdentityPassword } from './cli-prompt.mjs'
18
+ import { signNip98Token } from '../src/lib/nip98.js'
19
+ import { readVault } from './token-vault-store.mjs'
20
+ import { fetchResponses, DEFAULT_HOST } from './livybolt-api.mjs'
21
+ import { rawNsecVaultDecryptor, decryptResponses } from './vault-decrypt.mjs'
22
+
23
+ function fail(msg) {
24
+ console.error(`error: ${msg}`)
25
+ process.exit(1)
26
+ }
27
+
28
+ function parseFlags(argv) {
29
+ const flags = {}
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i]
32
+ if (a === '--survey') flags.survey = argv[++i]
33
+ else if (a === '--password') flags.password = argv[++i]
34
+ else if (a === '--host') flags.host = argv[++i]
35
+ else if (a === '--since') flags.since = argv[++i]
36
+ else if (a === '--json') flags.json = true
37
+ else fail(`unknown argument: ${a}`)
38
+ }
39
+ return flags
40
+ }
41
+
42
+ const resolvePassword = (flags) => resolveIdentityPassword(flags, fail)
43
+
44
+ export async function main(argv) {
45
+ const flags = parseFlags(argv)
46
+ if (!flags.survey) fail('usage: livybolt vault read --survey <id>')
47
+
48
+ const rec = readStore()
49
+ if (!rec) fail('no identity. Run: livybolt id new')
50
+
51
+ // Unlock the seed once: derive the identity key (to open the vault) and, for
52
+ // derived surveys, the per-survey decrypt key (from the stored account index).
53
+ const mnemonic = await seedMnemonicFromRecord(rec, () => resolvePassword(flags))
54
+ const { privkey } = agentIdentityFromSeedWords(mnemonic)
55
+
56
+ const entries = await openVault(readVault(), privkey)
57
+ const entry = entries.find((e) => e.surveyId === flags.survey)
58
+ if (!entry) fail(`survey ${flags.survey} not found in the vault.`)
59
+
60
+ // Authenticate the read with NIP-98 signed by the survey key (no owner token);
61
+ // fall back to the legacy owner token only when the entry carries no key.
62
+ const host = flags.host ?? DEFAULT_HOST
63
+ const surveyPrivkey = surveyPrivkeyForEntry(entry, mnemonic)
64
+ const authorization = surveyPrivkey
65
+ ? await signNip98Token(
66
+ `${host}/api/surveys/${encodeURIComponent(entry.surveyId)}/responses`,
67
+ 'GET',
68
+ surveyPrivkey
69
+ )
70
+ : undefined
71
+
72
+ const result = await fetchResponses({
73
+ host,
74
+ surveyId: entry.surveyId,
75
+ ownerToken: entry.ownerToken,
76
+ authorization,
77
+ since: flags.since,
78
+ })
79
+
80
+ // Legacy surveys carry the nsec directly; derived surveys re-derive it from
81
+ // the seed + stored account index.
82
+ const decryptSecret =
83
+ entry.surveyDecryptSecret ??
84
+ (typeof entry.surveyIndex === 'number'
85
+ ? deriveSurveyKeypair(mnemonic, entry.surveyIndex).nsec
86
+ : null)
87
+
88
+ let rows = result.responses ?? []
89
+ if (decryptSecret) {
90
+ rows = await decryptResponses(rows, rawNsecVaultDecryptor(decryptSecret))
91
+ }
92
+
93
+ if (flags.json) {
94
+ console.log(JSON.stringify({ ...result, responses: rows }, null, 2))
95
+ return
96
+ }
97
+
98
+ console.log(`survey: ${result.survey?.title ?? entry.surveyId}`)
99
+ console.log(`collected:${result.totals?.collected ?? rows.length} locked:${result.totals?.locked ?? 0}`)
100
+ console.log('')
101
+ for (const r of rows) {
102
+ const mark = r.encrypted ? (r.decrypted ? '🔓' : '🔒') : '·'
103
+ console.log(`${mark} #${r.ordinal} ${r.submittedAt}`)
104
+ console.log(` ${JSON.stringify(r.data)}`)
105
+ }
106
+ console.log('')
107
+ console.log(`count=${rows.length}`)
108
+ }
109
+
110
+ if (import.meta.url === `file://${process.argv[1]}`) {
111
+ main(process.argv.slice(2)).catch((err) => {
112
+ if (err instanceof AgentIdentityError) fail(`${err.code}: ${err.message}`)
113
+ if (err instanceof SeedUnlockError) fail(`${err.code}: ${err.message}`)
114
+ if (err instanceof VaultError) fail(`${err.code}: ${err.message}`)
115
+ console.error(`error: ${err.message}`)
116
+ process.exit(1)
117
+ })
118
+ }
@@ -0,0 +1,121 @@
1
+ // `livybolt vault rebuild` — recover the vault from the seed alone (foundation U5).
2
+ //
3
+ // The server is accountless, so recovery works like restoring an HD wallet:
4
+ // derive survey n's key at account n, ask the server to resolve its
5
+ // encryption_pubkey (GET /surveys?encryption_pubkey=…, foundation U3), and on a
6
+ // hit re-seal a {surveyId, surveyIndex} vault entry. Because reads now use NIP-98
7
+ // signed by the survey key (foundation U4), a rebuilt entry needs no owner token
8
+ // — the seed alone restores read access. Stop after `gap` consecutive misses.
9
+ //
10
+ // Only DERIVED surveys are recoverable. Legacy random-key surveys are not
11
+ // re-derivable from the seed.
12
+
13
+ import { deriveSurveyKeypair } from '../src/lib/crypto/survey-key.js'
14
+ import { rediscoverSurveys } from '../src/lib/read/rediscover.js'
15
+ import { agentIdentityFromSeedWords } from '../src/lib/crypto/agent-identity.js'
16
+ import { sealVaultEntry, openVault } from '../src/lib/crypto/token-vault.js'
17
+ import { readStore } from './agent-identity-store.mjs'
18
+ import { readVault, appendVaultEnvelope } from './token-vault-store.mjs'
19
+ import { seedMnemonicFromRecord } from './seed-unlock.mjs'
20
+ import { resolveIdentityPassword } from './cli-prompt.mjs'
21
+ import { lookupSurveyByPubkey, DEFAULT_HOST } from './livybolt-api.mjs'
22
+ import { setNextIndex } from './survey-index-store.mjs'
23
+
24
+ function fail(msg) {
25
+ console.error(`error: ${msg}`)
26
+ process.exit(1)
27
+ }
28
+
29
+ function parseFlags(argv) {
30
+ const flags = {}
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i]
33
+ if (a === '--gap') flags.gap = Number(argv[++i])
34
+ else if (a === '--host') flags.host = argv[++i]
35
+ else if (a === '--password') flags.password = argv[++i]
36
+ else fail(`unknown argument: ${a}`)
37
+ }
38
+ return flags
39
+ }
40
+
41
+ /**
42
+ * Gap-limit scan: derive account n, look it up, collect hits, stop after
43
+ * `gapLimit` consecutive misses (HD-wallet gap limit — a gap wider than the
44
+ * limit truncates the scan). Pure: `lookup(pubkeyHex) => descriptor | null`.
45
+ * Returns every hit (`found`), the subset not already vaulted (`toAdd`), and the
46
+ * next free index.
47
+ *
48
+ * The scan itself lives in src/lib/read/rediscover.ts (shared with the
49
+ * dashboard, which runs it over VIEW pubkeys); this wrapper binds it to the
50
+ * encryption hierarchy and adds the vault-idempotence split.
51
+ */
52
+ export async function scanForSurveys({
53
+ mnemonic,
54
+ lookup,
55
+ gapLimit = 20,
56
+ existingSurveyIds = new Set(),
57
+ }) {
58
+ const { found: hits, nextIndex } = await rediscoverSurveys({
59
+ derivePubkey: (n) => deriveSurveyKeypair(mnemonic, n).pubkeyHex,
60
+ lookup,
61
+ gapLimit,
62
+ })
63
+ const found = hits.map((h) => ({ surveyId: h.surveyId, index: h.accountIndex }))
64
+ const toAdd = found.filter((h) => !existingSurveyIds.has(h.surveyId))
65
+ return { found, toAdd, nextIndex }
66
+ }
67
+
68
+ export async function main(argv) {
69
+ const flags = parseFlags(argv)
70
+ const gapLimit = Number.isInteger(flags.gap) && flags.gap > 0 ? flags.gap : 20
71
+ const host = flags.host ?? DEFAULT_HOST
72
+
73
+ const rec = readStore()
74
+ if (!rec) fail('no identity. Run: livybolt id new')
75
+ const mnemonic = await seedMnemonicFromRecord(rec, () => resolveIdentityPassword(flags, fail))
76
+ const identity = agentIdentityFromSeedWords(mnemonic)
77
+
78
+ // Read the existing vault so the rebuild is additive and idempotent. A missing
79
+ // vault reads as [] (nothing to preserve); a CORRUPT or undecryptable one must
80
+ // ABORT — swallowing it would treat the vault as empty and re-append every
81
+ // survey, or overwrite a vault we simply failed to read.
82
+ let existingSurveyIds = new Set()
83
+ const existing = readVault()
84
+ if (existing.length > 0) {
85
+ const entries = await openVault(existing, identity.privkey)
86
+ existingSurveyIds = new Set(entries.map((e) => e.surveyId))
87
+ }
88
+
89
+ // A network error (not a 404) must abort the scan, not look like a miss that
90
+ // could prematurely hit the gap limit and truncate recovery.
91
+ const lookup = (pubkeyHex) => lookupSurveyByPubkey({ host, encryptionPubkey: pubkeyHex })
92
+
93
+ const { found, toAdd, nextIndex } = await scanForSurveys({
94
+ mnemonic,
95
+ lookup,
96
+ gapLimit,
97
+ existingSurveyIds,
98
+ })
99
+
100
+ for (const hit of toAdd) {
101
+ const env = await sealVaultEntry(
102
+ { surveyId: hit.surveyId, surveyIndex: hit.index },
103
+ identity.pubkeyHex
104
+ )
105
+ appendVaultEnvelope(env)
106
+ }
107
+ setNextIndex(nextIndex)
108
+
109
+ console.log(`found=${found.length} added=${toAdd.length} next_index=${nextIndex}`)
110
+ console.error(
111
+ 'Note: only DERIVED surveys are recoverable from the seed. Legacy random-key ' +
112
+ 'surveys are not re-derivable — restore their vault.json copy to read them.'
113
+ )
114
+ }
115
+
116
+ if (import.meta.url === `file://${process.argv[1]}`) {
117
+ main(process.argv.slice(2)).catch((err) => {
118
+ console.error(`error: ${err.message}`)
119
+ process.exit(1)
120
+ })
121
+ }
@@ -0,0 +1,85 @@
1
+ // Agent Identity Key — a per-agent Nostr keypair an agent provisions CLIENT-SIDE.
2
+ //
3
+ // This is the agent's stable *name* (`npub`) and the future *lock* on its token
4
+ // vault (and, later, a *signer* for linking into a dashboard account). It is
5
+ // generated and held entirely on the client — the server never sees the secret.
6
+ //
7
+ // It is DISTINCT from the Survey Decrypt Key (see ./recipient-key.ts): that key
8
+ // decrypts one survey's *response contents*; this key is an *identity*. The two
9
+ // are functionally separate and must never be conflated. This module deliberately
10
+ // does not import recipient-key — they share only the secp256k1 + nostr-tools
11
+ // primitives, nothing else.
12
+ //
13
+ // Built on a NIP-06 seed (BIP-39 mnemonic → HD-derived key, account index 0,
14
+ // path m/44'/1237'/0'/0/0) so the *seed words* are the canonical backup and
15
+ // purpose-isolated subkeys remain possible later. Seed-at-rest encryption lives
16
+ // in ./seed-store.ts (NIP-49 of the BIP-39 entropy). Like ./recipient-key.ts this
17
+ // is isomorphic (nostr-tools only) so the same code runs in the browser, the CLI,
18
+ // and tests.
19
+
20
+ import { getPublicKey } from 'nostr-tools/pure'
21
+ import * as nip19 from 'nostr-tools/nip19'
22
+ import { generateSeedWords, validateWords, privateKeyFromSeedWords } from 'nostr-tools/nip06'
23
+
24
+ // NIP-06 account index. The identity is account 0; per-survey keys derive at
25
+ // account n (see ./survey-key.ts).
26
+ const DEFAULT_ACCOUNT_INDEX = 0
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+    
37
+
38
+    
39
+
40
+
41
+
42
+
43
+
44
+ export class AgentIdentityError extends Error {
45
+ code
46
+ constructor(code , message ) {
47
+ super(message ?? code)
48
+ this.name = 'AgentIdentityError'
49
+ this.code = code
50
+ }
51
+ }
52
+
53
+ /** Generate a fresh identity from a new BIP-39 seed. */
54
+ export function generateAgentIdentity() {
55
+ return agentIdentityFromSeedWords(generateSeedWords())
56
+ }
57
+
58
+ /**
59
+ * Restore (or derive) an identity from BIP-39 seed words — the canonical,
60
+ * recoverable path. Deterministic: the same mnemonic always yields the same key.
61
+ */
62
+ export function agentIdentityFromSeedWords(
63
+ mnemonic ,
64
+ passphrase ,
65
+ accountIndex = DEFAULT_ACCOUNT_INDEX
66
+ ) {
67
+ const words = mnemonic.trim()
68
+ if (!words) throw new AgentIdentityError('empty')
69
+ if (!validateWords(words)) {
70
+ throw new AgentIdentityError('invalid_words', 'not a valid BIP-39 mnemonic')
71
+ }
72
+ const privkey = privateKeyFromSeedWords(words, passphrase, accountIndex)
73
+ return fromPrivkey(privkey, words)
74
+ }
75
+
76
+ function fromPrivkey(privkey , seedWords ) {
77
+ const pubkeyHex = getPublicKey(privkey)
78
+ return {
79
+ seedWords,
80
+ privkey,
81
+ pubkeyHex,
82
+ nsec: nip19.nsecEncode(privkey),
83
+ npub: nip19.npubEncode(pubkeyHex),
84
+ }
85
+ }
@@ -0,0 +1,253 @@
1
+ // End-to-end encrypted response envelope.
2
+ //
3
+ // The server is content-blind: it stores and serves this object without ever
4
+ // holding a key that can open it. Encryption happens in the respondent's
5
+ // browser; decryption happens in the owner's browser.
6
+ //
7
+ // Scheme (envelope / hybrid encryption):
8
+ // 1. The response plaintext is encrypted once with a fresh random 256-bit
9
+ // content-encryption key (CEK) under AES-256-GCM (an AEAD — ciphertext is
10
+ // tamper-evident).
11
+ // 2. The CEK is wrapped to each recipient's secp256k1 pubkey with NIP-44 v2.
12
+ // A single ephemeral keypair per envelope provides the sender side of the
13
+ // ECDH (respondents are anonymous and hold no identity key); the shared
14
+ // `epk` lets each recipient re-derive their conversation key.
15
+ //
16
+ // Wrapping the small CEK per-recipient (rather than re-encrypting the bulk) is
17
+ // what makes recovery contacts cheap: add a recipient = add one wrapped key.
18
+ //
19
+ // Isomorphic: depends only on Web Crypto (`globalThis.crypto.subtle`) and
20
+ // nostr-tools, both available in modern browsers and Node ≥ 20.
21
+
22
+ import * as nip44 from 'nostr-tools/nip44'
23
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
24
+
25
+ export const ENVELOPE_VERSION = 1
26
+ /** Cap on response plaintext, matching the adopted roadmap. */
27
+ export const MAX_PLAINTEXT_BYTES = 64 * 1024
28
+
29
+ const ALG = 'AES-256-GCM'
30
+ const HEX64 = /^[0-9a-f]{64}$/
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+ export class EnvelopeError extends Error {
61
+ code
62
+ constructor(code , message ) {
63
+ super(message ?? code)
64
+ this.name = 'EnvelopeError'
65
+ this.code = code
66
+ }
67
+ }
68
+
69
+ const subtle = () => {
70
+ const c = globalThis.crypto
71
+ if (!c?.subtle) throw new EnvelopeError('decrypt_failed', 'Web Crypto unavailable')
72
+ return c.subtle
73
+ }
74
+
75
+ /**
76
+ * UTF-8 encode onto a plain `ArrayBuffer`. `TextEncoder` returns
77
+ * `Uint8Array<ArrayBufferLike>`, which lib.dom's `BufferSource` (Web Crypto)
78
+ * rejects because it permits `SharedArrayBuffer`; re-homing onto a fresh
79
+ * `ArrayBuffer` satisfies the type without an `any` cast.
80
+ */
81
+ function utf8(s ) {
82
+ const enc = new TextEncoder().encode(s)
83
+ const out = new Uint8Array(enc.length)
84
+ out.set(enc)
85
+ return out
86
+ }
87
+
88
+ /** Additional authenticated data: binds the ciphertext to its version + alg. */
89
+ function aad(version ) {
90
+ return utf8(`lightsurvey/envelope/${version}/${ALG}`)
91
+ }
92
+
93
+ export async function sealEnvelope(
94
+ plaintext ,
95
+ recipientPubkeys
96
+ ) {
97
+ if (!Array.isArray(recipientPubkeys) || recipientPubkeys.length === 0) {
98
+ throw new EnvelopeError('no_recipients')
99
+ }
100
+ for (const pk of recipientPubkeys) {
101
+ if (typeof pk !== 'string' || !HEX64.test(pk)) {
102
+ throw new EnvelopeError('invalid_recipient_pubkey', `not an x-only hex pubkey: ${pk}`)
103
+ }
104
+ }
105
+ const recipients = [...new Set(recipientPubkeys)]
106
+
107
+ const plaintextBytes = utf8(plaintext)
108
+ if (plaintextBytes.length > MAX_PLAINTEXT_BYTES) {
109
+ throw new EnvelopeError(
110
+ 'plaintext_too_large',
111
+ `${plaintextBytes.length} bytes exceeds ${MAX_PLAINTEXT_BYTES}`
112
+ )
113
+ }
114
+
115
+ // 1. Bulk encryption under a fresh CEK.
116
+ const cek = globalThis.crypto.getRandomValues(new Uint8Array(32))
117
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
118
+ const aesKey = await subtle().importKey('raw', cek, 'AES-GCM', false, ['encrypt'])
119
+ const ctBuf = await subtle().encrypt(
120
+ { name: 'AES-GCM', iv, additionalData: aad(ENVELOPE_VERSION) },
121
+ aesKey,
122
+ plaintextBytes
123
+ )
124
+
125
+ // 2. Wrap the CEK to each recipient with one shared ephemeral keypair.
126
+ const esk = generateSecretKey()
127
+ const epk = getPublicKey(esk)
128
+ const cekB64 = bytesToBase64(cek)
129
+ const keys = recipients.map((pk) => ({
130
+ pk,
131
+ wk: nip44.encrypt(cekB64, nip44.getConversationKey(esk, pk)),
132
+ }))
133
+
134
+ return {
135
+ v: ENVELOPE_VERSION,
136
+ alg: ALG,
137
+ epk,
138
+ iv: bytesToBase64(iv),
139
+ ct: bytesToBase64(new Uint8Array(ctBuf)),
140
+ keys,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The decryption seam. Opening an envelope needs exactly one capability: a
146
+ * NIP-44 v2 decrypt of the per-recipient wrapped key. Modelling that as an
147
+ * interface lets the bulk AES-GCM step stay in one place while the key-unwrap
148
+ * is provided by either a raw private key (here) or a remote signer such as a
149
+ * NIP-46 bunker (which exposes `nip44_decrypt` but never the key itself).
150
+ */
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+ /**
162
+ * Raw-private-key backend for the {@link VaultDecryptor} seam. The key lives in
163
+ * memory and performs the NIP-44 unwrap directly. The bunker backend (deferred)
164
+ * implements the same interface by delegating to the remote signer.
165
+ */
166
+ export function rawNsecDecryptor(recipientPrivkey ) {
167
+ return {
168
+ pubkey: getPublicKey(recipientPrivkey),
169
+ async nip44Decrypt(peerPubkey , ciphertext ) {
170
+ const convKey = nip44.getConversationKey(recipientPrivkey, peerPubkey)
171
+ return nip44.decrypt(ciphertext, convKey)
172
+ },
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Open an envelope through the {@link VaultDecryptor} seam. The decryptor
178
+ * supplies the NIP-44 unwrap of the CEK; the AES-256-GCM bulk decrypt is done
179
+ * here so there is exactly one copy of it regardless of key backend.
180
+ */
181
+ export async function openEnvelopeWith(
182
+ envelope ,
183
+ decryptor
184
+ ) {
185
+ if (!isEnvelope(envelope)) throw new EnvelopeError('malformed_envelope')
186
+ if (envelope.v !== ENVELOPE_VERSION) {
187
+ throw new EnvelopeError('unsupported_version', `envelope version ${envelope.v}`)
188
+ }
189
+
190
+ const entry = envelope.keys.find((k) => k.pk === decryptor.pubkey)
191
+ if (!entry) throw new EnvelopeError('not_a_recipient')
192
+
193
+ try {
194
+ const cek = base64ToBytes(await decryptor.nip44Decrypt(envelope.epk, entry.wk))
195
+ if (cek.length !== 32) throw new Error('bad CEK length')
196
+
197
+ const aesKey = await subtle().importKey('raw', cek, 'AES-GCM', false, ['decrypt'])
198
+ const ptBuf = await subtle().decrypt(
199
+ { name: 'AES-GCM', iv: base64ToBytes(envelope.iv), additionalData: aad(envelope.v) },
200
+ aesKey,
201
+ base64ToBytes(envelope.ct)
202
+ )
203
+ return new TextDecoder().decode(ptBuf)
204
+ } catch (err) {
205
+ if (err instanceof EnvelopeError) throw err
206
+ throw new EnvelopeError('decrypt_failed', err instanceof Error ? err.message : undefined)
207
+ }
208
+ }
209
+
210
+ export async function openEnvelope(
211
+ envelope ,
212
+ recipientPrivkey
213
+ ) {
214
+ return openEnvelopeWith(envelope, rawNsecDecryptor(recipientPrivkey))
215
+ }
216
+
217
+ export function isEnvelope(value ) {
218
+ if (typeof value !== 'object' || value === null) return false
219
+ const e = value
220
+ return (
221
+ typeof e.v === 'number' &&
222
+ e.alg === ALG &&
223
+ typeof e.epk === 'string' &&
224
+ typeof e.iv === 'string' &&
225
+ typeof e.ct === 'string' &&
226
+ Array.isArray(e.keys) &&
227
+ e.keys.every(
228
+ (k) =>
229
+ typeof k === 'object' &&
230
+ k !== null &&
231
+ typeof (k ).pk === 'string' &&
232
+ typeof (k ).wk === 'string'
233
+ )
234
+ )
235
+ }
236
+
237
+ // ── base64 (isomorphic; handles up to 64 KiB without call-stack blowup) ───────
238
+
239
+ function bytesToBase64(bytes ) {
240
+ let binary = ''
241
+ const CHUNK = 0x8000
242
+ for (let i = 0; i < bytes.length; i += CHUNK) {
243
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
244
+ }
245
+ return btoa(binary)
246
+ }
247
+
248
+ function base64ToBytes(b64 ) {
249
+ const binary = atob(b64)
250
+ const bytes = new Uint8Array(binary.length)
251
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
252
+ return bytes
253
+ }
@@ -0,0 +1,111 @@
1
+ // Recipient (decryption) keypair for encrypted surveys.
2
+ //
3
+ // The owner of an encrypted survey holds a secp256k1 keypair. Responses are
4
+ // sealed to its pubkey (the survey's `encryption_pubkey`); only the matching
5
+ // private key can open them. For email/passkey/anonymous owners the keypair is
6
+ // generated in the browser and the secret is shown ONCE as an `nsec` for
7
+ // backup — it never reaches the server, so losing it is permanent data loss.
8
+ // NIP-07/NIP-46 signers are the power-user path (handled elsewhere).
9
+ //
10
+ // This module is the pure key-handling core: generation, bech32 encoding, and
11
+ // robust parsing of an owner-pasted secret (nsec or hex) back into bytes. It is
12
+ // isomorphic (nostr-tools only) so the same code runs in the browser and tests.
13
+
14
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
15
+ import * as nip19 from 'nostr-tools/nip19'
16
+
17
+ const HEX64 = /^[0-9a-fA-F]{64}$/
18
+ // Shape of a bech32 string (`<hrp>1<data>`) — used to tell "wrong kind of key"
19
+ // (e.g. an npub pasted as a secret) apart from outright garbage.
20
+ const BECH32_SHAPE = /^[a-z]+1[02-9ac-hj-np-z]+$/
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+ export class RecipientKeyError extends Error {
36
+ code
37
+ constructor(code , message ) {
38
+ super(message ?? code)
39
+ this.name = 'RecipientKeyError'
40
+ this.code = code
41
+ }
42
+ }
43
+
44
+ export function generateRecipientKeypair() {
45
+ const privkey = generateSecretKey()
46
+ const pubkeyHex = getPublicKey(privkey)
47
+ return {
48
+ privkey,
49
+ pubkeyHex,
50
+ nsec: nip19.nsecEncode(privkey),
51
+ npub: nip19.npubEncode(pubkeyHex),
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Parse an owner-supplied decryption secret — either an `nsec` or raw 64-char
57
+ * hex — into the private key bytes and derived pubkey. Throws RecipientKeyError
58
+ * with a specific `code` rather than leaking bech32/parse internals to the UI.
59
+ */
60
+ export function parseRecipientSecret(input )
61
+
62
+
63
+ {
64
+ const trimmed = input.trim()
65
+ if (!trimmed) throw new RecipientKeyError('empty')
66
+
67
+ let privkey
68
+ if (HEX64.test(trimmed)) {
69
+ privkey = hexToBytes32(trimmed.toLowerCase())
70
+ } else if (BECH32_SHAPE.test(trimmed)) {
71
+ const decoded = decodeBech32(trimmed)
72
+ if (decoded.type !== 'nsec') throw new RecipientKeyError('wrong_type', `got ${decoded.type}`)
73
+ privkey = decoded.data
74
+ } else {
75
+ throw new RecipientKeyError('malformed', 'expected an nsec or 64-char hex secret')
76
+ }
77
+
78
+ return { privkey, pubkeyHex: getPublicKey(privkey) }
79
+ }
80
+
81
+ /**
82
+ * Parse a recovery-contact pubkey — either an `npub` or raw 64-char hex — into
83
+ * x-only lowercase hex. Rejects nsec/other bech32 with `wrong_type` so a pasted
84
+ * secret can never be silently stored as a recipient.
85
+ */
86
+ export function parsePubkey(input ) {
87
+ const trimmed = input.trim()
88
+ if (!trimmed) throw new RecipientKeyError('empty')
89
+
90
+ if (HEX64.test(trimmed)) return trimmed.toLowerCase()
91
+ if (BECH32_SHAPE.test(trimmed)) {
92
+ const decoded = decodeBech32(trimmed)
93
+ if (decoded.type !== 'npub') throw new RecipientKeyError('wrong_type', `got ${decoded.type}`)
94
+ return decoded.data
95
+ }
96
+ throw new RecipientKeyError('malformed', 'expected an npub or 64-char hex pubkey')
97
+ }
98
+
99
+ function decodeBech32(value ) {
100
+ try {
101
+ return nip19.decode(value)
102
+ } catch {
103
+ throw new RecipientKeyError('malformed', 'not valid bech32')
104
+ }
105
+ }
106
+
107
+ function hexToBytes32(hex ) {
108
+ const bytes = new Uint8Array(32)
109
+ for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
110
+ return bytes
111
+ }