@agentlayer.tech/wallet 0.1.58 → 0.1.65
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 +30 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +158 -0
- package/agent-wallet/agent_wallet/boot_key_recovery.py +46 -0
- package/agent-wallet/agent_wallet/config.py +63 -1
- package/agent-wallet/agent_wallet/encrypted_storage.py +77 -9
- package/agent-wallet/agent_wallet/keystore.py +328 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +83 -4
- package/agent-wallet/agent_wallet/openclaw_runtime.py +20 -0
- package/agent-wallet/agent_wallet/sealed_keys.py +58 -2
- package/agent-wallet/agent_wallet/user_wallets.py +93 -5
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/agent-wallet/scripts/build_release_bundle.py +10 -2
- package/bin/openclaw-agent-wallet.mjs +73 -4
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/server.py +64 -44
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -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.65",
|
|
6
6
|
"contracts": {
|
|
7
7
|
"tools": [
|
|
8
8
|
"agentlayer_autonomous_approve",
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v0.1.62 - 2026-07-04
|
|
6
|
+
|
|
7
|
+
- Made default boot-key storage prompt-free on macOS. The automatic keystore
|
|
8
|
+
selection no longer probes macOS Keychain because even a read/probe through
|
|
9
|
+
`/usr/bin/security` can open a GUI password dialog during install or session
|
|
10
|
+
startup. macOS Keychain remains available only through explicit opt-in with
|
|
11
|
+
`AGENT_WALLET_KEYSTORE_BACKEND=macos-keychain` (or `native`), while the
|
|
12
|
+
default path uses the local fallback without showing Keychain prompts.
|
|
13
|
+
- `agent-wallet/agent_wallet/keystore.py`
|
|
14
|
+
- `agent-wallet/tests/smoke_keystore.py`
|
|
15
|
+
|
|
16
|
+
## v0.1.61 - 2026-07-04
|
|
17
|
+
|
|
18
|
+
- Hardened boot-key installation and migration around desktop keystores:
|
|
19
|
+
- The npm installer provisions the boot key through the runtime Python
|
|
20
|
+
keystore bridge instead of unconditionally persisting it in the release
|
|
21
|
+
`.env`, while keeping a legacy `.env` fallback when no non-interactive
|
|
22
|
+
keystore round-trip is available.
|
|
23
|
+
- The Node keystore bridge is bounded by a timeout so a hung Python/keychain
|
|
24
|
+
operation cannot stall install or reinstall.
|
|
25
|
+
- macOS keychain writes now run with non-interactive stdin, convert timeouts
|
|
26
|
+
into failed command results, and make the problematic
|
|
27
|
+
`set-generic-password-partition-list` ACL update strictly best-effort.
|
|
28
|
+
- Keystore resolution now verifies that a native backend can read the live
|
|
29
|
+
boot key or complete a write/read/delete probe before selecting it, falling
|
|
30
|
+
back cleanly when the OS tool exists but cannot authorize writes.
|
|
31
|
+
- `agent-wallet/agent_wallet/keystore.py`
|
|
32
|
+
- `bin/openclaw-agent-wallet.mjs`
|
|
33
|
+
- `agent-wallet/tests/smoke_keystore.py`
|
|
34
|
+
|
|
5
35
|
## v0.1.58 - 2026-07-01
|
|
6
36
|
|
|
7
37
|
- Resolved SPL token symbol/name in `get_wallet_portfolio` via a batched,
|
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ The CLI uses a versioned runtime layout:
|
|
|
102
102
|
~/.openclaw/agent-wallet-runtime/current # symlink → active release
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
On first install, `--yes` generates local runtime secrets. The installer stores `master_key` and `approval_secret` in `~/.openclaw/sealed_keys.json`;
|
|
105
|
+
On first install, `--yes` generates local runtime secrets. The installer stores `master_key` and `approval_secret` in `~/.openclaw/sealed_keys.json`; the boot key needed to unlock that sealed bundle stays in the prompt-free local fallback by default. macOS Keychain is opt-in (`AGENT_WALLET_KEYSTORE_BACKEND=macos-keychain`) because probing it can open a system password dialog during install or startup.
|
|
106
106
|
|
|
107
107
|
Useful CLI commands (require the global install above):
|
|
108
108
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.65
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Transparent, verified migration of the boot key into the OS keystore.
|
|
2
|
+
|
|
3
|
+
Runs at backend startup. Non-destructive: plaintext is swept ONLY after the
|
|
4
|
+
keystore read-back verifies. Idempotent and best-effort — a partial sweep
|
|
5
|
+
re-runs cleanly next start. See BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agent_wallet.config import (
|
|
14
|
+
read_boot_key_from_keystore,
|
|
15
|
+
resolve_openclaw_home,
|
|
16
|
+
settings,
|
|
17
|
+
)
|
|
18
|
+
from agent_wallet.file_ops import atomic_write_text
|
|
19
|
+
from agent_wallet.keystore import BOOT_KEY_ITEM, PlaintextFileStore, resolve_keystore
|
|
20
|
+
|
|
21
|
+
_ENV_LINE_PREFIX = "AGENT_WALLET_BOOT_KEY="
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_legacy_boot_key() -> str:
|
|
25
|
+
"""The authoritative live key from legacy sources only (env override -> file)."""
|
|
26
|
+
direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
|
|
27
|
+
if direct:
|
|
28
|
+
return direct
|
|
29
|
+
key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
|
|
30
|
+
if key_file:
|
|
31
|
+
try:
|
|
32
|
+
return Path(key_file).expanduser().read_text(encoding="utf-8").strip()
|
|
33
|
+
except OSError:
|
|
34
|
+
return ""
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _env_boot_key_value(line: str) -> str | None:
|
|
39
|
+
"""Return the boot-key value on an AGENT_WALLET_BOOT_KEY= line, else None."""
|
|
40
|
+
stripped = line.strip()
|
|
41
|
+
if not stripped.startswith(_ENV_LINE_PREFIX):
|
|
42
|
+
return None
|
|
43
|
+
return stripped[len(_ENV_LINE_PREFIX):].strip().strip('"').strip("'")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _strip_boot_key_line(env_path: Path, expected: str) -> bool:
|
|
47
|
+
"""Remove only the AGENT_WALLET_BOOT_KEY line whose value == expected.
|
|
48
|
+
|
|
49
|
+
Preserve all other vars, and never strip a line carrying a *different* value
|
|
50
|
+
(that would signal a key mismatch a human should look at). Returns True if
|
|
51
|
+
the file changed.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
55
|
+
except OSError:
|
|
56
|
+
return False
|
|
57
|
+
kept = [ln for ln in lines if _env_boot_key_value(ln) != expected]
|
|
58
|
+
if len(kept) == len(lines):
|
|
59
|
+
return False
|
|
60
|
+
atomic_write_text(env_path, ("\n".join(kept) + "\n") if kept else "", mode=0o600)
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _candidate_env_files(runtime: Path) -> list[Path]:
|
|
65
|
+
"""Boot-key .env locations to sweep, deduped.
|
|
66
|
+
|
|
67
|
+
Deliberately bounded: a recursive ``**/.env`` walk descends into the hundreds
|
|
68
|
+
of ``node_modules`` trees under the release dirs and effectively hangs onboarding
|
|
69
|
+
(observed: 650+ node_modules, glob never returns). The boot key was only ever
|
|
70
|
+
written to ``releases/<v>/agent-wallet/.env``; ``current``/``previous`` are
|
|
71
|
+
symlinks into ``releases`` and are resolved to dedupe against their targets.
|
|
72
|
+
"""
|
|
73
|
+
candidates = list(runtime.glob("releases/*/agent-wallet/.env"))
|
|
74
|
+
candidates.append(runtime / "current" / "agent-wallet" / ".env")
|
|
75
|
+
candidates.append(runtime / "previous" / "agent-wallet" / ".env")
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
out: list[Path] = []
|
|
78
|
+
for path in candidates:
|
|
79
|
+
try:
|
|
80
|
+
key = str(path.resolve())
|
|
81
|
+
except OSError:
|
|
82
|
+
key = str(path)
|
|
83
|
+
if key in seen:
|
|
84
|
+
continue
|
|
85
|
+
seen.add(key)
|
|
86
|
+
if path.exists():
|
|
87
|
+
out.append(path)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _sweep_plaintext(home: Path, expected: str) -> tuple[int, bool]:
|
|
92
|
+
"""Strip matching boot-key lines from every runtime .env; delete the boot-key file.
|
|
93
|
+
|
|
94
|
+
Idempotent and value-scoped: only plaintext equal to the authoritative
|
|
95
|
+
keystore key is removed, so re-running after a fresh installer write (which
|
|
96
|
+
re-emits the same key) cleans it up on the next start.
|
|
97
|
+
"""
|
|
98
|
+
runtime = home / "agent-wallet-runtime"
|
|
99
|
+
swept = 0
|
|
100
|
+
for env_path in _candidate_env_files(runtime):
|
|
101
|
+
try:
|
|
102
|
+
if _strip_boot_key_line(env_path, expected):
|
|
103
|
+
swept += 1
|
|
104
|
+
except Exception:
|
|
105
|
+
continue # best-effort; re-runs next start
|
|
106
|
+
removed = False
|
|
107
|
+
boot_file = runtime / "boot-key"
|
|
108
|
+
try:
|
|
109
|
+
if boot_file.read_text(encoding="utf-8").strip() == expected:
|
|
110
|
+
boot_file.unlink()
|
|
111
|
+
removed = True
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
pass
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
return swept, removed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def migrate_boot_key_to_keystore() -> dict:
|
|
120
|
+
"""Move a legacy plaintext boot key into the keystore, verify, then sweep plaintext.
|
|
121
|
+
|
|
122
|
+
Runs on every startup (guarded once per process). When the keystore already
|
|
123
|
+
holds the key, it still re-sweeps any plaintext an installer re-emitted, so a
|
|
124
|
+
per-release .env write never re-establishes a permanent leak.
|
|
125
|
+
"""
|
|
126
|
+
store = resolve_keystore()
|
|
127
|
+
home = resolve_openclaw_home()
|
|
128
|
+
|
|
129
|
+
# Do not sweep into a plaintext fallback: that offers no at-rest improvement,
|
|
130
|
+
# and the user explicitly chose to stay on the current file in that case.
|
|
131
|
+
if isinstance(store, PlaintextFileStore):
|
|
132
|
+
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
133
|
+
"removed_boot_key_file": False, "reason": "no-os-keystore"}
|
|
134
|
+
|
|
135
|
+
authoritative = read_boot_key_from_keystore()
|
|
136
|
+
first_time = False
|
|
137
|
+
if not authoritative:
|
|
138
|
+
legacy_key = _read_legacy_boot_key()
|
|
139
|
+
if not legacy_key:
|
|
140
|
+
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
141
|
+
"removed_boot_key_file": False, "reason": "no-legacy-key"}
|
|
142
|
+
try:
|
|
143
|
+
store.set(BOOT_KEY_ITEM, legacy_key)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
146
|
+
"removed_boot_key_file": False, "reason": f"keystore-set-failed: {exc}"}
|
|
147
|
+
# Verify-before-delete.
|
|
148
|
+
if read_boot_key_from_keystore() != legacy_key:
|
|
149
|
+
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
150
|
+
"removed_boot_key_file": False, "reason": "verify-failed"}
|
|
151
|
+
authoritative = legacy_key
|
|
152
|
+
first_time = True
|
|
153
|
+
|
|
154
|
+
swept, removed = _sweep_plaintext(home, authoritative)
|
|
155
|
+
migrated = first_time or swept > 0 or removed
|
|
156
|
+
return {"migrated": migrated, "backend": store.backend_id, "swept_env_files": swept,
|
|
157
|
+
"removed_boot_key_file": removed,
|
|
158
|
+
"reason": "ok" if migrated else "already-clean"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""User-facing boot-key export/import for paper backup and machine recovery.
|
|
2
|
+
|
|
3
|
+
The boot key is a retrievable string (baseline A), so recovery is simple: show
|
|
4
|
+
it for a paper backup, or write a recorded value back into the keystore on a new
|
|
5
|
+
machine. Full recovery kit = boot key + the already-encrypted sealed_keys.json +
|
|
6
|
+
wallet files. See BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from agent_wallet.config import read_boot_key_from_keystore, resolve_boot_key
|
|
12
|
+
from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
|
|
13
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
14
|
+
|
|
15
|
+
_EXPORT_WARNING = (
|
|
16
|
+
"SENSITIVE: anyone with this boot key AND your ~/.openclaw encrypted files "
|
|
17
|
+
"controls every wallet (Solana/EVM/BTC). Write it down and store it offline."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def export_boot_key() -> str:
|
|
22
|
+
"""Return the live boot key. Raises if none is resolvable."""
|
|
23
|
+
key = resolve_boot_key()
|
|
24
|
+
if not key:
|
|
25
|
+
raise WalletBackendError(
|
|
26
|
+
"No boot key is available to export. The wallet is not set up on this machine."
|
|
27
|
+
)
|
|
28
|
+
return key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def import_boot_key(value: str) -> dict:
|
|
32
|
+
"""Write a recorded boot key into the OS keystore and verify the read-back."""
|
|
33
|
+
key = str(value or "").strip()
|
|
34
|
+
if not key:
|
|
35
|
+
raise WalletBackendError("A non-empty boot key is required for import.")
|
|
36
|
+
store = resolve_keystore()
|
|
37
|
+
store.set(BOOT_KEY_ITEM, key)
|
|
38
|
+
if read_boot_key_from_keystore() != key:
|
|
39
|
+
raise WalletBackendError(
|
|
40
|
+
f"Boot key import could not be verified from the {store.backend_id} keystore."
|
|
41
|
+
)
|
|
42
|
+
return {"imported": True, "backend": store.backend_id}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def export_warning() -> str:
|
|
46
|
+
return _EXPORT_WARNING
|
|
@@ -80,6 +80,7 @@ def reload_settings() -> Settings:
|
|
|
80
80
|
refreshed = Settings()
|
|
81
81
|
for field_name in Settings.model_fields:
|
|
82
82
|
setattr(settings, field_name, getattr(refreshed, field_name))
|
|
83
|
+
clear_secret_caches()
|
|
83
84
|
return settings
|
|
84
85
|
|
|
85
86
|
|
|
@@ -385,11 +386,72 @@ def _env_bool(name: str, default: bool) -> bool:
|
|
|
385
386
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
386
387
|
|
|
387
388
|
|
|
389
|
+
def envelope_kdf_migration_enabled() -> bool:
|
|
390
|
+
"""Whether argon2id envelopes are lazily rewritten to hkdf-sha256 after a
|
|
391
|
+
successful decrypt. Disable with AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0
|
|
392
|
+
(e.g. when a rollback to a pre-hkdf runtime must stay possible)."""
|
|
393
|
+
return _env_bool("AGENT_WALLET_ENVELOPE_KDF_MIGRATION", True)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_boot_key_keystore_cache: dict[str, str] = {}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def clear_secret_caches() -> None:
|
|
400
|
+
"""Reset all process-local secret-resolution caches.
|
|
401
|
+
|
|
402
|
+
Covers the memoized keystore backend, the boot key read from the OS
|
|
403
|
+
keystore, unsealed secrets, and derived envelope keys. Wired into
|
|
404
|
+
``reload_settings`` so config reloads always observe fresh state; call it
|
|
405
|
+
directly in tests that rotate keystore contents in-process.
|
|
406
|
+
"""
|
|
407
|
+
_boot_key_keystore_cache.clear()
|
|
408
|
+
from agent_wallet.keystore import clear_keystore_cache
|
|
409
|
+
|
|
410
|
+
clear_keystore_cache()
|
|
411
|
+
from agent_wallet.sealed_keys import clear_unseal_cache
|
|
412
|
+
|
|
413
|
+
clear_unseal_cache()
|
|
414
|
+
from agent_wallet.encrypted_storage import clear_derived_key_cache
|
|
415
|
+
|
|
416
|
+
clear_derived_key_cache()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def read_boot_key_from_keystore() -> str:
|
|
420
|
+
"""Read the boot key from the OS keystore. Never raises; '' on any failure.
|
|
421
|
+
|
|
422
|
+
Successful (non-empty) reads are memoized per keystore service for the
|
|
423
|
+
process lifetime — every uncached read costs a subprocess call.
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
|
|
427
|
+
|
|
428
|
+
service_key = os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip()
|
|
429
|
+
cached = _boot_key_keystore_cache.get(service_key)
|
|
430
|
+
if cached:
|
|
431
|
+
return cached
|
|
432
|
+
value = resolve_keystore().get(BOOT_KEY_ITEM)
|
|
433
|
+
text = value.strip() if isinstance(value, str) else ""
|
|
434
|
+
if text:
|
|
435
|
+
_boot_key_keystore_cache[service_key] = text
|
|
436
|
+
return text
|
|
437
|
+
except Exception:
|
|
438
|
+
return ""
|
|
439
|
+
|
|
440
|
+
|
|
388
441
|
def resolve_boot_key() -> str:
|
|
389
|
-
"""Resolve the boot key used to unlock sealed secrets from disk.
|
|
442
|
+
"""Resolve the boot key used to unlock sealed secrets from disk.
|
|
443
|
+
|
|
444
|
+
Precedence (superset of the legacy lookup, so existing installs keep working):
|
|
445
|
+
1. AGENT_WALLET_BOOT_KEY env / settings override
|
|
446
|
+
2. OS keystore (the hardened path)
|
|
447
|
+
3. AGENT_WALLET_BOOT_KEY_FILE / boot-key file (legacy)
|
|
448
|
+
"""
|
|
390
449
|
direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
|
|
391
450
|
if direct:
|
|
392
451
|
return direct
|
|
452
|
+
from_keystore = read_boot_key_from_keystore()
|
|
453
|
+
if from_keystore:
|
|
454
|
+
return from_keystore
|
|
393
455
|
key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
|
|
394
456
|
if not key_file:
|
|
395
457
|
return ""
|
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import hmac
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
@@ -17,6 +18,10 @@ ENCRYPTED_WALLET_KIND = "openclaw-agent-wallet-secret"
|
|
|
17
18
|
ENCRYPTED_WALLET_VERSION = 1
|
|
18
19
|
USER_SCOPED_KEY_SALT = b"openclaw-agent-wallet-user-key-v1"
|
|
19
20
|
|
|
21
|
+
KDF_ARGON2ID = "argon2id"
|
|
22
|
+
KDF_HKDF_SHA256 = "hkdf-sha256"
|
|
23
|
+
_HKDF_INFO = b"openclaw-agent-wallet-envelope-hkdf-v1"
|
|
24
|
+
|
|
20
25
|
|
|
21
26
|
def _load_secretbox():
|
|
22
27
|
try:
|
|
@@ -28,11 +33,19 @@ def _load_secretbox():
|
|
|
28
33
|
return pwhash, secret, utils
|
|
29
34
|
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
# Derived envelope keys are expensive (argon2id ~1s each), so cache them by
|
|
37
|
+
# (kdf, master_key, salt). In-memory only, never serialized; bounded so a
|
|
38
|
+
# pathological caller cannot grow it without limit.
|
|
39
|
+
_derived_key_cache: dict[tuple[str, str, bytes], bytes] = {}
|
|
40
|
+
_DERIVED_KEY_CACHE_MAX = 64
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clear_derived_key_cache() -> None:
|
|
44
|
+
"""Drop cached derived keys (wired into config.clear_secret_caches)."""
|
|
45
|
+
_derived_key_cache.clear()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _kdf_argon2id(master_key: str, salt: bytes) -> bytes:
|
|
36
49
|
pwhash, secret, _ = _load_secretbox()
|
|
37
50
|
return pwhash.argon2id.kdf(
|
|
38
51
|
secret.SecretBox.KEY_SIZE,
|
|
@@ -43,6 +56,52 @@ def _derive_key(master_key: str, salt: bytes) -> bytes:
|
|
|
43
56
|
)
|
|
44
57
|
|
|
45
58
|
|
|
59
|
+
def _kdf_hkdf_sha256(master_key: str, salt: bytes) -> bytes:
|
|
60
|
+
# RFC 5869 extract+expand, one 32-byte block. Only safe because every
|
|
61
|
+
# caller passes machine-generated high-entropy key material (boot key /
|
|
62
|
+
# sealed master key) — password-hardness is what argon2id was paying for,
|
|
63
|
+
# and user passwords never reach this module.
|
|
64
|
+
prk = hmac.new(salt, master_key.encode("utf-8"), hashlib.sha256).digest()
|
|
65
|
+
return hmac.new(prk, _HKDF_INFO + b"\x01", hashlib.sha256).digest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _default_write_kdf() -> str:
|
|
69
|
+
raw = os.getenv("AGENT_WALLET_ENVELOPE_KDF", "").strip().lower()
|
|
70
|
+
return KDF_ARGON2ID if raw == KDF_ARGON2ID else KDF_HKDF_SHA256
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def envelope_kdf(raw_text: str) -> str:
|
|
74
|
+
"""Return the kdf recorded in an encrypted envelope ('' when not one)."""
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(raw_text)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
return ""
|
|
79
|
+
if not isinstance(payload, dict) or payload.get("kind") != ENCRYPTED_WALLET_KIND:
|
|
80
|
+
return ""
|
|
81
|
+
return str(payload.get("kdf") or KDF_ARGON2ID)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _derive_key(master_key: str, salt: bytes, *, kdf: str = KDF_ARGON2ID) -> bytes:
|
|
85
|
+
if not master_key.strip():
|
|
86
|
+
raise WalletBackendError(
|
|
87
|
+
"Encrypted wallet storage requires AGENT_WALLET_BOOT_KEY and a sealed master_key."
|
|
88
|
+
)
|
|
89
|
+
if kdf == KDF_HKDF_SHA256:
|
|
90
|
+
# Cheap enough to skip the cache entirely.
|
|
91
|
+
return _kdf_hkdf_sha256(master_key, bytes(salt))
|
|
92
|
+
if kdf != KDF_ARGON2ID:
|
|
93
|
+
raise WalletBackendError(f"Encrypted wallet file uses an unsupported kdf: {kdf}")
|
|
94
|
+
cache_key = (kdf, master_key, bytes(salt))
|
|
95
|
+
cached = _derived_key_cache.get(cache_key)
|
|
96
|
+
if cached is not None:
|
|
97
|
+
return cached
|
|
98
|
+
key = _kdf_argon2id(master_key, salt)
|
|
99
|
+
if len(_derived_key_cache) >= _DERIVED_KEY_CACHE_MAX:
|
|
100
|
+
_derived_key_cache.clear()
|
|
101
|
+
_derived_key_cache[cache_key] = key
|
|
102
|
+
return key
|
|
103
|
+
|
|
104
|
+
|
|
46
105
|
def _derive_user_scoped_key(
|
|
47
106
|
master_key: str,
|
|
48
107
|
*,
|
|
@@ -79,19 +138,27 @@ def encrypt_secret_material(
|
|
|
79
138
|
*,
|
|
80
139
|
master_key: str | None = None,
|
|
81
140
|
metadata: dict[str, Any] | None = None,
|
|
141
|
+
kdf: str | None = None,
|
|
82
142
|
) -> str:
|
|
83
|
-
"""Encrypt wallet secret material into a JSON envelope.
|
|
143
|
+
"""Encrypt wallet secret material into a JSON envelope.
|
|
144
|
+
|
|
145
|
+
``kdf`` selects the key derivation for this envelope; None resolves the
|
|
146
|
+
process default (hkdf-sha256 unless AGENT_WALLET_ENVELOPE_KDF=argon2id).
|
|
147
|
+
"""
|
|
148
|
+
effective_kdf = kdf if kdf is not None else _default_write_kdf()
|
|
149
|
+
if effective_kdf not in {KDF_ARGON2ID, KDF_HKDF_SHA256}:
|
|
150
|
+
raise WalletBackendError(f"Unsupported envelope kdf: {effective_kdf}")
|
|
84
151
|
_, secret, utils = _load_secretbox()
|
|
85
152
|
effective_master_key = master_key if master_key is not None else resolve_wallet_master_key()
|
|
86
153
|
salt = utils.random(16)
|
|
87
|
-
key = _derive_key(effective_master_key, salt)
|
|
154
|
+
key = _derive_key(effective_master_key, salt, kdf=effective_kdf)
|
|
88
155
|
box = secret.SecretBox(key)
|
|
89
156
|
encrypted = box.encrypt(secret_material.encode("utf-8"))
|
|
90
157
|
payload: dict[str, Any] = {
|
|
91
158
|
"kind": ENCRYPTED_WALLET_KIND,
|
|
92
159
|
"version": ENCRYPTED_WALLET_VERSION,
|
|
93
160
|
"cipher": "secretbox",
|
|
94
|
-
"kdf":
|
|
161
|
+
"kdf": effective_kdf,
|
|
95
162
|
"salt_b64": base64.b64encode(salt).decode("ascii"),
|
|
96
163
|
"nonce_b64": base64.b64encode(encrypted.nonce).decode("ascii"),
|
|
97
164
|
"ciphertext_b64": base64.b64encode(encrypted.ciphertext).decode("ascii"),
|
|
@@ -124,7 +191,8 @@ def decrypt_secret_material(
|
|
|
124
191
|
raise WalletBackendError("Encrypted wallet file is malformed.") from exc
|
|
125
192
|
|
|
126
193
|
effective_master_key = master_key if master_key is not None else resolve_wallet_master_key()
|
|
127
|
-
|
|
194
|
+
kdf = str(payload.get("kdf") or KDF_ARGON2ID)
|
|
195
|
+
key = _derive_key(effective_master_key, salt, kdf=kdf)
|
|
128
196
|
box = secret.SecretBox(key)
|
|
129
197
|
try:
|
|
130
198
|
plaintext = box.decrypt(ciphertext, nonce)
|