nebula-ai-core 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.
Files changed (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
@@ -0,0 +1,47 @@
1
+ export { encryptKey, decryptKey, type EncryptedKeystore } from './keystore'
2
+ export {
3
+ generateAgentWallet,
4
+ saveKeystore,
5
+ loadKeystore,
6
+ type AgentWalletMaterial,
7
+ } from './eoa'
8
+ export {
9
+ OPERATOR_KEYSTORE_VERSION,
10
+ OPERATOR_BLOB_SCOPES,
11
+ type OperatorBlobScope,
12
+ encryptAgentKey,
13
+ decryptAgentKey,
14
+ encryptOperatorBlob,
15
+ decryptOperatorBlob,
16
+ encodeKeystoreBytes,
17
+ decodeKeystoreBytes,
18
+ encodeOperatorBlobBytes,
19
+ decodeOperatorBlobBytes,
20
+ sniffKeystoreVersion,
21
+ deriveKeystoreKey,
22
+ deriveBlobKey,
23
+ deriveLegacyEmptyDomainKey,
24
+ tryDecryptKeystoreWithKey,
25
+ tryDecryptOperatorBlobWithKey,
26
+ type OperatorEncryptedKeystore,
27
+ type OperatorEncryptedBlob,
28
+ } from './operator-keystore-crypto'
29
+ export {
30
+ OPERATOR_SESSION_VERSION,
31
+ DEFAULT_OPERATOR_SESSION_TTL_MS,
32
+ type OperatorSession,
33
+ type OperatorSessionKeys,
34
+ type PrecomputeAllScopesOpts,
35
+ type PrecomputeVerifyKey,
36
+ operatorSessionPath,
37
+ writeOperatorSession,
38
+ readOperatorSession,
39
+ clearOperatorSession,
40
+ isOperatorSessionFresh,
41
+ isOperatorSessionComplete,
42
+ requiredScopesForAgent,
43
+ getSessionKey,
44
+ precomputeAllScopes,
45
+ buildOperatorSession,
46
+ } from './operator-session'
47
+ export { drainAgentEOA, type DrainAgentResult } from './drain'
@@ -0,0 +1,50 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
2
+
3
+ /**
4
+ * Simple AES-256-GCM keystore for the agent EOA privkey. Passphrase-derived
5
+ * key via scrypt. Format packs salt || iv || tag || ciphertext in base64.
6
+ */
7
+ export interface EncryptedKeystore {
8
+ version: 1
9
+ /** Base64-encoded `salt(16) || iv(12) || tag(16) || ciphertext`. */
10
+ blob: string
11
+ }
12
+
13
+ const KEY_LEN = 32
14
+ const SCRYPT_N = 2 ** 15
15
+ const SCRYPT_R = 8
16
+ const SCRYPT_P = 1
17
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024
18
+
19
+ function derive(passphrase: string, salt: Buffer): Buffer {
20
+ return scryptSync(passphrase, salt, KEY_LEN, {
21
+ N: SCRYPT_N,
22
+ r: SCRYPT_R,
23
+ p: SCRYPT_P,
24
+ maxmem: SCRYPT_MAXMEM,
25
+ })
26
+ }
27
+
28
+ export function encryptKey(privkey: Uint8Array, passphrase: string): EncryptedKeystore {
29
+ const salt = randomBytes(16)
30
+ const iv = randomBytes(12)
31
+ const key = derive(passphrase, salt)
32
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
33
+ const ct = Buffer.concat([cipher.update(Buffer.from(privkey)), cipher.final()])
34
+ const tag = cipher.getAuthTag()
35
+ const blob = Buffer.concat([salt, iv, tag, ct]).toString('base64')
36
+ return { version: 1, blob }
37
+ }
38
+
39
+ export function decryptKey(keystore: EncryptedKeystore, passphrase: string): Uint8Array {
40
+ if (keystore.version !== 1) throw new Error(`Unsupported keystore version: ${keystore.version}`)
41
+ const buf = Buffer.from(keystore.blob, 'base64')
42
+ const salt = buf.subarray(0, 16)
43
+ const iv = buf.subarray(16, 28)
44
+ const tag = buf.subarray(28, 44)
45
+ const ct = buf.subarray(44)
46
+ const key = derive(passphrase, salt)
47
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
48
+ decipher.setAuthTag(tag)
49
+ return new Uint8Array(Buffer.concat([decipher.update(ct), decipher.final()]))
50
+ }
@@ -0,0 +1,530 @@
1
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
2
+ import { type Address, type Hex, bytesToHex, hexToBytes } from 'viem'
3
+ import type { OperatorSigner } from '../operator/signer'
4
+
5
+ /**
6
+ * Phase 6.6 keystore: agent privkey encrypted with a key derived from the
7
+ * operator's wallet signature. Replaces the v0.5.0 passphrase-based keystore.
8
+ *
9
+ * Why sign-derived-key (not ECIES)? ECIES needs the operator's public key
10
+ * (not just address), and recovering pubkey from a chain-only operator means
11
+ * waiting for them to sign at least once. Sign-derived-key works the same way
12
+ * for every wallet that can sign EIP-712 typed data, which every operator
13
+ * source we support already does (raw privkey via viem, keychain via the
14
+ * raw path, keystore-file via ethers decrypt then viem, WalletConnect via
15
+ * `eth_signTypedData_v4`).
16
+ *
17
+ * Determinism: ECDSA signing under RFC 6979 (deterministic k) gives the same
18
+ * signature for the same `(privkey, message)` every time. viem's
19
+ * `privateKeyToAccount` uses `@noble/secp256k1` which is RFC 6979 by default;
20
+ * MetaMask, Rainbow, Coinbase, Trust, Zerion, Ledger, Trezor all use RFC 6979
21
+ * for EIP-712. So the same operator account always regenerates the same key.
22
+ *
23
+ * Phishing protection: EIP-712 typed data shows the wallet UI a structured
24
+ * "Nebula Keystore" message (not an opaque hex blob), so a malicious site can't
25
+ * prompt the operator to sign this thinking it's a login.
26
+ *
27
+ * Format:
28
+ * raw blob bytes = iv(12) || tag(16) || ciphertext
29
+ * on-disk JSON = { version: 2, blob: base64(raw blob bytes) }
30
+ */
31
+ export const OPERATOR_KEYSTORE_VERSION = 2 as const
32
+
33
+ const KS_DOMAIN = { name: 'Nebula Keystore', version: '1' } as const
34
+ const KS_TYPES = {
35
+ AgentKeystore: [
36
+ { name: 'agent', type: 'address' },
37
+ { name: 'purpose', type: 'string' },
38
+ ],
39
+ } as const
40
+ const KS_PRIMARY = 'AgentKeystore' as const
41
+ const KS_PURPOSE = 'nebula-keystore-v1'
42
+ const HKDF_INFO_KEYSTORE = Buffer.from('nebula-keystore-aead-v1', 'utf8')
43
+
44
+ /**
45
+ * Scope strings used as the EIP-712 `purpose` field. New scopes get their own
46
+ * derived key (different signature, different HKDF output) so a phishing site
47
+ * cannot replay one scope's signature against another. Add new scopes here as
48
+ * Phase 12 / Phase 13 needs them.
49
+ */
50
+ export const OPERATOR_BLOB_SCOPES = {
51
+ KEYSTORE: 'nebula-keystore-v1',
52
+ TELEGRAM: 'nebula-telegram-v1',
53
+ PROFILE: 'nebula-profile-v1',
54
+ } as const
55
+ export type OperatorBlobScope =
56
+ | (typeof OPERATOR_BLOB_SCOPES)[keyof typeof OPERATOR_BLOB_SCOPES]
57
+ | string
58
+
59
+ export interface OperatorEncryptedKeystore {
60
+ version: typeof OPERATOR_KEYSTORE_VERSION
61
+ /** Base64 of `iv(12) || tag(16) || ciphertext`. */
62
+ blob: string
63
+ }
64
+
65
+ /**
66
+ * Versioned, scoped operator-encrypted blob. Used for non-keystore secrets
67
+ * (e.g. telegram bot token + allowlisted user ids).
68
+ *
69
+ * `scope` is the EIP-712 `purpose` field used to derive the AEAD key, and is
70
+ * persisted on disk so the loader can route to the correct decrypt scope
71
+ * without prompting twice.
72
+ */
73
+ export interface OperatorEncryptedBlob {
74
+ version: typeof OPERATOR_KEYSTORE_VERSION
75
+ scope: OperatorBlobScope
76
+ /** Base64 of `iv(12) || tag(16) || ciphertext`. */
77
+ blob: string
78
+ }
79
+
80
+ function hkdfKeyFromSigHex(sigHex: string, info: Buffer): Buffer {
81
+ const rs = sigHex.slice(2, 130)
82
+ const ikm = Buffer.from(rs, 'hex')
83
+ if (ikm.length !== 64) {
84
+ throw new Error(
85
+ `Operator signature has unexpected length: ${ikm.length} bytes (expected 64). This source may not produce a 65-byte ECDSA signature; switch operator wallets.`,
86
+ )
87
+ }
88
+ return Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), info, 32))
89
+ }
90
+
91
+ /**
92
+ * v0.24.9: backward-compat fallback used during decrypt when the canonical
93
+ * key fails AES-GCM. Only the pre-v0.24.9 WalletConnect signer exposes
94
+ * `signTypedDataLegacyEmptyDomain` on its Account object (raw-privkey,
95
+ * keystore-file, keychain all go through viem's canonical hashTypedData).
96
+ * Returns null when the signer doesn't expose the legacy method (canonical
97
+ * path was correct, AES-GCM failure means actually-wrong-key).
98
+ *
99
+ * See `feedback-wc-signTypedData-eip712domain-trap.md` for the full root
100
+ * cause: legacy WC shipped typed-data verbatim without `EIP712Domain` in
101
+ * types, so MetaMask's `sanitizeData` inserted `EIP712Domain: []` (empty)
102
+ * and hashed the domain separator over `keccak256("EIP712Domain()")`,
103
+ * diverging from viem's canonical hash over the populated field list.
104
+ */
105
+ type LegacyEmptyDomainSigner = {
106
+ signTypedDataLegacyEmptyDomain?: (typedData: unknown) => Promise<`0x${string}`>
107
+ }
108
+
109
+ async function tryDeriveLegacyEmptyDomainKey(
110
+ signer: OperatorSigner,
111
+ agent: Address,
112
+ scope: OperatorBlobScope,
113
+ info: Buffer,
114
+ ): Promise<Buffer | null> {
115
+ const account = (await signer.account()) as unknown as LegacyEmptyDomainSigner
116
+ const legacy = account.signTypedDataLegacyEmptyDomain
117
+ if (!legacy) return null
118
+ try {
119
+ const sigHex = await legacy({
120
+ domain: KS_DOMAIN,
121
+ types: KS_TYPES,
122
+ primaryType: KS_PRIMARY,
123
+ message: { agent, purpose: scope },
124
+ })
125
+ return hkdfKeyFromSigHex(sigHex, info)
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ /**
132
+ * v0.24.10: Public wrapper around `tryDeriveLegacyEmptyDomainKey` so the
133
+ * operator-session verify-and-swap path can derive a legacy variant key for
134
+ * any scope without re-implementing the EIP-712 plumbing. Returns null when
135
+ * the signer doesn't expose the legacy escape hatch (every LocalAccount
136
+ * signer) OR the legacy sign call throws.
137
+ */
138
+ export async function deriveLegacyEmptyDomainKey(
139
+ signer: OperatorSigner,
140
+ agent: Address,
141
+ scope: 'keystore' | OperatorBlobScope,
142
+ ): Promise<Buffer | null> {
143
+ const rawScope = scope === 'keystore' ? KS_PURPOSE : scope
144
+ const info = scope === 'keystore' ? HKDF_INFO_KEYSTORE : hkdfInfoForScope(scope)
145
+ return tryDeriveLegacyEmptyDomainKey(signer, agent, rawScope, info)
146
+ }
147
+
148
+ function isAesGcmAuthError(e: unknown): boolean {
149
+ const msg = (e as Error | undefined)?.message ?? ''
150
+ // Node + Bun crypto both surface AES-GCM tag failures as
151
+ // "Unsupported state or unable to authenticate data". Any other
152
+ // crypto error (wrong key length, bad input length) should rethrow
153
+ // so callers see the real cause.
154
+ return msg.includes('Unsupported state or unable to authenticate')
155
+ }
156
+
157
+ function hkdfInfoForScope(scope: OperatorBlobScope): Buffer {
158
+ return Buffer.from(`nebula-aead-${scope}`, 'utf8')
159
+ }
160
+
161
+ async function deriveScopedKey(
162
+ signer: OperatorSigner,
163
+ scope: OperatorBlobScope,
164
+ agent: Address,
165
+ ): Promise<Buffer> {
166
+ const account = await signer.account()
167
+ const sigHex = await account.signTypedData({
168
+ domain: KS_DOMAIN,
169
+ types: KS_TYPES,
170
+ primaryType: KS_PRIMARY,
171
+ message: { agent, purpose: scope },
172
+ })
173
+ return hkdfKeyFromSigHex(sigHex, hkdfInfoForScope(scope))
174
+ }
175
+
176
+ async function deriveKey(signer: OperatorSigner, agent: Address): Promise<Buffer> {
177
+ // Uses HKDF_INFO_KEYSTORE (not the per-scope info) to keep the on-disk
178
+ // format backward-compatible with pre-Phase-12 keystores.
179
+ const account = await signer.account()
180
+ const sigHex = await account.signTypedData({
181
+ domain: KS_DOMAIN,
182
+ types: KS_TYPES,
183
+ primaryType: KS_PRIMARY,
184
+ message: { agent, purpose: KS_PURPOSE },
185
+ })
186
+ return hkdfKeyFromSigHex(sigHex, HKDF_INFO_KEYSTORE)
187
+ }
188
+
189
+ export async function encryptAgentKey(opts: {
190
+ signer?: OperatorSigner
191
+ agentAddress: Address
192
+ agentPrivkey: Hex
193
+ /**
194
+ * v0.23.1: Optional pre-derived AES-256 key (32 bytes). When present, skips
195
+ * `signer.signTypedData` entirely. Used by `nebula init` to share the same
196
+ * key derivation between the keystore encryption and the operator-session
197
+ * cache write, so the operator signs once (not twice) for the keystore scope.
198
+ */
199
+ precomputedKey?: Buffer
200
+ }): Promise<OperatorEncryptedKeystore> {
201
+ let key: Buffer
202
+ if (opts.precomputedKey) {
203
+ if (opts.precomputedKey.length !== 32) {
204
+ throw new Error(`Precomputed key must be 32 bytes, got ${opts.precomputedKey.length}`)
205
+ }
206
+ key = opts.precomputedKey
207
+ } else {
208
+ if (!opts.signer) {
209
+ throw new Error('encryptAgentKey requires either signer or precomputedKey')
210
+ }
211
+ key = await deriveKey(opts.signer, opts.agentAddress)
212
+ }
213
+ const iv = randomBytes(12)
214
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
215
+ const pt = Buffer.from(hexToBytes(opts.agentPrivkey))
216
+ const ct = Buffer.concat([cipher.update(pt), cipher.final()])
217
+ const tag = cipher.getAuthTag()
218
+ const blob = Buffer.concat([iv, tag, ct]).toString('base64')
219
+ return { version: OPERATOR_KEYSTORE_VERSION, blob }
220
+ }
221
+
222
+ function decryptAesGcmStrict(key: Buffer, iv: Buffer, tag: Buffer, ct: Buffer): Buffer {
223
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
224
+ decipher.setAuthTag(tag)
225
+ return Buffer.concat([decipher.update(ct), decipher.final()])
226
+ }
227
+
228
+ /**
229
+ * v0.24.9: try canonical key first; on AES-GCM auth failure, try the
230
+ * legacy empty-EIP712Domain variant for backwards-compat decrypt of
231
+ * pre-v0.24.9 WalletConnect-init'd blobs. Only the legacy-WC Account
232
+ * exposes `signTypedDataLegacyEmptyDomain`, so this fallback is a no-op
233
+ * for LocalAccount signers (raw-privkey, keystore-file, keychain) which
234
+ * always produce the canonical hash. When `precomputedKey` is supplied
235
+ * the fallback is intentionally skipped: callers cache one variant and
236
+ * we trust the cache.
237
+ */
238
+ async function decryptAesGcmWithLegacyFallback(opts: {
239
+ canonicalKey: Buffer
240
+ iv: Buffer
241
+ tag: Buffer
242
+ ct: Buffer
243
+ signer?: OperatorSigner
244
+ agentAddress: Address
245
+ scope: OperatorBlobScope
246
+ hkdfInfo: Buffer
247
+ hadPrecomputedKey: boolean
248
+ }): Promise<Buffer> {
249
+ try {
250
+ return decryptAesGcmStrict(opts.canonicalKey, opts.iv, opts.tag, opts.ct)
251
+ } catch (canonicalError) {
252
+ if (!isAesGcmAuthError(canonicalError) || opts.hadPrecomputedKey || !opts.signer) {
253
+ throw canonicalError
254
+ }
255
+ const legacyKey = await tryDeriveLegacyEmptyDomainKey(
256
+ opts.signer,
257
+ opts.agentAddress,
258
+ opts.scope,
259
+ opts.hkdfInfo,
260
+ )
261
+ if (!legacyKey) throw canonicalError
262
+ return decryptAesGcmStrict(legacyKey, opts.iv, opts.tag, opts.ct)
263
+ }
264
+ }
265
+
266
+ export async function decryptAgentKey(opts: {
267
+ signer?: OperatorSigner
268
+ agentAddress: Address
269
+ keystore: OperatorEncryptedKeystore
270
+ /**
271
+ * Optional pre-derived AES-256 key (32 bytes). When present, skips
272
+ * `signer.signTypedData` entirely. Used by the headless gateway path: a
273
+ * prior interactive `nebula gateway start` derives the key once via the
274
+ * operator signer, persists it in the operator-session file, and the
275
+ * gateway daemon reads it from there at boot. Bypasses Touch ID at every
276
+ * daemon restart while preserving the keystore-derivation security model
277
+ * (key is fully equivalent to what `signer` would produce, just cached).
278
+ */
279
+ precomputedKey?: Buffer
280
+ }): Promise<Hex> {
281
+ if (opts.keystore.version !== OPERATOR_KEYSTORE_VERSION) {
282
+ throw new Error(
283
+ `Unsupported operator keystore version: ${opts.keystore.version} (expected ${OPERATOR_KEYSTORE_VERSION}). For v1 (passphrase) keystores, run \`nebula migrate-keystore\` first.`,
284
+ )
285
+ }
286
+ const buf = Buffer.from(opts.keystore.blob, 'base64')
287
+ if (buf.length < 12 + 16 + 1) {
288
+ throw new Error(`Operator keystore blob too short: ${buf.length} bytes`)
289
+ }
290
+ const iv = buf.subarray(0, 12)
291
+ const tag = buf.subarray(12, 28)
292
+ const ct = buf.subarray(28)
293
+ let canonicalKey: Buffer
294
+ if (opts.precomputedKey) {
295
+ if (opts.precomputedKey.length !== 32) {
296
+ throw new Error(`Precomputed key must be 32 bytes, got ${opts.precomputedKey.length}`)
297
+ }
298
+ canonicalKey = opts.precomputedKey
299
+ } else {
300
+ if (!opts.signer) {
301
+ throw new Error('decryptAgentKey requires either signer or precomputedKey')
302
+ }
303
+ canonicalKey = await deriveKey(opts.signer, opts.agentAddress)
304
+ }
305
+ const pt = await decryptAesGcmWithLegacyFallback({
306
+ canonicalKey,
307
+ iv,
308
+ tag,
309
+ ct,
310
+ signer: opts.signer,
311
+ agentAddress: opts.agentAddress,
312
+ scope: KS_PURPOSE,
313
+ hkdfInfo: HKDF_INFO_KEYSTORE,
314
+ hadPrecomputedKey: Boolean(opts.precomputedKey),
315
+ })
316
+ return bytesToHex(new Uint8Array(pt))
317
+ }
318
+
319
+ export function encodeKeystoreBytes(ks: OperatorEncryptedKeystore): Uint8Array {
320
+ return new TextEncoder().encode(JSON.stringify(ks))
321
+ }
322
+
323
+ export function decodeKeystoreBytes(bytes: Uint8Array): OperatorEncryptedKeystore {
324
+ const parsed = JSON.parse(new TextDecoder().decode(bytes))
325
+ if (typeof parsed !== 'object' || parsed === null) {
326
+ throw new Error('Keystore bytes do not parse to an object')
327
+ }
328
+ if (parsed.version !== OPERATOR_KEYSTORE_VERSION) {
329
+ throw new Error(
330
+ `Keystore bytes have version ${parsed.version}, expected ${OPERATOR_KEYSTORE_VERSION}`,
331
+ )
332
+ }
333
+ if (typeof parsed.blob !== 'string') {
334
+ throw new Error('Keystore bytes have invalid blob field')
335
+ }
336
+ return parsed as OperatorEncryptedKeystore
337
+ }
338
+
339
+ /**
340
+ * Encrypt an arbitrary operator-owned secret blob with a scope-derived key.
341
+ * Phase 12 uses this to persist `{telegram: {botToken, allowedUserIds}}` to
342
+ * `~/.nebula/agents/<id>/telegram-secrets.encrypted`.
343
+ *
344
+ * Each scope (`OPERATOR_BLOB_SCOPES.*`) gets its own EIP-712 sig + HKDF
345
+ * output. A phishing site that obtains one scope's sig cannot decrypt another.
346
+ */
347
+ export async function encryptOperatorBlob(opts: {
348
+ signer?: OperatorSigner
349
+ scope: OperatorBlobScope
350
+ agentAddress?: Address
351
+ plaintext: Uint8Array
352
+ /** Pre-derived scope key (32 bytes). When provided, skips signer derivation. */
353
+ precomputedKey?: Buffer
354
+ }): Promise<OperatorEncryptedBlob> {
355
+ let key: Buffer
356
+ if (opts.precomputedKey) {
357
+ if (opts.precomputedKey.length !== 32) {
358
+ throw new Error(`Precomputed key must be 32 bytes, got ${opts.precomputedKey.length}`)
359
+ }
360
+ key = opts.precomputedKey
361
+ } else {
362
+ if (!opts.signer || !opts.agentAddress) {
363
+ throw new Error('encryptOperatorBlob requires either signer+agentAddress or precomputedKey')
364
+ }
365
+ key = await deriveScopedKey(opts.signer, opts.scope, opts.agentAddress)
366
+ }
367
+ const iv = randomBytes(12)
368
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
369
+ const ct = Buffer.concat([cipher.update(Buffer.from(opts.plaintext)), cipher.final()])
370
+ const tag = cipher.getAuthTag()
371
+ const blob = Buffer.concat([iv, tag, ct]).toString('base64')
372
+ return { version: OPERATOR_KEYSTORE_VERSION, scope: opts.scope, blob }
373
+ }
374
+
375
+ export async function decryptOperatorBlob(opts: {
376
+ signer?: OperatorSigner
377
+ scope: OperatorBlobScope
378
+ agentAddress: Address
379
+ blob: OperatorEncryptedBlob
380
+ /** Pre-derived scope key (32 bytes). Skips signer when present. */
381
+ precomputedKey?: Buffer
382
+ }): Promise<Uint8Array> {
383
+ if (opts.blob.version !== OPERATOR_KEYSTORE_VERSION) {
384
+ throw new Error(
385
+ `Unsupported operator blob version: ${opts.blob.version} (expected ${OPERATOR_KEYSTORE_VERSION}).`,
386
+ )
387
+ }
388
+ if (opts.blob.scope !== opts.scope) {
389
+ throw new Error(
390
+ `Operator blob scope mismatch: blob has '${opts.blob.scope}', expected '${opts.scope}'. Refusing to decrypt across scopes.`,
391
+ )
392
+ }
393
+ const buf = Buffer.from(opts.blob.blob, 'base64')
394
+ if (buf.length < 12 + 16 + 1) {
395
+ throw new Error(`Operator blob too short: ${buf.length} bytes`)
396
+ }
397
+ const iv = buf.subarray(0, 12)
398
+ const tag = buf.subarray(12, 28)
399
+ const ct = buf.subarray(28)
400
+ let canonicalKey: Buffer
401
+ if (opts.precomputedKey) {
402
+ if (opts.precomputedKey.length !== 32) {
403
+ throw new Error(`Precomputed key must be 32 bytes, got ${opts.precomputedKey.length}`)
404
+ }
405
+ canonicalKey = opts.precomputedKey
406
+ } else {
407
+ if (!opts.signer) {
408
+ throw new Error('decryptOperatorBlob requires either signer or precomputedKey')
409
+ }
410
+ canonicalKey = await deriveScopedKey(opts.signer, opts.scope, opts.agentAddress)
411
+ }
412
+ const pt = await decryptAesGcmWithLegacyFallback({
413
+ canonicalKey,
414
+ iv,
415
+ tag,
416
+ ct,
417
+ signer: opts.signer,
418
+ agentAddress: opts.agentAddress,
419
+ scope: opts.scope,
420
+ hkdfInfo: hkdfInfoForScope(opts.scope),
421
+ hadPrecomputedKey: Boolean(opts.precomputedKey),
422
+ })
423
+ return new Uint8Array(pt)
424
+ }
425
+
426
+ /**
427
+ * Derive the legacy keystore AES key for `decryptAgentKey`. Public so the
428
+ * operator-session writer can pre-derive once and cache. Headless gateway
429
+ * boots from the cached key.
430
+ */
431
+ export async function deriveKeystoreKey(signer: OperatorSigner, agent: Address): Promise<Buffer> {
432
+ return deriveKey(signer, agent)
433
+ }
434
+
435
+ /**
436
+ * Derive a scope-specific AES key for `decryptOperatorBlob`. Same caching
437
+ * use case as `deriveKeystoreKey`.
438
+ */
439
+ export async function deriveBlobKey(
440
+ signer: OperatorSigner,
441
+ agent: Address,
442
+ scope: OperatorBlobScope,
443
+ ): Promise<Buffer> {
444
+ return deriveScopedKey(signer, scope, agent)
445
+ }
446
+
447
+ export function encodeOperatorBlobBytes(blob: OperatorEncryptedBlob): Uint8Array {
448
+ return new TextEncoder().encode(JSON.stringify(blob))
449
+ }
450
+
451
+ export function decodeOperatorBlobBytes(bytes: Uint8Array): OperatorEncryptedBlob {
452
+ const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
453
+ if (parsed === null || typeof parsed !== 'object') {
454
+ throw new Error('Operator blob bytes do not parse to an object')
455
+ }
456
+ if (parsed.version !== OPERATOR_KEYSTORE_VERSION) {
457
+ throw new Error(
458
+ `Operator blob version mismatch: got ${parsed.version}, expected ${OPERATOR_KEYSTORE_VERSION}`,
459
+ )
460
+ }
461
+ if (typeof parsed.scope !== 'string' || typeof parsed.blob !== 'string') {
462
+ throw new Error('Operator blob bytes have invalid scope/blob fields')
463
+ }
464
+ return parsed as unknown as OperatorEncryptedBlob
465
+ }
466
+
467
+ /**
468
+ * v0.24.10: Trial-decrypt a keystore blob with a candidate AES key. Returns
469
+ * true on success, false on AES-GCM auth failure or any malformed-input
470
+ * issue. Used by `precomputeAllScopes`'s verify-and-swap path to detect
471
+ * whether a freshly derived canonical key actually decrypts the on-disk
472
+ * keystore (a wrong key surfaces as an AES-GCM auth failure here, which
473
+ * triggers the legacy fallback).
474
+ */
475
+ export function tryDecryptKeystoreWithKey(
476
+ keystore: OperatorEncryptedKeystore,
477
+ key: Buffer,
478
+ ): boolean {
479
+ if (keystore.version !== OPERATOR_KEYSTORE_VERSION) return false
480
+ if (key.length !== 32) return false
481
+ const buf = Buffer.from(keystore.blob, 'base64')
482
+ if (buf.length < 12 + 16 + 1) return false
483
+ try {
484
+ decryptAesGcmStrict(key, buf.subarray(0, 12), buf.subarray(12, 28), buf.subarray(28))
485
+ return true
486
+ } catch {
487
+ return false
488
+ }
489
+ }
490
+
491
+ /**
492
+ * v0.24.10: Same as `tryDecryptKeystoreWithKey` for a scoped operator blob.
493
+ * Verifies the blob's stored scope matches `expectedScope` before attempting
494
+ * decrypt so a key derived for one scope can't accidentally "verify" against
495
+ * a blob from another scope.
496
+ */
497
+ export function tryDecryptOperatorBlobWithKey(
498
+ blob: OperatorEncryptedBlob,
499
+ key: Buffer,
500
+ expectedScope: OperatorBlobScope,
501
+ ): boolean {
502
+ if (blob.version !== OPERATOR_KEYSTORE_VERSION) return false
503
+ if (blob.scope !== expectedScope) return false
504
+ if (key.length !== 32) return false
505
+ const buf = Buffer.from(blob.blob, 'base64')
506
+ if (buf.length < 12 + 16 + 1) return false
507
+ try {
508
+ decryptAesGcmStrict(key, buf.subarray(0, 12), buf.subarray(12, 28), buf.subarray(28))
509
+ return true
510
+ } catch {
511
+ return false
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Sniff the keystore version of a serialized blob without doing any crypto.
517
+ * Used by `nebula restore` and `nebula migrate-keystore` to branch between the
518
+ * v1 (passphrase) and v2 (operator) decrypt paths.
519
+ */
520
+ export function sniffKeystoreVersion(bytes: Uint8Array): number | null {
521
+ try {
522
+ const parsed = JSON.parse(new TextDecoder().decode(bytes))
523
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.version === 'number') {
524
+ return parsed.version
525
+ }
526
+ return null
527
+ } catch {
528
+ return null
529
+ }
530
+ }