ethagent 0.2.1 → 1.0.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,486 @@
1
+ import crypto from 'node:crypto'
2
+ import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'
3
+ import { addressFromPrivateKey, recoverAddressFromSignature, toChecksumAddress, validatePrivateKey } from './eth.js'
4
+
5
+ export const BACKUP_ENVELOPE_VERSION = 'ethagent-pq-backup-v1'
6
+ export const AGENT_STATE_BACKUP_ENVELOPE_VERSION = 'ethagent-state-backup-v1'
7
+
8
+ type BackupPayload = {
9
+ privateKey: string
10
+ address: string
11
+ createdAt: string
12
+ }
13
+
14
+ export type AgentStatePayload = {
15
+ ownerAddress: string
16
+ createdAt: string
17
+ state: Record<string, unknown>
18
+ }
19
+
20
+ export type IdentityBackupEnvelope = {
21
+ version: 1
22
+ envelopeVersion: typeof BACKUP_ENVELOPE_VERSION
23
+ address: string
24
+ ownerAddress?: string
25
+ createdAt: string
26
+ challenge: string
27
+ walletSignature: string
28
+ crypto: {
29
+ kem: 'ML-KEM-768'
30
+ aead: 'AES-256-GCM'
31
+ kdf: 'HKDF-SHA256'
32
+ signature: 'EIP-191'
33
+ }
34
+ salt: string
35
+ kemPublicKey: string
36
+ kemCiphertext: string
37
+ nonce: string
38
+ ciphertext: string
39
+ tag: string
40
+ }
41
+
42
+ export type CreateIdentityBackupArgs = {
43
+ privateKey: string
44
+ recoveryPassphrase: string
45
+ walletSignature: string
46
+ createdAt?: string
47
+ ownerAddress?: string
48
+ }
49
+
50
+ export type RestoreIdentityBackupArgs = {
51
+ envelope: IdentityBackupEnvelope
52
+ recoveryPassphrase: string
53
+ walletSignature: string
54
+ }
55
+
56
+ export type AgentStateBackupEnvelope = {
57
+ version: 1
58
+ envelopeVersion: typeof AGENT_STATE_BACKUP_ENVELOPE_VERSION
59
+ ownerAddress: string
60
+ createdAt: string
61
+ challenge: string
62
+ crypto: {
63
+ kem: 'ML-KEM-768'
64
+ aead: 'AES-256-GCM'
65
+ kdf: 'HKDF-SHA256'
66
+ signature: 'EIP-191'
67
+ }
68
+ salt: string
69
+ kemPublicKey: string
70
+ kemCiphertext: string
71
+ nonce: string
72
+ ciphertext: string
73
+ tag: string
74
+ }
75
+
76
+ export type CreateAgentStateBackupArgs = {
77
+ ownerAddress: string
78
+ walletSignature: string
79
+ state: Record<string, unknown>
80
+ createdAt?: string
81
+ }
82
+
83
+ export type RestoreAgentStateBackupArgs = {
84
+ envelope: AgentStateBackupEnvelope
85
+ walletSignature: string
86
+ }
87
+
88
+ export class AgentStateOwnerMismatchError extends Error {
89
+ constructor(
90
+ readonly backupOwner: string,
91
+ readonly currentOwner: string,
92
+ ) {
93
+ super('agent backup is encrypted for another wallet')
94
+ this.name = 'AgentStateOwnerMismatchError'
95
+ }
96
+ }
97
+
98
+ export function createRecoveryChallenge(address: string): string {
99
+ const checksum = toChecksumAddress(address)
100
+ return [
101
+ 'ethagent identity recovery v1',
102
+ `address: ${checksum}`,
103
+ 'purpose: authorize encrypted portable agent backup',
104
+ ].join('\n')
105
+ }
106
+
107
+ export function createAgentStateRecoveryChallenge(ownerAddress: string): string {
108
+ const checksum = toChecksumAddress(ownerAddress)
109
+ return [
110
+ 'ethagent encrypted state access',
111
+ `Owner: ${checksum}`,
112
+ 'Action: authorize this wallet to unlock the encrypted agent backup',
113
+ 'Version: 1',
114
+ ].join('\n')
115
+ }
116
+
117
+ export function createIdentityBackupEnvelope(args: CreateIdentityBackupArgs): IdentityBackupEnvelope {
118
+ if (!validatePrivateKey(args.privateKey)) throw new Error('invalid private key')
119
+ if (args.recoveryPassphrase.length < 8) throw new Error('recovery passphrase must be at least 8 characters')
120
+
121
+ const address = addressFromPrivateKey(args.privateKey)
122
+ const ownerAddress = args.ownerAddress ? toChecksumAddress(args.ownerAddress) : undefined
123
+ const signingAddress = ownerAddress ?? address
124
+ const challenge = createRecoveryChallenge(signingAddress)
125
+ assertSignatureForAddress(challenge, args.walletSignature, signingAddress)
126
+
127
+ const createdAt = args.createdAt ?? new Date().toISOString()
128
+ const salt = crypto.randomBytes(32)
129
+ const kemSeed = deriveKemSeed(args.recoveryPassphrase, args.walletSignature, salt, address)
130
+ const kemKeys = ml_kem768.keygen(kemSeed)
131
+ const kem = ml_kem768.encapsulate(kemKeys.publicKey)
132
+ const key = deriveAesKey(args.recoveryPassphrase, args.walletSignature, kem.sharedSecret, salt, address)
133
+ const nonce = crypto.randomBytes(12)
134
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
135
+ cipher.setAAD(aadFor(address, createdAt))
136
+ const plaintext = Buffer.from(JSON.stringify({
137
+ privateKey: normalizedPrivateKey(args.privateKey),
138
+ address,
139
+ createdAt,
140
+ } satisfies BackupPayload), 'utf8')
141
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
142
+ const tag = cipher.getAuthTag()
143
+
144
+ return {
145
+ version: 1,
146
+ envelopeVersion: BACKUP_ENVELOPE_VERSION,
147
+ address,
148
+ ...(ownerAddress ? { ownerAddress } : {}),
149
+ createdAt,
150
+ challenge,
151
+ walletSignature: args.walletSignature,
152
+ crypto: {
153
+ kem: 'ML-KEM-768',
154
+ aead: 'AES-256-GCM',
155
+ kdf: 'HKDF-SHA256',
156
+ signature: 'EIP-191',
157
+ },
158
+ salt: toBase64(salt),
159
+ kemPublicKey: toBase64(kemKeys.publicKey),
160
+ kemCiphertext: toBase64(kem.cipherText),
161
+ nonce: toBase64(nonce),
162
+ ciphertext: toBase64(encrypted),
163
+ tag: toBase64(tag),
164
+ }
165
+ }
166
+
167
+ export function restoreIdentityBackupEnvelope(args: RestoreIdentityBackupArgs): BackupPayload {
168
+ const envelope = normalizeEnvelope(args.envelope)
169
+ const signingAddress = envelope.ownerAddress ?? envelope.address
170
+ assertSignatureForAddress(envelope.challenge, args.walletSignature, signingAddress)
171
+ assertSignatureForAddress(envelope.challenge, envelope.walletSignature, signingAddress)
172
+
173
+ const salt = fromBase64(envelope.salt)
174
+ const kemSeed = deriveKemSeed(args.recoveryPassphrase, envelope.walletSignature, salt, envelope.address)
175
+ const kemKeys = ml_kem768.keygen(kemSeed)
176
+ const expectedPublicKey = toBase64(kemKeys.publicKey)
177
+ if (expectedPublicKey !== envelope.kemPublicKey) {
178
+ throw new Error('recovery credentials do not match this backup')
179
+ }
180
+
181
+ const sharedSecret = ml_kem768.decapsulate(fromBase64(envelope.kemCiphertext), kemKeys.secretKey)
182
+ const key = deriveAesKey(args.recoveryPassphrase, envelope.walletSignature, sharedSecret, salt, envelope.address)
183
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, fromBase64(envelope.nonce))
184
+ decipher.setAAD(aadFor(envelope.address, envelope.createdAt))
185
+ decipher.setAuthTag(fromBase64(envelope.tag))
186
+
187
+ let decoded: unknown
188
+ try {
189
+ const plaintext = Buffer.concat([
190
+ decipher.update(fromBase64(envelope.ciphertext)),
191
+ decipher.final(),
192
+ ]).toString('utf8')
193
+ decoded = JSON.parse(plaintext)
194
+ } catch {
195
+ throw new Error('could not decrypt backup with the supplied credentials')
196
+ }
197
+
198
+ if (!isBackupPayload(decoded)) throw new Error('backup payload is invalid')
199
+ if (decoded.address.toLowerCase() !== envelope.address.toLowerCase()) {
200
+ throw new Error('backup payload address mismatch')
201
+ }
202
+ if (addressFromPrivateKey(decoded.privateKey).toLowerCase() !== envelope.address.toLowerCase()) {
203
+ throw new Error('backup private key does not match address')
204
+ }
205
+ return decoded
206
+ }
207
+
208
+ export function createAgentStateBackupEnvelope(args: CreateAgentStateBackupArgs): AgentStateBackupEnvelope {
209
+ const ownerAddress = toChecksumAddress(args.ownerAddress)
210
+ const challenge = createAgentStateRecoveryChallenge(ownerAddress)
211
+ assertSignatureForAddress(challenge, args.walletSignature, ownerAddress)
212
+
213
+ const createdAt = args.createdAt ?? new Date().toISOString()
214
+ const salt = crypto.randomBytes(32)
215
+ const kemSeed = deriveStateKemSeed(args.walletSignature, salt, ownerAddress)
216
+ const kemKeys = ml_kem768.keygen(kemSeed)
217
+ const kem = ml_kem768.encapsulate(kemKeys.publicKey)
218
+ const key = deriveStateAesKey(args.walletSignature, kem.sharedSecret, salt, ownerAddress)
219
+ const nonce = crypto.randomBytes(12)
220
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
221
+ cipher.setAAD(stateAadFor(ownerAddress, createdAt))
222
+ const plaintext = Buffer.from(JSON.stringify({
223
+ ownerAddress,
224
+ createdAt,
225
+ state: args.state,
226
+ } satisfies AgentStatePayload), 'utf8')
227
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
228
+ const tag = cipher.getAuthTag()
229
+
230
+ return {
231
+ version: 1,
232
+ envelopeVersion: AGENT_STATE_BACKUP_ENVELOPE_VERSION,
233
+ ownerAddress,
234
+ createdAt,
235
+ challenge,
236
+ crypto: {
237
+ kem: 'ML-KEM-768',
238
+ aead: 'AES-256-GCM',
239
+ kdf: 'HKDF-SHA256',
240
+ signature: 'EIP-191',
241
+ },
242
+ salt: toBase64(salt),
243
+ kemPublicKey: toBase64(kemKeys.publicKey),
244
+ kemCiphertext: toBase64(kem.cipherText),
245
+ nonce: toBase64(nonce),
246
+ ciphertext: toBase64(encrypted),
247
+ tag: toBase64(tag),
248
+ }
249
+ }
250
+
251
+ export function restoreAgentStateBackupEnvelope(args: RestoreAgentStateBackupArgs): AgentStatePayload {
252
+ const envelope = normalizeAgentStateEnvelope(args.envelope)
253
+ assertSignatureForAddress(envelope.challenge, args.walletSignature, envelope.ownerAddress)
254
+
255
+ const salt = fromBase64(envelope.salt)
256
+ const kemSeed = deriveStateKemSeed(args.walletSignature, salt, envelope.ownerAddress)
257
+ const kemKeys = ml_kem768.keygen(kemSeed)
258
+ const expectedPublicKey = toBase64(kemKeys.publicKey)
259
+ if (expectedPublicKey !== envelope.kemPublicKey) {
260
+ throw new Error('wallet signature does not match this agent backup')
261
+ }
262
+
263
+ const sharedSecret = ml_kem768.decapsulate(fromBase64(envelope.kemCiphertext), kemKeys.secretKey)
264
+ const key = deriveStateAesKey(args.walletSignature, sharedSecret, salt, envelope.ownerAddress)
265
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, fromBase64(envelope.nonce))
266
+ decipher.setAAD(stateAadFor(envelope.ownerAddress, envelope.createdAt))
267
+ decipher.setAuthTag(fromBase64(envelope.tag))
268
+
269
+ let decoded: unknown
270
+ try {
271
+ const plaintext = Buffer.concat([
272
+ decipher.update(fromBase64(envelope.ciphertext)),
273
+ decipher.final(),
274
+ ]).toString('utf8')
275
+ decoded = JSON.parse(plaintext)
276
+ } catch {
277
+ throw new Error('could not decrypt agent state with the supplied wallet signature')
278
+ }
279
+
280
+ if (!isAgentStatePayload(decoded)) throw new Error('agent state backup payload is invalid')
281
+ if (decoded.ownerAddress.toLowerCase() !== envelope.ownerAddress.toLowerCase()) {
282
+ throw new Error('agent state backup owner mismatch')
283
+ }
284
+ return {
285
+ ...decoded,
286
+ ownerAddress: toChecksumAddress(decoded.ownerAddress),
287
+ }
288
+ }
289
+
290
+ export function assertAgentStateBackupOwner(envelope: AgentStateBackupEnvelope, currentOwner: string): void {
291
+ const backupOwner = toChecksumAddress(envelope.ownerAddress)
292
+ const owner = toChecksumAddress(currentOwner)
293
+ if (backupOwner.toLowerCase() !== owner.toLowerCase()) {
294
+ throw new AgentStateOwnerMismatchError(backupOwner, owner)
295
+ }
296
+ }
297
+
298
+ export function serializeIdentityBackupEnvelope(envelope: IdentityBackupEnvelope): string {
299
+ return JSON.stringify(normalizeEnvelope(envelope), null, 2)
300
+ }
301
+
302
+ export function parseIdentityBackupEnvelope(raw: string | Uint8Array): IdentityBackupEnvelope {
303
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
304
+ const parsed = JSON.parse(text) as unknown
305
+ return normalizeEnvelope(parsed)
306
+ }
307
+
308
+ export function serializeAgentStateBackupEnvelope(envelope: AgentStateBackupEnvelope): string {
309
+ return JSON.stringify(normalizeAgentStateEnvelope(envelope), null, 2)
310
+ }
311
+
312
+ export function parseAgentStateBackupEnvelope(raw: string | Uint8Array): AgentStateBackupEnvelope {
313
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
314
+ const parsed = JSON.parse(text) as unknown
315
+ return normalizeAgentStateEnvelope(parsed)
316
+ }
317
+
318
+ function normalizeEnvelope(input: unknown): IdentityBackupEnvelope {
319
+ if (!isEnvelope(input)) throw new Error('invalid identity backup envelope')
320
+ if (input.envelopeVersion !== BACKUP_ENVELOPE_VERSION) throw new Error('unsupported backup envelope version')
321
+ if (input.crypto.kem !== 'ML-KEM-768' || input.crypto.aead !== 'AES-256-GCM') {
322
+ throw new Error('unsupported backup crypto suite')
323
+ }
324
+ return {
325
+ ...input,
326
+ address: toChecksumAddress(input.address),
327
+ ...(typeof input.ownerAddress === 'string' ? { ownerAddress: toChecksumAddress(input.ownerAddress) } : {}),
328
+ }
329
+ }
330
+
331
+ function isEnvelope(input: unknown): input is IdentityBackupEnvelope {
332
+ if (!input || typeof input !== 'object') return false
333
+ const obj = input as Partial<IdentityBackupEnvelope>
334
+ return obj.version === 1
335
+ && obj.envelopeVersion === BACKUP_ENVELOPE_VERSION
336
+ && typeof obj.address === 'string'
337
+ && (obj.ownerAddress === undefined || typeof obj.ownerAddress === 'string')
338
+ && typeof obj.createdAt === 'string'
339
+ && typeof obj.challenge === 'string'
340
+ && typeof obj.walletSignature === 'string'
341
+ && typeof obj.salt === 'string'
342
+ && typeof obj.kemPublicKey === 'string'
343
+ && typeof obj.kemCiphertext === 'string'
344
+ && typeof obj.nonce === 'string'
345
+ && typeof obj.ciphertext === 'string'
346
+ && typeof obj.tag === 'string'
347
+ && !!obj.crypto
348
+ }
349
+
350
+ function normalizeAgentStateEnvelope(input: unknown): AgentStateBackupEnvelope {
351
+ if (!isAgentStateEnvelope(input)) throw new Error('invalid agent state backup envelope')
352
+ if (input.envelopeVersion !== AGENT_STATE_BACKUP_ENVELOPE_VERSION) throw new Error('unsupported agent state backup envelope version')
353
+ if (input.crypto.kem !== 'ML-KEM-768' || input.crypto.aead !== 'AES-256-GCM') {
354
+ throw new Error('unsupported backup crypto suite')
355
+ }
356
+ return {
357
+ ...input,
358
+ ownerAddress: toChecksumAddress(input.ownerAddress),
359
+ }
360
+ }
361
+
362
+ function isAgentStateEnvelope(input: unknown): input is AgentStateBackupEnvelope {
363
+ if (!input || typeof input !== 'object') return false
364
+ const obj = input as Partial<AgentStateBackupEnvelope> & { walletSignature?: unknown }
365
+ return obj.version === 1
366
+ && obj.envelopeVersion === AGENT_STATE_BACKUP_ENVELOPE_VERSION
367
+ && typeof obj.ownerAddress === 'string'
368
+ && typeof obj.createdAt === 'string'
369
+ && typeof obj.challenge === 'string'
370
+ && obj.walletSignature === undefined
371
+ && typeof obj.salt === 'string'
372
+ && typeof obj.kemPublicKey === 'string'
373
+ && typeof obj.kemCiphertext === 'string'
374
+ && typeof obj.nonce === 'string'
375
+ && typeof obj.ciphertext === 'string'
376
+ && typeof obj.tag === 'string'
377
+ && !!obj.crypto
378
+ }
379
+
380
+ function isBackupPayload(input: unknown): input is BackupPayload {
381
+ if (!input || typeof input !== 'object') return false
382
+ const obj = input as Partial<BackupPayload>
383
+ return typeof obj.privateKey === 'string'
384
+ && validatePrivateKey(obj.privateKey)
385
+ && typeof obj.address === 'string'
386
+ && typeof obj.createdAt === 'string'
387
+ }
388
+
389
+ function isAgentStatePayload(input: unknown): input is AgentStatePayload {
390
+ if (!input || typeof input !== 'object') return false
391
+ const obj = input as Partial<AgentStatePayload>
392
+ return typeof obj.ownerAddress === 'string'
393
+ && typeof obj.createdAt === 'string'
394
+ && !!obj.state
395
+ && typeof obj.state === 'object'
396
+ && !Array.isArray(obj.state)
397
+ }
398
+
399
+ function assertSignatureForAddress(challenge: string, signature: string, address: string): void {
400
+ const recovered = recoverAddressFromSignature(challenge, signature)
401
+ if (recovered.toLowerCase() !== address.toLowerCase()) {
402
+ throw new Error('wallet signature does not match backup address')
403
+ }
404
+ }
405
+
406
+ function deriveKemSeed(passphrase: string, walletSignature: string, salt: Uint8Array, address: string): Uint8Array {
407
+ return hkdf(
408
+ Buffer.from(`${walletSignature}\n${passphrase}`, 'utf8'),
409
+ salt,
410
+ `ethagent:${BACKUP_ENVELOPE_VERSION}:ml-kem768:${address.toLowerCase()}`,
411
+ 64,
412
+ )
413
+ }
414
+
415
+ function deriveAesKey(
416
+ passphrase: string,
417
+ walletSignature: string,
418
+ sharedSecret: Uint8Array,
419
+ salt: Uint8Array,
420
+ address: string,
421
+ ): Buffer {
422
+ return Buffer.from(hkdf(
423
+ Buffer.concat([
424
+ Buffer.from(walletSignature, 'utf8'),
425
+ Buffer.from('\n', 'utf8'),
426
+ Buffer.from(passphrase, 'utf8'),
427
+ Buffer.from('\n', 'utf8'),
428
+ Buffer.from(sharedSecret),
429
+ ]),
430
+ salt,
431
+ `ethagent:${BACKUP_ENVELOPE_VERSION}:aes-256-gcm:${address.toLowerCase()}`,
432
+ 32,
433
+ ))
434
+ }
435
+
436
+ function deriveStateKemSeed(walletSignature: string, salt: Uint8Array, ownerAddress: string): Uint8Array {
437
+ return hkdf(
438
+ Buffer.from(walletSignature, 'utf8'),
439
+ salt,
440
+ `ethagent:${AGENT_STATE_BACKUP_ENVELOPE_VERSION}:ml-kem768:${ownerAddress.toLowerCase()}`,
441
+ 64,
442
+ )
443
+ }
444
+
445
+ function deriveStateAesKey(
446
+ walletSignature: string,
447
+ sharedSecret: Uint8Array,
448
+ salt: Uint8Array,
449
+ ownerAddress: string,
450
+ ): Buffer {
451
+ return Buffer.from(hkdf(
452
+ Buffer.concat([
453
+ Buffer.from(walletSignature, 'utf8'),
454
+ Buffer.from('\n', 'utf8'),
455
+ Buffer.from(sharedSecret),
456
+ ]),
457
+ salt,
458
+ `ethagent:${AGENT_STATE_BACKUP_ENVELOPE_VERSION}:aes-256-gcm:${ownerAddress.toLowerCase()}`,
459
+ 32,
460
+ ))
461
+ }
462
+
463
+ function hkdf(ikm: Uint8Array, salt: Uint8Array, info: string, length: number): Uint8Array {
464
+ return new Uint8Array(crypto.hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), length))
465
+ }
466
+
467
+ function aadFor(address: string, createdAt: string): Buffer {
468
+ return Buffer.from(`${BACKUP_ENVELOPE_VERSION}\n${address.toLowerCase()}\n${createdAt}`, 'utf8')
469
+ }
470
+
471
+ function stateAadFor(ownerAddress: string, createdAt: string): Buffer {
472
+ return Buffer.from(`${AGENT_STATE_BACKUP_ENVELOPE_VERSION}\n${ownerAddress.toLowerCase()}\n${createdAt}`, 'utf8')
473
+ }
474
+
475
+ function normalizedPrivateKey(privateKey: string): string {
476
+ const trimmed = privateKey.trim()
477
+ return trimmed.startsWith('0x') || trimmed.startsWith('0X') ? `0x${trimmed.slice(2)}` : `0x${trimmed}`
478
+ }
479
+
480
+ function toBase64(bytes: Uint8Array): string {
481
+ return Buffer.from(bytes).toString('base64')
482
+ }
483
+
484
+ function fromBase64(value: string): Uint8Array {
485
+ return new Uint8Array(Buffer.from(value, 'base64'))
486
+ }
@@ -0,0 +1,137 @@
1
+ import { secp256k1 } from '@noble/curves/secp256k1.js'
2
+ import { keccak_256 } from '@noble/hashes/sha3.js'
3
+ import crypto from 'node:crypto'
4
+
5
+ type RecoverableSecp256k1 = typeof secp256k1 & {
6
+ recoverPublicKey: (signature: Uint8Array, message: Uint8Array) => Uint8Array
7
+ }
8
+
9
+ const HEX_RE = /^(0x)?[0-9a-fA-F]+$/
10
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/
11
+ const SIG_RE = /^0x[0-9a-fA-F]{130}$/
12
+
13
+ function stripHex(input: string): string {
14
+ return input.startsWith('0x') || input.startsWith('0X') ? input.slice(2) : input
15
+ }
16
+
17
+ function hexToBytes(hex: string): Uint8Array {
18
+ const stripped = stripHex(hex)
19
+ if (stripped.length % 2 !== 0) throw new Error('hex string has odd length')
20
+ const out = new Uint8Array(stripped.length / 2)
21
+ for (let i = 0; i < out.length; i += 1) {
22
+ const byte = Number.parseInt(stripped.slice(i * 2, i * 2 + 2), 16)
23
+ if (Number.isNaN(byte)) throw new Error('invalid hex')
24
+ out[i] = byte
25
+ }
26
+ return out
27
+ }
28
+
29
+ function bytesToHex(bytes: Uint8Array): string {
30
+ let out = ''
31
+ for (let i = 0; i < bytes.length; i += 1) {
32
+ out += bytes[i]!.toString(16).padStart(2, '0')
33
+ }
34
+ return out
35
+ }
36
+
37
+ function ethereumMessageDigest(message: string | Uint8Array): Uint8Array {
38
+ const data = typeof message === 'string' ? new TextEncoder().encode(message) : message
39
+ const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${data.length}`)
40
+ const payload = new Uint8Array(prefix.length + data.length)
41
+ payload.set(prefix, 0)
42
+ payload.set(data, prefix.length)
43
+ return keccak_256(payload)
44
+ }
45
+
46
+ function addressFromPublicKey(publicKey: Uint8Array): string {
47
+ const uncompressed = publicKey.length === 65
48
+ ? publicKey
49
+ : secp256k1.Point.fromBytes(publicKey).toBytes(false)
50
+ const hash = keccak_256(uncompressed.subarray(1))
51
+ const addrBytes = hash.subarray(-20)
52
+ return toChecksumAddress('0x' + bytesToHex(addrBytes))
53
+ }
54
+
55
+ export function generatePrivateKey(): string {
56
+ while (true) {
57
+ const bytes = crypto.randomBytes(32)
58
+ const hex = bytes.toString('hex')
59
+ if (validatePrivateKey(hex)) return `0x${hex}`
60
+ }
61
+ }
62
+
63
+ export function validatePrivateKey(input: string): boolean {
64
+ if (typeof input !== 'string') return false
65
+ const stripped = stripHex(input.trim())
66
+ if (stripped.length !== 64) return false
67
+ if (!HEX_RE.test(stripped)) return false
68
+ let allZero = true
69
+ for (let i = 0; i < stripped.length; i += 1) {
70
+ if (stripped[i] !== '0') { allZero = false; break }
71
+ }
72
+ if (allZero) return false
73
+ try {
74
+ const bytes = hexToBytes(stripped)
75
+ const n = BigInt('0x' + stripped)
76
+ if (n >= secp256k1.Point.Fn.ORDER) return false
77
+ secp256k1.getPublicKey(bytes, false)
78
+ return true
79
+ } catch {
80
+ return false
81
+ }
82
+ }
83
+
84
+ export function addressFromPrivateKey(input: string): string {
85
+ if (!validatePrivateKey(input)) throw new Error('invalid private key')
86
+ const bytes = hexToBytes(stripHex(input.trim()))
87
+ const pub = secp256k1.getPublicKey(bytes, false)
88
+ return addressFromPublicKey(pub)
89
+ }
90
+
91
+ export function toChecksumAddress(address: string): string {
92
+ if (!ADDR_RE.test(address)) throw new Error('invalid address')
93
+ const lower = address.slice(2).toLowerCase()
94
+ const hashHex = bytesToHex(keccak_256(new TextEncoder().encode(lower)))
95
+ let out = '0x'
96
+ for (let i = 0; i < lower.length; i += 1) {
97
+ const ch = lower[i]!
98
+ if (ch >= 'a' && ch <= 'f') {
99
+ out += Number.parseInt(hashHex[i]!, 16) >= 8 ? ch.toUpperCase() : ch
100
+ } else {
101
+ out += ch
102
+ }
103
+ }
104
+ return out
105
+ }
106
+
107
+ export function signMessage(privateKey: string, message: string | Uint8Array): string {
108
+ if (!validatePrivateKey(privateKey)) throw new Error('invalid private key')
109
+ const sk = hexToBytes(stripHex(privateKey.trim()))
110
+ const digest = ethereumMessageDigest(message)
111
+ const sig = secp256k1.sign(digest, sk, { prehash: false })
112
+ const compact = sig.toBytes('compact')
113
+ const r = compact.subarray(0, 32)
114
+ const s = compact.subarray(32, 64)
115
+ const v = 27 + (sig.recovery ?? 0)
116
+ const out = new Uint8Array(65)
117
+ out.set(r, 0)
118
+ out.set(s, 32)
119
+ out[64] = v
120
+ return '0x' + bytesToHex(out)
121
+ }
122
+
123
+ export function recoverAddressFromSignature(message: string | Uint8Array, signature: string): string {
124
+ if (!SIG_RE.test(signature)) throw new Error('invalid signature')
125
+ const bytes = hexToBytes(stripHex(signature))
126
+ const v = bytes[64]!
127
+ const recovery = v >= 27 ? v - 27 : v
128
+ if (recovery < 0 || recovery > 3) throw new Error('invalid recovery id')
129
+ const recovered = new Uint8Array(65)
130
+ recovered[0] = recovery
131
+ recovered.set(bytes.subarray(0, 64), 1)
132
+ const publicKey = (secp256k1 as RecoverableSecp256k1).recoverPublicKey(
133
+ recovered,
134
+ ethereumMessageDigest(message),
135
+ )
136
+ return addressFromPublicKey(publicKey)
137
+ }