@agentlayer.tech/wallet 0.1.73 → 0.1.75
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/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/CHANGELOG.md +72 -0
- package/RELEASING.md +23 -4
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +10 -2
- package/agent-wallet/agent_wallet/boot_key_recovery.py +6 -4
- package/agent-wallet/agent_wallet/config.py +125 -23
- package/agent-wallet/agent_wallet/encrypted_storage.py +85 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +116 -23
- package/agent-wallet/agent_wallet/keystore.py +82 -2
- package/agent-wallet/agent_wallet/sealed_keys.py +19 -18
- package/agent-wallet/agent_wallet/user_wallets.py +6 -5
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/agent-wallet/scripts/bootstrap_openclaw_evm.py +8 -3
- package/agent-wallet/scripts/install_agent_wallet.py +4 -1
- package/bin/openclaw-agent-wallet.mjs +738 -88
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/.mcp.json +1 -2
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +2 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +3 -2
- package/wdk-evm-wallet/src/config.js +24 -0
- package/wdk-evm-wallet/src/server.js +2 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-wallet",
|
|
3
3
|
"name": "Agent Wallet",
|
|
4
4
|
"description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.75",
|
|
6
6
|
"contracts": {
|
|
7
7
|
"tools": [
|
|
8
8
|
"agentlayer_autonomous_approve",
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v0.1.75 - 2026-07-11
|
|
6
|
+
|
|
7
|
+
- Added a persistent, non-secret ownership registry for OpenClaw, Hermes,
|
|
8
|
+
Codex, and Claude Code integrations. Explicit installs are now distinguishable
|
|
9
|
+
from unrelated external plugins across runtime upgrades.
|
|
10
|
+
|
|
11
|
+
- Fixed `wallet update` so managed framework integrations advance with the
|
|
12
|
+
verified `current` runtime. OpenClaw config and Hermes paths are repaired,
|
|
13
|
+
while Codex and Claude Code registrations and versioned caches are refreshed
|
|
14
|
+
through their host CLIs.
|
|
15
|
+
|
|
16
|
+
- Added guarded adoption for legacy AgentLayer installs. Existing integrations
|
|
17
|
+
are adopted only when their registration and plugin manifests both identify
|
|
18
|
+
AgentLayer; unowned symlinks and external plugins remain untouched.
|
|
19
|
+
|
|
20
|
+
- Added per-framework synchronization details to `wallet status` and
|
|
21
|
+
`wallet doctor`, including failed host registration and restart requirements.
|
|
22
|
+
npm-exec installs also refresh an already-installed stale global CLI without
|
|
23
|
+
making global installation mandatory.
|
|
24
|
+
|
|
25
|
+
- Expanded installer regression coverage for managed upgrades, unmanaged
|
|
26
|
+
integration preservation, global CLI refresh, host registration, and
|
|
27
|
+
hermetic framework/npm homes.
|
|
28
|
+
|
|
29
|
+
## v0.1.74 - 2026-07-10
|
|
30
|
+
|
|
31
|
+
- Added a guarded npm beta-promotion workflow that acceptance-tests the exact
|
|
32
|
+
registry artifact, verifies its integrity, rejects downgrades, serializes with
|
|
33
|
+
publish jobs, and restores the previous `latest` tag if verification fails.
|
|
34
|
+
|
|
35
|
+
- Added a stable per-home identity and verified PID ownership record for the
|
|
36
|
+
local EVM daemon. Restarts now signal only the confirmed listener, while a
|
|
37
|
+
bounded single-listener compatibility path upgrades pre-identity daemons.
|
|
38
|
+
|
|
39
|
+
- Disabled implicit Argon2id-to-HKDF rewrites during normal wallet reads.
|
|
40
|
+
Opt-in envelope migration now creates and verifies an Argon2id rollback
|
|
41
|
+
backup, restores the original on write failure, and refuses stale restores
|
|
42
|
+
after logical secret contents change.
|
|
43
|
+
|
|
44
|
+
- Persisted the verified boot-key keystore backend under each OpenClaw home so
|
|
45
|
+
`auto` selection remains stable across updates and desktop session changes.
|
|
46
|
+
Explicit backend overrides still work, and temporary fallback use does not
|
|
47
|
+
overwrite the pinned backend.
|
|
48
|
+
|
|
49
|
+
- Preserved upgrades from pre-resolver runtimes by ordering the legacy Node
|
|
50
|
+
fallback as keystore, current runtime env, then legacy files. Installer output
|
|
51
|
+
now reports the non-secret boot-key source, and CI covers conflicting legacy
|
|
52
|
+
sources, staged verification failures, editor migration, and EVM wrong-home
|
|
53
|
+
daemon recovery.
|
|
54
|
+
|
|
55
|
+
- Replaced automatic editor reinstallation after every runtime update with a
|
|
56
|
+
narrow migration of stale runtime-owned symlinks and Hermes env paths. Codex
|
|
57
|
+
and Claude MCP source manifests are now immutable; only user-owned Claude
|
|
58
|
+
cache copies may receive a custom `OPENCLAW_HOME` pin. The versioned runtime
|
|
59
|
+
now includes the previously omitted `claude-code/` bridge.
|
|
60
|
+
|
|
61
|
+
- Staged normal installs under hidden `releases/.staging-*` directories and
|
|
62
|
+
verify them before switching `current`. Failed candidates are moved to
|
|
63
|
+
`.failed-*`, excluded from `available_releases`, and recorded in an update
|
|
64
|
+
journal; on failure, existing `current` and `previous` pointers remain untouched.
|
|
65
|
+
|
|
66
|
+
- Centralized installer boot-key selection in the active Python runtime. When
|
|
67
|
+
sealed secrets exist, conflicting env, keystore, and legacy-file candidates
|
|
68
|
+
are tested against `sealed_keys.json`; a stale higher-priority value can no
|
|
69
|
+
longer mask the key that actually decrypts the bundle. Older runtimes retain
|
|
70
|
+
the existing JavaScript fallback for upgrade compatibility.
|
|
71
|
+
|
|
72
|
+
- Removed a test-only `OPENCLAW_HOME` pin that leaked into the published Claude
|
|
73
|
+
Code MCP manifest. Installer regression tests now isolate every editor home,
|
|
74
|
+
assert that tracked plugin sources remain unchanged, and inspect the packed
|
|
75
|
+
npm MCP manifests for temporary or user-specific absolute paths.
|
|
76
|
+
|
|
5
77
|
## v0.1.73 - 2026-07-09
|
|
6
78
|
|
|
7
79
|
- Fixed CLI installer/update boot-key precedence when recovering an existing
|
package/RELEASING.md
CHANGED
|
@@ -166,10 +166,29 @@ The GitHub Releases page should contain a new prerelease named
|
|
|
166
166
|
|
|
167
167
|
### Promote a beta to stable
|
|
168
168
|
|
|
169
|
-
Once a beta checks out,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
Once a beta checks out, promote the exact published tarball instead of rebuilding
|
|
170
|
+
it under a different version:
|
|
171
|
+
|
|
172
|
+
1. Open the `promote npm beta` workflow in GitHub Actions.
|
|
173
|
+
2. Enter the current beta version, for example `0.1.11-beta.1`.
|
|
174
|
+
3. Enable the confirmation input and run the workflow.
|
|
175
|
+
|
|
176
|
+
The workflow verifies that the version is the current `beta`, refuses to move
|
|
177
|
+
`latest` backward, records its registry integrity, and installs that exact npm
|
|
178
|
+
artifact into an isolated OpenClaw home. Only after acceptance succeeds does it
|
|
179
|
+
move `latest`; if post-write verification fails, the previous `latest` is
|
|
180
|
+
restored. The existing GitHub prerelease is then marked stable.
|
|
181
|
+
|
|
182
|
+
Because npm packages are immutable, the promoted artifact keeps its prerelease
|
|
183
|
+
version string while becoming the stable `latest` channel. This guarantees that
|
|
184
|
+
production receives the same bytes tested on beta.
|
|
185
|
+
|
|
186
|
+
The workflow requires a repository secret named `NPM_PROMOTION_TOKEN`. npm OIDC
|
|
187
|
+
trusted publishing only authorizes publish operations, not `dist-tag` changes,
|
|
188
|
+
so this must be a granular token scoped to `@agentlayer.tech/wallet` with package
|
|
189
|
+
write access and bypass-2FA enabled. Do not reuse a broad account token.
|
|
190
|
+
Configure required reviewers and main-branch deployment protection on the
|
|
191
|
+
`npm-production` GitHub environment before using promotion in production.
|
|
173
192
|
|
|
174
193
|
## What Ships
|
|
175
194
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.75
|
|
@@ -16,7 +16,12 @@ from agent_wallet.config import (
|
|
|
16
16
|
settings,
|
|
17
17
|
)
|
|
18
18
|
from agent_wallet.file_ops import atomic_write_text
|
|
19
|
-
from agent_wallet.keystore import
|
|
19
|
+
from agent_wallet.keystore import (
|
|
20
|
+
BOOT_KEY_ITEM,
|
|
21
|
+
PlaintextFileStore,
|
|
22
|
+
record_keystore_backend,
|
|
23
|
+
resolve_keystore,
|
|
24
|
+
)
|
|
20
25
|
|
|
21
26
|
_ENV_LINE_PREFIX = "AGENT_WALLET_BOOT_KEY="
|
|
22
27
|
|
|
@@ -129,6 +134,8 @@ def migrate_boot_key_to_keystore() -> dict:
|
|
|
129
134
|
# Do not sweep into a plaintext fallback: that offers no at-rest improvement,
|
|
130
135
|
# and the user explicitly chose to stay on the current file in that case.
|
|
131
136
|
if isinstance(store, PlaintextFileStore):
|
|
137
|
+
if read_boot_key_from_keystore():
|
|
138
|
+
record_keystore_backend(store)
|
|
132
139
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
133
140
|
"removed_boot_key_file": False, "reason": "no-os-keystore"}
|
|
134
141
|
|
|
@@ -145,12 +152,13 @@ def migrate_boot_key_to_keystore() -> dict:
|
|
|
145
152
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
146
153
|
"removed_boot_key_file": False, "reason": f"keystore-set-failed: {exc}"}
|
|
147
154
|
# Verify-before-delete.
|
|
148
|
-
if
|
|
155
|
+
if store.get(BOOT_KEY_ITEM) != legacy_key:
|
|
149
156
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
150
157
|
"removed_boot_key_file": False, "reason": "verify-failed"}
|
|
151
158
|
authoritative = legacy_key
|
|
152
159
|
first_time = True
|
|
153
160
|
|
|
161
|
+
record_keystore_backend(store)
|
|
154
162
|
swept, removed = _sweep_plaintext(home, authoritative)
|
|
155
163
|
migrated = first_time or swept > 0 or removed
|
|
156
164
|
return {"migrated": migrated, "backend": store.backend_id, "swept_env_files": swept,
|
|
@@ -8,8 +8,8 @@ wallet files. See BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from agent_wallet.config import
|
|
12
|
-
from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
|
|
11
|
+
from agent_wallet.config import clear_secret_caches, resolve_boot_key
|
|
12
|
+
from agent_wallet.keystore import BOOT_KEY_ITEM, record_keystore_backend, resolve_keystore
|
|
13
13
|
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
14
14
|
|
|
15
15
|
_EXPORT_WARNING = (
|
|
@@ -35,11 +35,13 @@ def import_boot_key(value: str) -> dict:
|
|
|
35
35
|
raise WalletBackendError("A non-empty boot key is required for import.")
|
|
36
36
|
store = resolve_keystore()
|
|
37
37
|
store.set(BOOT_KEY_ITEM, key)
|
|
38
|
-
if
|
|
38
|
+
if store.get(BOOT_KEY_ITEM) != key:
|
|
39
39
|
raise WalletBackendError(
|
|
40
40
|
f"Boot key import could not be verified from the {store.backend_id} keystore."
|
|
41
41
|
)
|
|
42
|
-
|
|
42
|
+
state = record_keystore_backend(store)
|
|
43
|
+
clear_secret_caches()
|
|
44
|
+
return {"imported": True, "backend": store.backend_id, "backend_state": state}
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
def export_warning() -> str:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Configuration for agent wallet backends."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import os
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
from typing import Iterator
|
|
5
7
|
|
|
6
8
|
from pydantic_settings import BaseSettings
|
|
7
9
|
|
|
@@ -387,13 +389,16 @@ def _env_bool(name: str, default: bool) -> bool:
|
|
|
387
389
|
|
|
388
390
|
|
|
389
391
|
def envelope_kdf_migration_enabled() -> bool:
|
|
390
|
-
"""Whether
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
"""Whether an explicit read may migrate argon2id envelopes with backups.
|
|
393
|
+
|
|
394
|
+
Disabled by default so normal reads never change storage or break rollback
|
|
395
|
+
to a pre-HKDF runtime. Opt in with AGENT_WALLET_ENVELOPE_KDF_MIGRATION=1.
|
|
396
|
+
"""
|
|
397
|
+
return _env_bool("AGENT_WALLET_ENVELOPE_KDF_MIGRATION", False)
|
|
394
398
|
|
|
395
399
|
|
|
396
|
-
_boot_key_keystore_cache: dict[str, str] = {}
|
|
400
|
+
_boot_key_keystore_cache: dict[tuple[str, str, str], str] = {}
|
|
401
|
+
_boot_key_validation_cache: dict[tuple[str, str, int, int], bool] = {}
|
|
397
402
|
|
|
398
403
|
|
|
399
404
|
def clear_secret_caches() -> None:
|
|
@@ -405,6 +410,7 @@ def clear_secret_caches() -> None:
|
|
|
405
410
|
directly in tests that rotate keystore contents in-process.
|
|
406
411
|
"""
|
|
407
412
|
_boot_key_keystore_cache.clear()
|
|
413
|
+
_boot_key_validation_cache.clear()
|
|
408
414
|
from agent_wallet.keystore import clear_keystore_cache
|
|
409
415
|
|
|
410
416
|
clear_keystore_cache()
|
|
@@ -425,40 +431,136 @@ def read_boot_key_from_keystore() -> str:
|
|
|
425
431
|
try:
|
|
426
432
|
from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
|
|
427
433
|
|
|
428
|
-
|
|
429
|
-
|
|
434
|
+
cache_key = (
|
|
435
|
+
os.getenv("AGENT_WALLET_KEYSTORE_BACKEND", "auto").strip().lower(),
|
|
436
|
+
os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip(),
|
|
437
|
+
str(resolve_openclaw_home().resolve()),
|
|
438
|
+
)
|
|
439
|
+
cached = _boot_key_keystore_cache.get(cache_key)
|
|
430
440
|
if cached:
|
|
431
441
|
return cached
|
|
432
442
|
value = resolve_keystore().get(BOOT_KEY_ITEM)
|
|
433
443
|
text = value.strip() if isinstance(value, str) else ""
|
|
434
444
|
if text:
|
|
435
|
-
_boot_key_keystore_cache[
|
|
445
|
+
_boot_key_keystore_cache[cache_key] = text
|
|
436
446
|
return text
|
|
437
447
|
except Exception:
|
|
438
448
|
return ""
|
|
439
449
|
|
|
440
450
|
|
|
451
|
+
def _read_boot_key_file(path_value: str) -> str:
|
|
452
|
+
if not path_value.strip():
|
|
453
|
+
return ""
|
|
454
|
+
try:
|
|
455
|
+
return Path(path_value).expanduser().read_text(encoding="utf-8").strip()
|
|
456
|
+
except OSError:
|
|
457
|
+
return ""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _boot_key_candidates() -> Iterator[tuple[str, str]]:
|
|
461
|
+
seen: set[str] = set()
|
|
462
|
+
|
|
463
|
+
def candidate(source: str, value: str) -> tuple[str, str] | None:
|
|
464
|
+
if not value or value in seen:
|
|
465
|
+
return None
|
|
466
|
+
seen.add(value)
|
|
467
|
+
return source, value
|
|
468
|
+
|
|
469
|
+
for item in (
|
|
470
|
+
candidate("environment", os.getenv("AGENT_WALLET_BOOT_KEY", "").strip()),
|
|
471
|
+
candidate("runtime_env", settings.agent_wallet_boot_key.strip()),
|
|
472
|
+
):
|
|
473
|
+
if item:
|
|
474
|
+
yield item
|
|
475
|
+
|
|
476
|
+
keystore_item = candidate("keystore", read_boot_key_from_keystore())
|
|
477
|
+
if keystore_item:
|
|
478
|
+
yield keystore_item
|
|
479
|
+
|
|
480
|
+
configured_file = os.getenv(
|
|
481
|
+
"AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file
|
|
482
|
+
).strip()
|
|
483
|
+
configured_item = candidate("configured_file", _read_boot_key_file(configured_file))
|
|
484
|
+
if configured_item:
|
|
485
|
+
yield configured_item
|
|
486
|
+
|
|
487
|
+
default_file = resolve_openclaw_home() / "agent-wallet-runtime" / "boot-key"
|
|
488
|
+
if not configured_file or Path(configured_file).expanduser() != default_file:
|
|
489
|
+
default_item = candidate("default_file", _read_boot_key_file(str(default_file)))
|
|
490
|
+
if default_item:
|
|
491
|
+
yield default_item
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _boot_key_unlocks_sealed_file(boot_key: str) -> bool | None:
|
|
495
|
+
sealed_path = resolve_openclaw_home() / "sealed_keys.json"
|
|
496
|
+
try:
|
|
497
|
+
stat = sealed_path.stat()
|
|
498
|
+
except FileNotFoundError:
|
|
499
|
+
return None
|
|
500
|
+
except OSError:
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
cache_key = (boot_key, str(sealed_path), stat.st_mtime_ns, stat.st_size)
|
|
504
|
+
cached = _boot_key_validation_cache.get(cache_key)
|
|
505
|
+
if cached is not None:
|
|
506
|
+
return cached
|
|
507
|
+
try:
|
|
508
|
+
from agent_wallet.sealed_keys import unseal_keys
|
|
509
|
+
|
|
510
|
+
unseal_keys(boot_key)
|
|
511
|
+
valid = True
|
|
512
|
+
except Exception:
|
|
513
|
+
valid = False
|
|
514
|
+
if len(_boot_key_validation_cache) >= 8:
|
|
515
|
+
_boot_key_validation_cache.clear()
|
|
516
|
+
_boot_key_validation_cache[cache_key] = valid
|
|
517
|
+
return valid
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _resolve_boot_key_candidate() -> tuple[str, str, bool, list[str], bool]:
|
|
521
|
+
rejected: list[str] = []
|
|
522
|
+
for source, value in _boot_key_candidates():
|
|
523
|
+
verified = _boot_key_unlocks_sealed_file(value)
|
|
524
|
+
if verified is not False:
|
|
525
|
+
return value, source, verified is True, rejected, bool(rejected)
|
|
526
|
+
rejected.append(source)
|
|
527
|
+
return "", "none", False, rejected, len(rejected) > 1
|
|
528
|
+
|
|
529
|
+
|
|
441
530
|
def resolve_boot_key() -> str:
|
|
442
|
-
"""Resolve
|
|
531
|
+
"""Resolve a boot key, verifying candidates against sealed secrets when present.
|
|
443
532
|
|
|
444
|
-
Precedence
|
|
533
|
+
Precedence remains compatible with legacy installs:
|
|
445
534
|
1. AGENT_WALLET_BOOT_KEY env / settings override
|
|
446
535
|
2. OS keystore (the hardened path)
|
|
447
536
|
3. AGENT_WALLET_BOOT_KEY_FILE / boot-key file (legacy)
|
|
537
|
+
|
|
538
|
+
A stale higher-priority candidate cannot mask a lower-priority key that
|
|
539
|
+
actually unlocks ``sealed_keys.json``.
|
|
448
540
|
"""
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
541
|
+
return _resolve_boot_key_candidate()[0]
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def resolve_boot_key_for_installer() -> str:
|
|
545
|
+
"""Stable installer bridge; older runtimes lack this symbol and use JS fallback."""
|
|
546
|
+
return resolve_boot_key()
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def boot_key_resolution_status() -> dict[str, object]:
|
|
550
|
+
"""Return non-secret diagnostics for doctor/install reporting."""
|
|
551
|
+
from agent_wallet.keystore import keystore_backend_status
|
|
552
|
+
|
|
553
|
+
value, source, verified, rejected, conflict = _resolve_boot_key_candidate()
|
|
554
|
+
fingerprint = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12] if value else None
|
|
555
|
+
return {
|
|
556
|
+
"available": bool(value),
|
|
557
|
+
"source": source,
|
|
558
|
+
"sealed_keys_verified": verified,
|
|
559
|
+
"conflict_detected": conflict,
|
|
560
|
+
"rejected_sources": rejected,
|
|
561
|
+
"fingerprint": fingerprint,
|
|
562
|
+
"keystore": keystore_backend_status(),
|
|
563
|
+
}
|
|
462
564
|
|
|
463
565
|
|
|
464
566
|
def _reject_legacy_runtime_secret_env(var_name: str) -> None:
|
|
@@ -20,6 +20,7 @@ USER_SCOPED_KEY_SALT = b"openclaw-agent-wallet-user-key-v1"
|
|
|
20
20
|
|
|
21
21
|
KDF_ARGON2ID = "argon2id"
|
|
22
22
|
KDF_HKDF_SHA256 = "hkdf-sha256"
|
|
23
|
+
ARGON2ID_BACKUP_SUFFIX = ".argon2id.bak"
|
|
23
24
|
_HKDF_INFO = b"openclaw-agent-wallet-envelope-hkdf-v1"
|
|
24
25
|
|
|
25
26
|
|
|
@@ -201,6 +202,89 @@ def decrypt_secret_material(
|
|
|
201
202
|
return plaintext.decode("utf-8")
|
|
202
203
|
|
|
203
204
|
|
|
205
|
+
def migrate_envelope_to_hkdf(
|
|
206
|
+
path: Path,
|
|
207
|
+
secret_material: str,
|
|
208
|
+
*,
|
|
209
|
+
master_key: str,
|
|
210
|
+
metadata: dict[str, Any] | None = None,
|
|
211
|
+
) -> Path | None:
|
|
212
|
+
"""Rewrite one verified argon2id envelope with a rollback-safe backup."""
|
|
213
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
214
|
+
if envelope_kdf(raw_text) != KDF_ARGON2ID:
|
|
215
|
+
return None
|
|
216
|
+
if decrypt_secret_material(raw_text, master_key=master_key) != secret_material:
|
|
217
|
+
raise WalletBackendError("Argon2id envelope verification failed before migration.")
|
|
218
|
+
|
|
219
|
+
backup = path.with_name(path.name + ARGON2ID_BACKUP_SUFFIX)
|
|
220
|
+
if backup.exists():
|
|
221
|
+
backup_text = backup.read_text(encoding="utf-8")
|
|
222
|
+
if envelope_kdf(backup_text) != KDF_ARGON2ID:
|
|
223
|
+
raise WalletBackendError(f"Envelope migration backup is invalid: {backup}")
|
|
224
|
+
if decrypt_secret_material(backup_text, master_key=master_key) != secret_material:
|
|
225
|
+
raise WalletBackendError(f"Envelope migration backup does not match: {backup}")
|
|
226
|
+
else:
|
|
227
|
+
atomic_write_text(backup, raw_text, mode=0o600)
|
|
228
|
+
if backup.read_text(encoding="utf-8") != raw_text:
|
|
229
|
+
raise WalletBackendError(f"Envelope migration backup could not be verified: {backup}")
|
|
230
|
+
|
|
231
|
+
if metadata is None:
|
|
232
|
+
try:
|
|
233
|
+
parsed = json.loads(raw_text)
|
|
234
|
+
original_metadata = parsed.get("metadata") if isinstance(parsed, dict) else None
|
|
235
|
+
except ValueError:
|
|
236
|
+
original_metadata = None
|
|
237
|
+
metadata = original_metadata if isinstance(original_metadata, dict) else None
|
|
238
|
+
|
|
239
|
+
migrated = encrypt_secret_material(
|
|
240
|
+
secret_material,
|
|
241
|
+
master_key=master_key,
|
|
242
|
+
metadata=metadata,
|
|
243
|
+
kdf=KDF_HKDF_SHA256,
|
|
244
|
+
)
|
|
245
|
+
if decrypt_secret_material(migrated, master_key=master_key) != secret_material:
|
|
246
|
+
raise WalletBackendError("HKDF envelope verification failed before migration.")
|
|
247
|
+
if path.read_text(encoding="utf-8") != raw_text:
|
|
248
|
+
raise WalletBackendError(f"Envelope changed during migration: {path}")
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
atomic_write_text(path, migrated, mode=0o600)
|
|
252
|
+
written = path.read_text(encoding="utf-8")
|
|
253
|
+
if decrypt_secret_material(written, master_key=master_key) != secret_material:
|
|
254
|
+
raise WalletBackendError("HKDF envelope verification failed after migration.")
|
|
255
|
+
except Exception:
|
|
256
|
+
atomic_write_text(path, raw_text, mode=0o600)
|
|
257
|
+
raise
|
|
258
|
+
return backup
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def restore_argon2id_envelope(path: Path, *, master_key: str) -> bool:
|
|
262
|
+
"""Restore a migration backup only when it contains the same plaintext."""
|
|
263
|
+
backup = path.with_name(path.name + ARGON2ID_BACKUP_SUFFIX)
|
|
264
|
+
if not backup.exists():
|
|
265
|
+
return False
|
|
266
|
+
current_text = path.read_text(encoding="utf-8")
|
|
267
|
+
backup_text = backup.read_text(encoding="utf-8")
|
|
268
|
+
current_secret = decrypt_secret_material(current_text, master_key=master_key)
|
|
269
|
+
backup_secret = decrypt_secret_material(backup_text, master_key=master_key)
|
|
270
|
+
if current_secret != backup_secret:
|
|
271
|
+
raise WalletBackendError(
|
|
272
|
+
f"Refusing to restore stale envelope backup with different contents: {backup}"
|
|
273
|
+
)
|
|
274
|
+
if envelope_kdf(backup_text) != KDF_ARGON2ID:
|
|
275
|
+
raise WalletBackendError(f"Envelope migration backup is invalid: {backup}")
|
|
276
|
+
atomic_write_text(path, backup_text, mode=0o600)
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def remove_envelope_migration_backup(path: Path) -> None:
|
|
281
|
+
"""Discard a rollback backup after the envelope's logical content changes."""
|
|
282
|
+
try:
|
|
283
|
+
path.with_name(path.name + ARGON2ID_BACKUP_SUFFIX).unlink()
|
|
284
|
+
except FileNotFoundError:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
|
|
204
288
|
def load_wallet_secret_material(
|
|
205
289
|
path: Path,
|
|
206
290
|
*,
|
|
@@ -227,3 +311,4 @@ def write_encrypted_wallet_file(
|
|
|
227
311
|
metadata=metadata,
|
|
228
312
|
)
|
|
229
313
|
atomic_write_text(path, payload, mode=0o600)
|
|
314
|
+
remove_envelope_migration_backup(path)
|