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,164 @@
1
+ // Seed at rest — seal/unlock a BIP-39 mnemonic as a NIP-49 `ncryptsec`.
2
+ //
3
+ // Per-survey keys derive from the agent's ONE seed (see ./survey-key.ts), so the
4
+ // seed — not the account-0 private key — is what must rest at rest. NIP-49
5
+ // encrypts a secret of 16/32 bytes; a BIP-39 mnemonic's *entropy* is exactly that
6
+ // (16 bytes for 12 words, 32 for 24), and round-trips back to the mnemonic. So we
7
+ // seal the entropy with the literal NIP-49 primitive (scrypt + XChaCha20-Poly1305)
8
+ // and reconstruct the words on unlock.
9
+ //
10
+ // CAVEAT: the resulting `ncryptsec` encodes seed ENTROPY, not a signing `nsec`.
11
+ // The seed words are the canonical portable backup; this blob is an
12
+ // at-rest/transport encoding. To stop a generic Nostr tool from silently
13
+ // importing it as a private key, the entropy is prefixed with a magic tag before
14
+ // encryption (see SEED_BLOB_HEADER): the decrypted payload is then MAGIC‖entropy
15
+ // (21 or 37 bytes), never a 32-byte secp256k1 key, so a foreign NIP-49 tool
16
+ // rejects it on length instead of deriving a wrong-but-usable key. The tag also
17
+ // lets `restore --ncryptsec` reject a foreign blob loudly rather than misreading
18
+ // arbitrary 32-byte plaintext as seed entropy.
19
+ //
20
+ // Isomorphic (nostr-tools + @scure/bip39 only) so the same code runs in the
21
+ // browser, the CLI, and tests — and can later ride into a shared `livybolt-core`.
22
+
23
+ import { encrypt as nip49Encrypt, decrypt as nip49Decrypt } from 'nostr-tools/nip49'
24
+ import { validateWords } from 'nostr-tools/nip06'
25
+ import * as bip39 from '@scure/bip39'
26
+ // The `.js` suffix is required by @scure/bip39's package exports (v2.x).
27
+ import { wordlist } from '@scure/bip39/wordlists/english.js'
28
+
29
+ // NIP-49 scrypt work factor (log2 N). 16 is the common default; the CLI uses it.
30
+ const DEFAULT_LOGN = 16
31
+
32
+ // Self-identifying tags prepended to the entropy before NIP-49 encryption:
33
+ // ASCII magic + a 1-byte format version. Bump the version if the payload
34
+ // layout ever changes. Their length (5) keeps the tagged payload off the
35
+ // 16/32-byte entropy sizes, so a tagged blob can never be mistaken for a bare
36
+ // nsec or bare entropy. Two DISTINCT tags keep the two secrets apart: a
37
+ // master-seed backup ("LVYS") can never be imported as a pairing view blob
38
+ // ("LVYV") and vice versa — the unlock functions reject a cross-tag blob
39
+ // loudly instead of quietly restoring the wrong hierarchy.
40
+ const SEED_BLOB_HEADER = new Uint8Array([0x4c, 0x56, 0x59, 0x53, 0x01]) // "LVYS" v1
41
+ const VIEW_BLOB_HEADER = new Uint8Array([0x4c, 0x56, 0x59, 0x56, 0x01]) // "LVYV" v1
42
+
43
+
44
+
45
+ export class SeedStoreError extends Error {
46
+ code
47
+ constructor(code , message ) {
48
+ super(message ?? code)
49
+ this.name = 'SeedStoreError'
50
+ this.code = code
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Seal a BIP-39 mnemonic into a NIP-49 `ncryptsec` of its entropy. The password
56
+ * is required to open it again.
57
+ */
58
+ export function sealSeed(mnemonic , password , logN = DEFAULT_LOGN) {
59
+ return sealTagged(SEED_BLOB_HEADER, mnemonic, password, logN)
60
+ }
61
+
62
+ /**
63
+ * Open a NIP-49 `ncryptsec` (of seed entropy) back into the BIP-39 mnemonic. A
64
+ * wrong password (or a corrupt blob) fails AEAD authentication → `bad_password`.
65
+ */
66
+ export function unlockSeed(ncryptsec , password ) {
67
+ return unlockTagged(
68
+ SEED_BLOB_HEADER,
69
+ ncryptsec,
70
+ password,
71
+ 'this ncryptsec is not a Livybolt seed backup (missing format tag) — restore from your seed words instead'
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Seal the VIEW seed (a 12-word view mnemonic — see ./view-key.ts) as the
77
+ * pairing blob `livybolt pair` renders. Distinct tag from the master backup, so
78
+ * neither blob can be imported as the other. The blob is a durable, portable
79
+ * credential — no nonce, TTL, or device binding by design: it stays valid
80
+ * until view-seed rotation, and a user may keep it in a password manager to
81
+ * pair future browsers without the CLI machine.
82
+ */
83
+ export function sealViewSeed(
84
+ viewMnemonic ,
85
+ password ,
86
+ logN = DEFAULT_LOGN
87
+ ) {
88
+ return sealTagged(VIEW_BLOB_HEADER, viewMnemonic, password, logN)
89
+ }
90
+
91
+ /** Open a pairing blob back into the view mnemonic (browser import path). */
92
+ export function unlockViewSeed(ncryptsec , password ) {
93
+ return unlockTagged(
94
+ VIEW_BLOB_HEADER,
95
+ ncryptsec,
96
+ password,
97
+ 'this ncryptsec is not a Livybolt pairing blob (missing view tag) — export one with `livybolt pair`'
98
+ )
99
+ }
100
+
101
+ function sealTagged(
102
+ header ,
103
+ mnemonic ,
104
+ password ,
105
+ logN
106
+ ) {
107
+ const words = mnemonic.trim()
108
+ if (!words) throw new SeedStoreError('empty', 'a non-empty mnemonic is required')
109
+ if (!password) throw new SeedStoreError('empty', 'a non-empty password is required')
110
+ if (!validateWords(words)) {
111
+ throw new SeedStoreError('invalid_words', 'not a valid BIP-39 mnemonic')
112
+ }
113
+ const entropy = bip39.mnemonicToEntropy(words, wordlist)
114
+ // Tag the entropy so the blob is self-identifying (see the headers above).
115
+ const tagged = new Uint8Array(header.length + entropy.length)
116
+ tagged.set(header, 0)
117
+ tagged.set(entropy, header.length)
118
+ return nip49Encrypt(tagged, password, logN)
119
+ }
120
+
121
+ function unlockTagged(
122
+ header ,
123
+ ncryptsec ,
124
+ password ,
125
+ untaggedMessage
126
+ ) {
127
+ if (!password) throw new SeedStoreError('empty', 'a non-empty password is required')
128
+ let payload
129
+ try {
130
+ payload = nip49Decrypt(ncryptsec.trim(), password)
131
+ } catch {
132
+ throw new SeedStoreError(
133
+ 'bad_password',
134
+ 'could not decrypt — wrong password or corrupt ncryptsec'
135
+ )
136
+ }
137
+ // NIP-49 is authenticated (XChaCha20-Poly1305), so a successful decrypt means
138
+ // the password was correct. If the plaintext lacks the expected tag it is NOT
139
+ // this kind of Livybolt blob — a foreign ncryptsec (e.g. a raw nsec), a pre-tag
140
+ // blob, or the OTHER Livybolt blob kind. Reject it loudly rather than feeding
141
+ // arbitrary bytes to entropyToMnemonic.
142
+ if (!hasHeader(header, payload)) {
143
+ throw new SeedStoreError('untagged', untaggedMessage)
144
+ }
145
+ const entropy = payload.subarray(header.length)
146
+ let mnemonic
147
+ try {
148
+ mnemonic = bip39.entropyToMnemonic(entropy, wordlist)
149
+ } catch {
150
+ throw new SeedStoreError('bad_password', 'decrypted payload is not valid seed entropy')
151
+ }
152
+ if (!validateWords(mnemonic)) {
153
+ throw new SeedStoreError('bad_password', 'decrypted payload is not a valid seed')
154
+ }
155
+ return mnemonic
156
+ }
157
+
158
+ function hasHeader(header , payload ) {
159
+ if (payload.length <= header.length) return false
160
+ for (let i = 0; i < header.length; i++) {
161
+ if (payload[i] !== header[i]) return false
162
+ }
163
+ return true
164
+ }
@@ -0,0 +1,97 @@
1
+ // Per-survey decryption keypair, DERIVED from the agent's one seed.
2
+ //
3
+ // Today an encrypted survey's decryption key is random (see
4
+ // ./recipient-key.ts `generateRecipientKeypair`) and lives only in the local
5
+ // vault — lose the vault and the responses are unrecoverable even though you
6
+ // "still have your seed." This module removes that footgun: every survey key is
7
+ // a deterministic function of the seed at a NIP-06 account index, so the seed
8
+ // words are the single backup and the vault becomes a rebuildable cache.
9
+ //
10
+ // Account 0 is RESERVED for the agent identity (see ./agent-identity.ts).
11
+ // Surveys derive at account n >= 1, so a survey decryption key can never collide
12
+ // with the identity key. These derived keys are app-internal encryption keypairs
13
+ // (they receive NIP-44-sealed responses); they are NOT Nostr identities and must
14
+ // never sign public Nostr events — that is what keeps NIP-06's "prefer a single
15
+ // nsec" identity guidance inapplicable here. We use the BIP-32/SLIP-44 (coin
16
+ // 1237) derivation math NIP-06 documents, nothing more.
17
+ //
18
+ // Isomorphic (nostr-tools only) so the same code runs in the browser, the CLI,
19
+ // and tests — and, later, the extracted livybolt-core shared by the native clients.
20
+
21
+ import { getPublicKey } from 'nostr-tools/pure'
22
+ import * as nip19 from 'nostr-tools/nip19'
23
+ import { privateKeyFromSeedWords, validateWords } from 'nostr-tools/nip06'
24
+
25
+
26
+ /** Account index reserved for the agent identity — never a survey. */
27
+ export const IDENTITY_ACCOUNT_INDEX = 0
28
+ /** First valid survey account index (surveys start here, n >= 1). */
29
+ export const FIRST_SURVEY_ACCOUNT_INDEX = 1
30
+ /** Highest valid account index — NIP-06 derives a hardened path, so the index
31
+ * must fit the BIP-32 hardened range (< 2^31). */
32
+ export const MAX_SURVEY_ACCOUNT_INDEX = 0x7fffffff
33
+
34
+ /** A survey's decryption keypair plus the account index it derives from. */
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+ export class SurveyKeyError extends Error {
43
+ code
44
+ constructor(code , message ) {
45
+ super(message ?? code)
46
+ this.name = 'SurveyKeyError'
47
+ this.code = code
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Derive survey n's decryption keypair from the agent's BIP-39 seed at NIP-06
53
+ * account index n (n >= 1). Deterministic: the same mnemonic + index always
54
+ * yields the same key, which is what makes the seed a complete backup.
55
+ *
56
+ * @param mnemonic BIP-39 seed words — the canonical backup.
57
+ * @param accountIndex NIP-06 account index; must be an integer >= 1 (0 is the
58
+ * reserved identity account).
59
+ * @param passphrase Optional BIP-39 passphrase (the "25th word"); must match
60
+ * whatever the identity was derived with.
61
+ */
62
+ export function deriveSurveyKeypair(
63
+ mnemonic ,
64
+ accountIndex ,
65
+ passphrase
66
+ ) {
67
+ const words = mnemonic.trim()
68
+ if (!words) throw new SurveyKeyError('empty')
69
+ if (!Number.isInteger(accountIndex)) {
70
+ throw new SurveyKeyError('invalid_index', 'account index must be an integer')
71
+ }
72
+ if (accountIndex < FIRST_SURVEY_ACCOUNT_INDEX) {
73
+ throw new SurveyKeyError(
74
+ 'reserved_index',
75
+ `account ${accountIndex} is reserved for the identity; surveys start at ${FIRST_SURVEY_ACCOUNT_INDEX}`
76
+ )
77
+ }
78
+ if (accountIndex > MAX_SURVEY_ACCOUNT_INDEX) {
79
+ throw new SurveyKeyError(
80
+ 'invalid_index',
81
+ `account ${accountIndex} exceeds the BIP-32 hardened range (max ${MAX_SURVEY_ACCOUNT_INDEX})`
82
+ )
83
+ }
84
+ if (!validateWords(words)) {
85
+ throw new SurveyKeyError('invalid_words', 'not a valid BIP-39 mnemonic')
86
+ }
87
+
88
+ const privkey = privateKeyFromSeedWords(words, passphrase, accountIndex)
89
+ const pubkeyHex = getPublicKey(privkey)
90
+ return {
91
+ accountIndex,
92
+ privkey,
93
+ pubkeyHex,
94
+ nsec: nip19.nsecEncode(privkey),
95
+ npub: nip19.npubEncode(pubkeyHex),
96
+ }
97
+ }
@@ -0,0 +1,116 @@
1
+ // Token Vault — a per-agent, encrypted list of the surveys an agent owns.
2
+ //
3
+ // Each entry pairs a survey with the credentials needed to (a) CLAIM it into an
4
+ // account later (`ownerToken`) and (b) READ its encrypted responses now
5
+ // (`surveyDecryptSecret` — the per-survey Survey Decrypt Key secret). Bundling
6
+ // the decrypt secret here is what makes the agent-native read/analyze loop work:
7
+ // unlock the identity once, open the vault, and every survey's responses become
8
+ // readable.
9
+ //
10
+ // Entries are sealed individually TO the Agent Identity Key's pubkey (see
11
+ // ./envelope.ts). Two deliberate properties follow:
12
+ // • APPEND needs only the pubkey — rolling a new survey in never requires
13
+ // unlocking the identity (no password).
14
+ // • READ needs the private key — only an unlocked identity can open the vault.
15
+ //
16
+ // The vault is the *lock's contents*; the Agent Identity Key is the *lock*. They
17
+ // stay separate from the Survey Decrypt Keys the entries carry. Isomorphic
18
+ // (envelope.ts + nostr-tools only); the node-only file store lives in
19
+ // scripts/token-vault-store.mjs.
20
+
21
+ // Explicit `.ts` extension: this module is also loaded directly by the raw-node
22
+ // CLI/smoke scripts (Node's type-stripper resolves relative TS imports only with
23
+ // the extension). `allowImportingTsExtensions` keeps tsc happy; bundler/vitest
24
+ // resolution is unaffected.
25
+ import { sealEnvelope, openEnvelope, isEnvelope, } from './envelope.js'
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+ export class VaultError extends Error {
52
+ code
53
+ constructor(code , message ) {
54
+ super(message ?? code)
55
+ this.name = 'VaultError'
56
+ this.code = code
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Seal one entry to the identity pubkey. Takes only a PUBLIC key — appending to
62
+ * the vault never needs the identity's secret (no unlock/password).
63
+ */
64
+ export async function sealVaultEntry(
65
+ input ,
66
+ identityPubkeyHex
67
+ ) {
68
+ const entry = { ...input, addedAt: new Date().toISOString() }
69
+ return sealEnvelope(JSON.stringify(entry), [identityPubkeyHex])
70
+ }
71
+
72
+ /** Open one sealed entry with the identity private key. */
73
+ export async function openVaultEntry(
74
+ envelope ,
75
+ identityPrivkey
76
+ ) {
77
+ // openEnvelope throws EnvelopeError (e.g. not_a_recipient) — let it propagate.
78
+ const json = await openEnvelope(envelope, identityPrivkey)
79
+ let parsed
80
+ try {
81
+ parsed = JSON.parse(json)
82
+ } catch {
83
+ throw new VaultError('malformed_entry', 'entry is not valid JSON')
84
+ }
85
+ if (!isVaultEntry(parsed)) {
86
+ throw new VaultError(
87
+ 'malformed_entry',
88
+ 'entry needs a surveyId and either an ownerToken (legacy) or a surveyIndex (derived)'
89
+ )
90
+ }
91
+ return parsed
92
+ }
93
+
94
+ /** Open an entire vault (array of sealed envelopes) in order. */
95
+ export async function openVault(
96
+ envelopes ,
97
+ identityPrivkey
98
+ ) {
99
+ const out = []
100
+ for (const env of envelopes) out.push(await openVaultEntry(env, identityPrivkey))
101
+ return out
102
+ }
103
+
104
+ /** A vault is stored as an array of these sealed envelopes. */
105
+ export function isVaultEnvelope(value ) {
106
+ return isEnvelope(value)
107
+ }
108
+
109
+ function isVaultEntry(value ) {
110
+ if (typeof value !== 'object' || value === null) return false
111
+ const e = value
112
+ if (typeof e.surveyId !== 'string') return false
113
+ // A legacy entry is keyed by its ownerToken; a derived (rebuilt) entry by its
114
+ // surveyIndex. Exactly one is required — an entry with neither is malformed.
115
+ return typeof e.ownerToken === 'string' || typeof e.surveyIndex === 'number'
116
+ }
@@ -0,0 +1,98 @@
1
+ // View-seed hierarchy — the browser's read-only root, derived ONE-WAY from the
2
+ // master seed.
3
+ //
4
+ // The dashboard must decrypt responses without ever holding the master seed
5
+ // (see docs/plans/2026-07-02-001-feat-view-seed-web-dashboard-plan.md). This
6
+ // module is the split: a VIEW SEED is computed from the master mnemonic via
7
+ // HKDF with a domain-separation tag, and per-survey VIEW KEYS derive from it at
8
+ // the SAME NIP-06 account index as the survey's encryption key. One-way by
9
+ // construction: view seed → view keys is derivable; view seed → master is not.
10
+ // Same-index derivation is what makes browser rediscovery and seed-only
11
+ // recovery free — one scan algorithm, two hierarchies.
12
+ //
13
+ // The HKDF input is the BIP-39 *seed* (mnemonicToSeed of words + optional
14
+ // passphrase), not the raw entropy: the passphrase ("25th word") must change
15
+ // the view hierarchy exactly as it changes every other derived key, and the
16
+ // passphrase never touches the entropy. The 16-byte HKDF output becomes a
17
+ // 12-word view MNEMONIC, so downstream derivation reuses the canonical NIP-06
18
+ // `privateKeyFromSeedWords` unchanged (and the view seed seals/transports via
19
+ // the same NIP-49 machinery as the master, under its own blob tag).
20
+ //
21
+ // View keys are app-internal encryption keypairs (they receive NIP-44-sealed
22
+ // CEK wraps); they are NOT Nostr identities and must never sign public Nostr
23
+ // events — the same rule as ./survey-key.ts.
24
+ //
25
+ // Isomorphic (nostr-tools + @scure/bip39 + @noble/hashes only) so the same
26
+ // code runs in the browser, the CLI, and tests.
27
+
28
+ import { hkdf } from '@noble/hashes/hkdf.js'
29
+ import { sha256 } from '@noble/hashes/sha2.js'
30
+ import { validateWords } from 'nostr-tools/nip06'
31
+ import * as bip39 from '@scure/bip39'
32
+ // The `.js` suffix is required by @scure/bip39's package exports (v2.x).
33
+ import { wordlist } from '@scure/bip39/wordlists/english.js'
34
+ // Explicit `.ts` extension: this module is also loaded directly by the raw-node
35
+ // CLI scripts (`livybolt pair`, `livybolt create`) — Node's type-stripper resolves
36
+ // relative TS imports only with the extension (same rule as ./token-vault.ts).
37
+ import {
38
+ deriveSurveyKeypair,
39
+ SurveyKeyError,
40
+
41
+ } from './survey-key.js'
42
+
43
+ /** HKDF domain-separation tag. Bump the version if the derivation ever changes
44
+ * — existing view seeds would rotate, master-derived keys would not. */
45
+ export const VIEW_SEED_TAG = 'lightsurvey/view-seed/v1'
46
+
47
+ /** A survey's view keypair plus the account index it derives from. Shape-
48
+ * identical to the encryption keypair — same envelope seam, different root. */
49
+
50
+
51
+
52
+
53
+ export class ViewKeyError extends Error {
54
+ code
55
+ constructor(code , message ) {
56
+ super(message ?? code)
57
+ this.name = 'ViewKeyError'
58
+ this.code = code
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Derive the view seed (a 12-word mnemonic) one-way from the master mnemonic.
64
+ * Deterministic: the same master words + passphrase always yield the same view
65
+ * seed, so the 12 master words remain the single canonical backup — the view
66
+ * seed needs none of its own.
67
+ *
68
+ * @param mnemonic Master BIP-39 seed words.
69
+ * @param passphrase Optional BIP-39 passphrase; must match whatever the
70
+ * identity/survey keys were derived with (it changes the view seed too).
71
+ */
72
+ export function deriveViewSeed(mnemonic , passphrase ) {
73
+ const words = mnemonic.trim()
74
+ if (!words) throw new ViewKeyError('empty', 'a non-empty mnemonic is required')
75
+ if (!validateWords(words)) {
76
+ throw new ViewKeyError('invalid_words', 'not a valid BIP-39 mnemonic')
77
+ }
78
+ const masterSeed = bip39.mnemonicToSeedSync(words, passphrase)
79
+ const tag = new TextEncoder().encode(VIEW_SEED_TAG)
80
+ const viewEntropy = hkdf(sha256, masterSeed, undefined, tag, 16)
81
+ return bip39.entropyToMnemonic(viewEntropy, wordlist)
82
+ }
83
+
84
+ /**
85
+ * Derive survey n's VIEW keypair from the view seed at NIP-06 account index n
86
+ * (n >= 1, account 0 reserved — mirroring the encryption hierarchy so the two
87
+ * stay index-parallel). Runs the same guards and math as
88
+ * `deriveSurveyKeypair`, just over the view seed.
89
+ */
90
+ export function deriveViewKeypair(viewSeed , accountIndex ) {
91
+ try {
92
+ return deriveSurveyKeypair(viewSeed, accountIndex)
93
+ } catch (e) {
94
+ // Re-type so callers of this module handle one error surface.
95
+ if (e instanceof SurveyKeyError) throw new ViewKeyError(e.code, e.message)
96
+ throw e
97
+ }
98
+ }