@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.
@@ -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.58",
5
+ "version": "0.1.65",
6
6
  "contracts": {
7
7
  "tools": [
8
8
  "agentlayer_autonomous_approve",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.58",
3
+ "version": "0.1.65",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
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`; only the boot key needed to unlock that sealed bundle is written to the runtime `.env`.
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.58
1
+ 0.1.65
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Keep in sync with package.json, pyproject.toml, and the npm installer version.
4
4
  # scripts/check_release_version.mjs enforces this on release.
5
- __version__ = "0.1.58"
5
+ __version__ = "0.1.65"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -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
- def _derive_key(master_key: str, salt: bytes) -> bytes:
32
- if not master_key.strip():
33
- raise WalletBackendError(
34
- "Encrypted wallet storage requires AGENT_WALLET_BOOT_KEY and a sealed master_key."
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": "argon2id",
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
- key = _derive_key(effective_master_key, salt)
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)