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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +43 -0
- package/scripts/agent-identity-store.mjs +43 -0
- package/scripts/agent-identity.mjs +230 -0
- package/scripts/balance.mjs +68 -0
- package/scripts/cli-prompt.mjs +38 -0
- package/scripts/livybolt-api.mjs +253 -0
- package/scripts/livybolt.mjs +93 -0
- package/scripts/pair.mjs +116 -0
- package/scripts/pending-store.mjs +70 -0
- package/scripts/seed-unlock.mjs +111 -0
- package/scripts/survey-create.mjs +175 -0
- package/scripts/survey-index-store.mjs +88 -0
- package/scripts/token-vault-store.mjs +52 -0
- package/scripts/token-vault.mjs +94 -0
- package/scripts/unlock.mjs +148 -0
- package/scripts/vault-decrypt.mjs +38 -0
- package/scripts/vault-open.mjs +44 -0
- package/scripts/vault-read.mjs +118 -0
- package/scripts/vault-rebuild.mjs +121 -0
- package/src/lib/crypto/agent-identity.js +85 -0
- package/src/lib/crypto/envelope.js +253 -0
- package/src/lib/crypto/recipient-key.js +111 -0
- package/src/lib/crypto/seed-store.js +164 -0
- package/src/lib/crypto/survey-key.js +97 -0
- package/src/lib/crypto/token-vault.js +116 -0
- package/src/lib/crypto/view-key.js +98 -0
|
@@ -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
|
+
}
|