@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.
@@ -0,0 +1,328 @@
1
+ """Cross-platform, dependency-free secret storage for the wallet boot key.
2
+
3
+ Backends shell out to tools already present on each desktop OS:
4
+ - macOS: /usr/bin/security (generic password in the login keychain)
5
+ - Windows: powershell DPAPI (ConvertFrom/ConvertTo-SecureString), per-user+machine
6
+ - Linux: secret-tool (libsecret) iff a Secret Service is reachable
7
+ - fallback: a 0600 plaintext file (current behavior) — never chosen over a
8
+ working keystore.
9
+
10
+ The store holds ONE install-level secret (the boot key). See
11
+ BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Protocol, runtime_checkable
22
+
23
+ from agent_wallet.config import resolve_openclaw_home
24
+ from agent_wallet.file_ops import atomic_write_text, chmod_if_exists
25
+
26
+ KEYSTORE_SERVICE = "ai.agentlayer.wallet"
27
+ BOOT_KEY_ITEM = "boot_key"
28
+ _PROBE_ITEM = "__probe__"
29
+ _KEYSTORE_BACKEND_ENV = "AGENT_WALLET_KEYSTORE_BACKEND"
30
+
31
+ _SECURITY_BIN = "/usr/bin/security"
32
+ _SUBPROCESS_TIMEOUT = 10.0
33
+
34
+
35
+ def _service() -> str:
36
+ """Keychain/Secret-Service service name. Overridable so tests never touch the
37
+ real shared slot (the OS keychain is global, not scoped to OPENCLAW_HOME)."""
38
+ return os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip() or KEYSTORE_SERVICE
39
+
40
+
41
+ def _backend_preference() -> str:
42
+ """Selected keystore backend.
43
+
44
+ auto (default) prefers the native OS keystore (macOS Keychain / Windows DPAPI /
45
+ Linux Secret Service) and falls back to a 0600 plaintext file. macOS Keychain
46
+ is prompt-free because set() writes with `-A` (open ACL, no partition-list).
47
+ Override with AGENT_WALLET_KEYSTORE_BACKEND=plaintext|macos-keychain|native|...
48
+ """
49
+ return os.getenv(_KEYSTORE_BACKEND_ENV, "auto").strip().lower()
50
+
51
+
52
+ class KeyStoreError(Exception):
53
+ """Raised when a keystore backend operation fails unexpectedly."""
54
+
55
+
56
+ @runtime_checkable
57
+ class KeyStore(Protocol):
58
+ backend_id: str
59
+
60
+ def available(self) -> bool: ...
61
+ def get(self, name: str) -> str | None: ...
62
+ def set(self, name: str, value: str) -> None: ...
63
+ def delete(self, name: str) -> None: ...
64
+
65
+
66
+ def _timeout_output(value: str | bytes | None) -> str:
67
+ if value is None:
68
+ return ""
69
+ if isinstance(value, bytes):
70
+ return value.decode(errors="replace")
71
+ return value
72
+
73
+
74
+ def _run(
75
+ argv: list[str],
76
+ *,
77
+ input_text: str | None = None,
78
+ timeout: float = _SUBPROCESS_TIMEOUT,
79
+ ) -> subprocess.CompletedProcess[str]:
80
+ kwargs: dict[str, object] = {
81
+ "capture_output": True,
82
+ "text": True,
83
+ "timeout": timeout,
84
+ "check": False,
85
+ }
86
+ if input_text is None:
87
+ kwargs["stdin"] = subprocess.DEVNULL
88
+ else:
89
+ kwargs["input"] = input_text
90
+ try:
91
+ return subprocess.run(argv, **kwargs)
92
+ except subprocess.TimeoutExpired as exc:
93
+ return subprocess.CompletedProcess(
94
+ argv,
95
+ 124,
96
+ _timeout_output(exc.stdout),
97
+ _timeout_output(exc.stderr) or f"timed out after {timeout:g}s",
98
+ )
99
+
100
+
101
+ class MacKeychainStore:
102
+ backend_id = "macos-keychain"
103
+
104
+ def available(self) -> bool:
105
+ return platform.system() == "Darwin" and Path(_SECURITY_BIN).exists()
106
+
107
+ def get(self, name: str) -> str | None:
108
+ proc = _run([_SECURITY_BIN, "find-generic-password", "-s", _service(), "-a", name, "-w"])
109
+ if proc.returncode != 0:
110
+ return None # item not found (44) or other non-fatal lookup miss
111
+ value = proc.stdout.rstrip("\n")
112
+ return value or None
113
+
114
+ def set(self, name: str, value: str) -> None:
115
+ # Recreate the item fresh with an open ACL. This is the ONLY reliably
116
+ # prompt-free way to write a login-keychain item non-interactively:
117
+ # -A -> accessible to any application without a prompt, and it
118
+ # bypasses the Sierra+ partition-list mechanism entirely.
119
+ # delete+add -> never -U-update an existing item; updating one created
120
+ # with a different ACL (or setting its partition list)
121
+ # pops a GUI keychain-password dialog in the background.
122
+ # Trade-off: any process running as this user can read the key without a
123
+ # prompt — the same runtime exposure as a 0600 file. At-rest protection
124
+ # (backups, synced home dirs, a stolen disk) is fully preserved.
125
+ _run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
126
+ proc = _run([
127
+ _SECURITY_BIN, "add-generic-password",
128
+ "-s", _service(), "-a", name,
129
+ "-w", value, "-A",
130
+ ])
131
+ if proc.returncode != 0:
132
+ raise KeyStoreError(f"security add-generic-password failed: {proc.stderr.strip()}")
133
+
134
+ def delete(self, name: str) -> None:
135
+ _run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
136
+
137
+
138
+ class WindowsDpapiStore:
139
+ backend_id = "windows-dpapi"
140
+
141
+ def _blob_path(self, name: str) -> Path:
142
+ return resolve_openclaw_home() / "keystore" / f"{name}.dpapi"
143
+
144
+ def available(self) -> bool:
145
+ return platform.system() == "Windows" and shutil.which("powershell") is not None
146
+
147
+ def get(self, name: str) -> str | None:
148
+ path = self._blob_path(name)
149
+ if not path.exists():
150
+ return None
151
+ blob = path.read_text(encoding="utf-8").strip()
152
+ if not blob:
153
+ return None
154
+ script = (
155
+ "$ErrorActionPreference='Stop';"
156
+ "$b=[Console]::In.ReadToEnd().Trim();"
157
+ "$ss=ConvertTo-SecureString $b;"
158
+ "$p=[Runtime.InteropServices.Marshal]::PtrToStringUni("
159
+ "[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss));"
160
+ "[Console]::Out.Write($p)"
161
+ )
162
+ proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=blob)
163
+ if proc.returncode != 0:
164
+ return None
165
+ return proc.stdout or None
166
+
167
+ def set(self, name: str, value: str) -> None:
168
+ script = (
169
+ "$ErrorActionPreference='Stop';"
170
+ "$v=[Console]::In.ReadToEnd();"
171
+ "$ss=ConvertTo-SecureString $v -AsPlainText -Force;"
172
+ "[Console]::Out.Write((ConvertFrom-SecureString $ss))"
173
+ )
174
+ proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=value)
175
+ if proc.returncode != 0 or not proc.stdout.strip():
176
+ raise KeyStoreError(f"DPAPI ConvertFrom-SecureString failed: {proc.stderr.strip()}")
177
+ atomic_write_text(self._blob_path(name), proc.stdout.strip() + "\n", mode=0o600)
178
+
179
+ def delete(self, name: str) -> None:
180
+ path = self._blob_path(name)
181
+ try:
182
+ path.unlink()
183
+ except FileNotFoundError:
184
+ pass
185
+
186
+
187
+ class LinuxSecretServiceStore:
188
+ backend_id = "linux-secretservice"
189
+
190
+ def available(self) -> bool:
191
+ if platform.system() != "Linux" or shutil.which("secret-tool") is None:
192
+ return False
193
+ # A probe lookup succeeds (rc 0/1) only when a Secret Service answers;
194
+ # a missing/unreachable service errors out (rc >1) or times out.
195
+ try:
196
+ proc = _run(["secret-tool", "lookup", "service", _service(), "account", "__probe__"])
197
+ except subprocess.TimeoutExpired:
198
+ return False
199
+ return proc.returncode in (0, 1)
200
+
201
+ def get(self, name: str) -> str | None:
202
+ proc = _run(["secret-tool", "lookup", "service", _service(), "account", name])
203
+ if proc.returncode != 0:
204
+ return None
205
+ value = proc.stdout.rstrip("\n")
206
+ return value or None
207
+
208
+ def set(self, name: str, value: str) -> None:
209
+ proc = _run(
210
+ ["secret-tool", "store", "--label", f"{_service()} {name}",
211
+ "service", _service(), "account", name],
212
+ input_text=value,
213
+ )
214
+ if proc.returncode != 0:
215
+ raise KeyStoreError(f"secret-tool store failed: {proc.stderr.strip()}")
216
+
217
+ def delete(self, name: str) -> None:
218
+ _run(["secret-tool", "clear", "service", _service(), "account", name])
219
+
220
+
221
+ class PlaintextFileStore:
222
+ """Fallback: a 0600 file under OPENCLAW_HOME/keystore. Always available."""
223
+
224
+ backend_id = "plaintext-file"
225
+
226
+ def _path(self, name: str) -> Path:
227
+ return resolve_openclaw_home() / "keystore" / f"{name}.plaintext"
228
+
229
+ def available(self) -> bool:
230
+ return True
231
+
232
+ def get(self, name: str) -> str | None:
233
+ path = self._path(name)
234
+ if not path.exists():
235
+ return None
236
+ chmod_if_exists(path)
237
+ value = path.read_text(encoding="utf-8").strip()
238
+ return value or None
239
+
240
+ def set(self, name: str, value: str) -> None:
241
+ atomic_write_text(self._path(name), value.strip() + "\n", mode=0o600)
242
+
243
+ def delete(self, name: str) -> None:
244
+ try:
245
+ self._path(name).unlink()
246
+ except FileNotFoundError:
247
+ pass
248
+
249
+
250
+ _keystore_cache: dict[tuple[str, str], KeyStore] = {}
251
+
252
+
253
+ def clear_keystore_cache() -> None:
254
+ """Drop the memoized keystore backend (e.g. after env/service changes)."""
255
+ _keystore_cache.clear()
256
+
257
+
258
+ def resolve_keystore() -> KeyStore:
259
+ """Memoized keystore resolution.
260
+
261
+ Resolving probes the OS keystore with subprocess calls (`security`,
262
+ `secret-tool`, powershell), so cache the winning backend per
263
+ (preference, service) for the process lifetime. Cleared by
264
+ ``agent_wallet.config.clear_secret_caches`` / ``reload_settings``.
265
+ """
266
+ cache_key = (_backend_preference(), _service())
267
+ cached = _keystore_cache.get(cache_key)
268
+ if cached is not None:
269
+ return cached
270
+ store = _resolve_keystore_uncached()
271
+ _keystore_cache[cache_key] = store
272
+ return store
273
+
274
+
275
+ def _resolve_keystore_uncached() -> KeyStore:
276
+ """Return the first available OS-native backend, else the plaintext fallback."""
277
+ preference = _backend_preference()
278
+ if preference in {"plain", "plaintext", "plaintext-file", "file"}:
279
+ return PlaintextFileStore()
280
+
281
+ candidates: list[KeyStore]
282
+ if preference in {"macos", "macos-keychain", "keychain"}:
283
+ candidates = [MacKeychainStore()]
284
+ elif preference in {"windows", "windows-dpapi", "dpapi"}:
285
+ candidates = [WindowsDpapiStore()]
286
+ elif preference in {"linux", "linux-secretservice", "secretservice"}:
287
+ candidates = [LinuxSecretServiceStore()]
288
+ elif preference == "native":
289
+ candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
290
+ else:
291
+ # Auto: native OS keystore first, plaintext fallback last. macOS Keychain
292
+ # is prompt-free now that set() uses `-A` (open ACL, no partition-list) and
293
+ # _backend_usable falls back gracefully if a session still can't authorize.
294
+ candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
295
+
296
+ for candidate in candidates:
297
+ try:
298
+ if candidate.available() and _backend_usable(candidate):
299
+ return candidate
300
+ except Exception:
301
+ continue
302
+ return PlaintextFileStore()
303
+
304
+
305
+ def _backend_usable(candidate: KeyStore) -> bool:
306
+ """Return true when a native backend can read the live key or write safely.
307
+
308
+ A desktop keystore binary can exist while the session cannot authorize
309
+ non-interactive writes. Treat that as unavailable so install/migration uses
310
+ the bounded fallback instead of hanging or repeatedly failing.
311
+ """
312
+ try:
313
+ if candidate.get(BOOT_KEY_ITEM):
314
+ return True
315
+ except Exception:
316
+ pass
317
+
318
+ probe_value = "ok"
319
+ try:
320
+ candidate.set(_PROBE_ITEM, probe_value)
321
+ return candidate.get(_PROBE_ITEM) == probe_value
322
+ except Exception:
323
+ return False
324
+ finally:
325
+ try:
326
+ candidate.delete(_PROBE_ITEM)
327
+ except Exception:
328
+ pass
@@ -33,6 +33,8 @@ def _parse_csv(value: Any) -> str:
33
33
 
34
34
 
35
35
  SECRET_CONFIG_KEYS = {"privateKey", "masterKey", "approvalSecret"}
36
+ # Always-allowed floor kept for compatibility; the live gate is the dynamic
37
+ # set of adapter-declared read-only tools (_read_only_tool_names).
36
38
  READ_ONLY_WORKER_TOOLS = frozenset(
37
39
  {
38
40
  "get_wallet_balance",
@@ -41,6 +43,20 @@ READ_ONLY_WORKER_TOOLS = frozenset(
41
43
  )
42
44
 
43
45
 
46
+ def _read_only_tool_names(context: Any) -> frozenset[str]:
47
+ """All tools the resident read worker may serve: the adapter's read-only
48
+ specs for this backend, plus the legacy floor."""
49
+ names = set(READ_ONLY_WORKER_TOOLS)
50
+ try:
51
+ for tool in context.adapter.list_tools():
52
+ spec = tool.model_dump() if hasattr(tool, "model_dump") else dict(tool)
53
+ if spec.get("read_only") is True and str(spec.get("name") or "").strip():
54
+ names.add(str(spec["name"]))
55
+ except Exception:
56
+ pass
57
+ return frozenset(names)
58
+
59
+
44
60
  def _reject_secret_config_json(config: dict[str, Any]) -> None:
45
61
  present = sorted(key for key in SECRET_CONFIG_KEYS if str(config.get(key) or "").strip())
46
62
  if present:
@@ -180,8 +196,27 @@ async def _run_invoke(
180
196
  tool_name: str,
181
197
  arguments: dict[str, Any],
182
198
  config: dict[str, Any],
199
+ *,
200
+ approval_summary: dict[str, Any] | None = None,
201
+ approval_mainnet_confirmed: bool = False,
202
+ approval_ttl_seconds: int | None = None,
183
203
  ) -> dict[str, Any]:
184
204
  context = _build_runtime_context(user_id, config)
205
+ # Mint the approval token in-process when the host passed the confirmation
206
+ # summary along with the execute call: this replaces a second cold
207
+ # `issue-approval` subprocess (full interpreter boot + onboarding) with a
208
+ # local HMAC signature. A caller-provided token always wins.
209
+ if (
210
+ isinstance(approval_summary, dict)
211
+ and not str(arguments.get("approval_token") or "").strip()
212
+ ):
213
+ arguments = dict(arguments)
214
+ arguments["approval_token"] = context.issue_execute_approval(
215
+ tool_name=tool_name,
216
+ confirmation_summary=approval_summary,
217
+ mainnet_confirmed=approval_mainnet_confirmed,
218
+ ttl_seconds=approval_ttl_seconds,
219
+ )
185
220
  result = await context.adapter.invoke(tool_name, arguments)
186
221
  return result.model_dump()
187
222
 
@@ -253,11 +288,16 @@ def _error_payload_for_exception(exc: Exception) -> dict[str, Any]:
253
288
  return payload
254
289
 
255
290
 
256
- async def _run_read_worker_tool(context: Any, request: dict[str, Any]) -> dict[str, Any]:
291
+ async def _run_read_worker_tool(
292
+ context: Any,
293
+ request: dict[str, Any],
294
+ allowed_tools: frozenset[str] | None = None,
295
+ ) -> dict[str, Any]:
257
296
  tool_name = str(request.get("tool") or "").strip()
258
- if tool_name not in READ_ONLY_WORKER_TOOLS:
297
+ effective_allowed = allowed_tools if allowed_tools is not None else READ_ONLY_WORKER_TOOLS
298
+ if tool_name not in effective_allowed:
259
299
  raise WalletBackendError(
260
- f"read-worker only allows read-only tools: {', '.join(sorted(READ_ONLY_WORKER_TOOLS))}."
300
+ f"read-worker only allows read-only tools for this backend; '{tool_name}' is not one."
261
301
  )
262
302
  arguments = request.get("arguments") or {}
263
303
  if not isinstance(arguments, dict):
@@ -268,6 +308,7 @@ async def _run_read_worker_tool(context: Any, request: dict[str, Any]) -> dict[s
268
308
 
269
309
  async def _serve_read_worker(user_id: str, config: dict[str, Any]) -> int:
270
310
  context = _build_runtime_context(user_id, config, read_only=True)
311
+ allowed_tools = _read_only_tool_names(context)
271
312
  while True:
272
313
  line = sys.stdin.readline()
273
314
  if not line:
@@ -282,7 +323,7 @@ async def _serve_read_worker(user_id: str, config: dict[str, Any]) -> int:
282
323
  if request.get("op") == "shutdown":
283
324
  print(json.dumps({"id": request_id, "ok": True, "stopped": True}), flush=True)
284
325
  break
285
- payload = await _run_read_worker_tool(context, request)
326
+ payload = await _run_read_worker_tool(context, request, allowed_tools)
286
327
  print(
287
328
  json.dumps(
288
329
  {
@@ -510,6 +551,15 @@ def main() -> int:
510
551
  invoke_parser.add_argument("--tool", required=True)
511
552
  invoke_parser.add_argument("--arguments-json", default="{}")
512
553
  invoke_parser.add_argument("--config-json", default="{}")
554
+ invoke_parser.add_argument(
555
+ "--approval-summary-json",
556
+ help=(
557
+ "Confirmation summary for execute calls; when provided, the approval "
558
+ "token is minted in this process instead of a separate issue-approval run."
559
+ ),
560
+ )
561
+ invoke_parser.add_argument("--approval-mainnet-confirmed", action="store_true")
562
+ invoke_parser.add_argument("--approval-ttl-seconds", type=int)
513
563
 
514
564
  approval_parser = subparsers.add_parser("issue-approval")
515
565
  approval_parser.add_argument("--user-id", required=True)
@@ -582,6 +632,15 @@ def main() -> int:
582
632
  read_worker_parser.add_argument("--user-id", required=True)
583
633
  read_worker_parser.add_argument("--config-json", default="{}")
584
634
 
635
+ subparsers.add_parser("boot-key-export")
636
+
637
+ boot_key_import_parser = subparsers.add_parser("boot-key-import")
638
+ boot_key_import_parser.add_argument(
639
+ "--key-stdin",
640
+ action="store_true",
641
+ help="Read the boot key to import from stdin.",
642
+ )
643
+
585
644
  args = parser.parse_args()
586
645
 
587
646
  try:
@@ -705,13 +764,33 @@ def main() -> int:
705
764
  payload = asyncio.run(_run_evm_wallet_lock(args.user_id, config))
706
765
  elif args.command == "read-worker":
707
766
  return asyncio.run(_serve_read_worker(args.user_id, config))
767
+ elif args.command == "boot-key-export":
768
+ from agent_wallet.boot_key_recovery import export_boot_key, export_warning
769
+
770
+ print(export_warning(), file=sys.stderr)
771
+ payload = {"boot_key": export_boot_key()}
772
+ elif args.command == "boot-key-import":
773
+ if not args.key_stdin:
774
+ raise WalletBackendError("boot-key-import requires --key-stdin.")
775
+ from agent_wallet.boot_key_recovery import import_boot_key
776
+
777
+ payload = import_boot_key(_read_stdin_secret("boot key"))
708
778
  else:
779
+ approval_summary = None
780
+ raw_summary = getattr(args, "approval_summary_json", None)
781
+ if raw_summary:
782
+ approval_summary = _load_json(raw_summary)
709
783
  payload = asyncio.run(
710
784
  _run_invoke(
711
785
  args.user_id,
712
786
  args.tool,
713
787
  _load_json(args.arguments_json),
714
788
  config,
789
+ approval_summary=approval_summary,
790
+ approval_mainnet_confirmed=bool(
791
+ getattr(args, "approval_mainnet_confirmed", False)
792
+ ),
793
+ approval_ttl_seconds=getattr(args, "approval_ttl_seconds", None),
715
794
  )
716
795
  )
717
796
  except Exception as exc:
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from typing import Any
7
7
 
8
8
  from agent_wallet.approval import issue_approval_token
9
+ from agent_wallet.boot_key_migration import migrate_boot_key_to_keystore
9
10
  from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
10
11
  from agent_wallet.config import normalize_btc_network, normalize_evm_network, settings
11
12
  from agent_wallet.evm_user_wallets import ensure_user_evm_wallet_ready
@@ -19,6 +20,24 @@ from agent_wallet.wallet_layer.base import AgentWalletBackend, WalletBackendErro
19
20
  from agent_wallet.wallet_layer.wdk_evm import WdkEvmLocalWalletBackend
20
21
  from agent_wallet.wallet_layer.wdk_btc import WdkBtcLocalWalletBackend
21
22
 
23
+ _boot_key_migration_done = False
24
+
25
+
26
+ def ensure_boot_key_migrated_once() -> None:
27
+ """Run the boot-key keystore migration at most once per process. Never raises.
28
+
29
+ Best-effort: a failure here must never block wallet onboarding — the legacy
30
+ plaintext sources still resolve via ``resolve_boot_key``.
31
+ """
32
+ global _boot_key_migration_done
33
+ if _boot_key_migration_done:
34
+ return
35
+ _boot_key_migration_done = True
36
+ try:
37
+ migrate_boot_key_to_keystore()
38
+ except Exception:
39
+ pass
40
+
22
41
 
23
42
  @dataclass(slots=True)
24
43
  class OpenClawWalletRuntimeContext:
@@ -98,6 +117,7 @@ def onboard_openclaw_user_wallet(
98
117
  wdk_evm_account_index: int | None = None,
99
118
  ) -> OpenClawWalletRuntimeContext:
100
119
  """Provision and assemble a runtime-ready wallet context for one OpenClaw user."""
120
+ ensure_boot_key_migrated_once()
101
121
  backend_name = str(backend or settings.agent_wallet_backend).strip().lower()
102
122
  effective_sign_only = True if read_only else (
103
123
  settings.agent_wallet_sign_only if sign_only is None else sign_only
@@ -11,6 +11,16 @@ from agent_wallet.wallet_layer.base import WalletBackendError
11
11
 
12
12
  SEALED_KEYS_FILENAME = "sealed_keys.json"
13
13
 
14
+ # Single-entry cache: (boot_key, path, mtime_ns, size) -> secrets. Unsealing
15
+ # runs a KDF, so repeated resolutions in one process must pay it only once.
16
+ # File identity in the key makes rotation (re-seal) self-invalidating.
17
+ _unseal_cache: dict[tuple[str, str, int, int], dict[str, str]] = {}
18
+
19
+
20
+ def clear_unseal_cache() -> None:
21
+ """Drop cached unsealed secrets (wired into config.clear_secret_caches)."""
22
+ _unseal_cache.clear()
23
+
14
24
 
15
25
  def resolve_sealed_keys_path() -> Path:
16
26
  """Resolve the encrypted secret bundle path under the OpenClaw home directory."""
@@ -35,11 +45,12 @@ def seal_keys(boot_key: str, secrets: dict[str, str]) -> Path:
35
45
  encrypted = encrypt_secret_material(payload, master_key=boot_key)
36
46
  path = resolve_sealed_keys_path()
37
47
  atomic_write_text(path, encrypted, mode=0o600)
48
+ clear_unseal_cache()
38
49
  return path
39
50
 
40
51
 
41
52
  def unseal_keys(boot_key: str) -> dict[str, str]:
42
- """Decrypt all secrets from the sealed file."""
53
+ """Decrypt all secrets from the sealed file. Memoized by file identity."""
43
54
  if not boot_key.strip():
44
55
  return {}
45
56
 
@@ -47,7 +58,17 @@ def unseal_keys(boot_key: str) -> dict[str, str]:
47
58
  if not path.exists():
48
59
  return {}
49
60
 
50
- plaintext = decrypt_secret_material(path.read_text(encoding="utf-8"), master_key=boot_key)
61
+ try:
62
+ stat = path.stat()
63
+ except OSError:
64
+ return {}
65
+ cache_key = (boot_key, str(path), stat.st_mtime_ns, stat.st_size)
66
+ cached = _unseal_cache.get(cache_key)
67
+ if cached is not None:
68
+ return dict(cached)
69
+
70
+ raw_text = path.read_text(encoding="utf-8")
71
+ plaintext = decrypt_secret_material(raw_text, master_key=boot_key)
51
72
  try:
52
73
  payload = json.loads(plaintext)
53
74
  except json.JSONDecodeError as exc:
@@ -58,4 +79,39 @@ def unseal_keys(boot_key: str) -> dict[str, str]:
58
79
  for key, value in payload.items():
59
80
  if isinstance(key, str) and isinstance(value, str):
60
81
  secrets[key] = value
82
+ _maybe_migrate_envelope_kdf(boot_key, secrets, raw_text)
83
+ _unseal_cache.clear()
84
+ _unseal_cache[cache_key] = dict(secrets)
61
85
  return secrets
86
+
87
+
88
+ def _maybe_migrate_envelope_kdf(boot_key: str, secrets: dict[str, str], raw_text: str) -> None:
89
+ """Lazily re-seal an argon2id file as hkdf-sha256 after a successful unseal.
90
+
91
+ Best-effort: any failure leaves the (still readable) argon2id file in
92
+ place. The boot key is machine-generated high entropy, so hkdf loses no
93
+ security while dropping ~1s of KDF per cold process. Rollback caveat:
94
+ pre-hkdf runtimes cannot read the rewritten file — kill switch is
95
+ AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0.
96
+ """
97
+ from agent_wallet.config import envelope_kdf_migration_enabled
98
+ from agent_wallet.encrypted_storage import (
99
+ KDF_ARGON2ID,
100
+ KDF_HKDF_SHA256,
101
+ _default_write_kdf,
102
+ envelope_kdf,
103
+ )
104
+
105
+ try:
106
+ if not envelope_kdf_migration_enabled():
107
+ return
108
+ if _default_write_kdf() == KDF_ARGON2ID:
109
+ return # forced-argon2id installs must not rewrite in place forever
110
+ if envelope_kdf(raw_text) != KDF_ARGON2ID:
111
+ return
112
+ payload = json.dumps(secrets, indent=2)
113
+ encrypted = encrypt_secret_material(payload, master_key=boot_key, kdf=KDF_HKDF_SHA256)
114
+ atomic_write_text(resolve_sealed_keys_path(), encrypted, mode=0o600)
115
+ clear_unseal_cache()
116
+ except Exception:
117
+ pass