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.
- package/README.md +24 -0
- package/package.json +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +75 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +213 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- 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
|
+
}
|