ethagent 0.2.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|