@wlfi-agent/cli 1.4.12 → 1.4.14

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.
Files changed (289) hide show
  1. package/Cargo.lock +3968 -0
  2. package/Cargo.toml +50 -0
  3. package/README.md +426 -6
  4. package/crates/vault-cli-admin/Cargo.toml +26 -0
  5. package/crates/vault-cli-admin/src/io_utils.rs +500 -0
  6. package/crates/vault-cli-admin/src/main.rs +3990 -0
  7. package/crates/vault-cli-admin/src/shared_config.rs +624 -0
  8. package/crates/vault-cli-admin/src/tui/amounts.rs +180 -0
  9. package/crates/vault-cli-admin/src/tui/token_rpc.rs +250 -0
  10. package/crates/vault-cli-admin/src/tui/utils.rs +82 -0
  11. package/crates/vault-cli-admin/src/tui.rs +3410 -0
  12. package/crates/vault-cli-agent/Cargo.toml +24 -0
  13. package/crates/vault-cli-agent/src/io_utils.rs +576 -0
  14. package/crates/vault-cli-agent/src/main.rs +833 -0
  15. package/crates/vault-cli-daemon/Cargo.toml +28 -0
  16. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +216 -0
  17. package/crates/vault-cli-daemon/src/main.rs +644 -0
  18. package/crates/vault-cli-daemon/src/relay_sync.rs +894 -0
  19. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +167 -0
  20. package/crates/vault-daemon/Cargo.toml +32 -0
  21. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +1041 -0
  22. package/crates/vault-daemon/src/daemon_parts/core_helpers.rs +1256 -0
  23. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +622 -0
  24. package/crates/vault-daemon/src/lib.rs +54 -0
  25. package/crates/vault-daemon/src/persistence.rs +441 -0
  26. package/crates/vault-daemon/src/tests.rs +237 -0
  27. package/crates/vault-daemon/src/tests_parts/part1.rs +1224 -0
  28. package/crates/vault-daemon/src/tests_parts/part2.rs +1021 -0
  29. package/crates/vault-daemon/src/tests_parts/part3.rs +835 -0
  30. package/crates/vault-daemon/src/tests_parts/part4.rs +604 -0
  31. package/crates/vault-domain/Cargo.toml +20 -0
  32. package/crates/vault-domain/src/action.rs +849 -0
  33. package/crates/vault-domain/src/address.rs +51 -0
  34. package/crates/vault-domain/src/approval.rs +90 -0
  35. package/crates/vault-domain/src/constants.rs +4 -0
  36. package/crates/vault-domain/src/error.rs +54 -0
  37. package/crates/vault-domain/src/keys.rs +71 -0
  38. package/crates/vault-domain/src/lib.rs +42 -0
  39. package/crates/vault-domain/src/nonce.rs +102 -0
  40. package/crates/vault-domain/src/policy.rs +172 -0
  41. package/crates/vault-domain/src/request.rs +53 -0
  42. package/crates/vault-domain/src/scope.rs +24 -0
  43. package/crates/vault-domain/src/session.rs +50 -0
  44. package/crates/vault-domain/src/signature.rs +34 -0
  45. package/crates/vault-domain/src/tests.rs +651 -0
  46. package/crates/vault-domain/src/u128_as_decimal_string.rs +44 -0
  47. package/crates/vault-policy/Cargo.toml +17 -0
  48. package/crates/vault-policy/src/engine.rs +301 -0
  49. package/crates/vault-policy/src/error.rs +81 -0
  50. package/crates/vault-policy/src/lib.rs +17 -0
  51. package/crates/vault-policy/src/report.rs +34 -0
  52. package/crates/vault-policy/src/tests.rs +891 -0
  53. package/crates/vault-policy/src/tests_explain.rs +78 -0
  54. package/crates/vault-sdk-agent/Cargo.toml +21 -0
  55. package/crates/vault-sdk-agent/src/lib.rs +711 -0
  56. package/crates/vault-signer/Cargo.toml +25 -0
  57. package/crates/vault-signer/src/lib.rs +731 -0
  58. package/crates/vault-signer/tests/secure_enclave_acl.rs +54 -0
  59. package/crates/vault-transport-unix/Cargo.toml +24 -0
  60. package/crates/vault-transport-unix/src/lib.rs +1640 -0
  61. package/crates/vault-transport-xpc/Cargo.toml +25 -0
  62. package/crates/vault-transport-xpc/src/client_codec_api.rs +635 -0
  63. package/crates/vault-transport-xpc/src/lib.rs +680 -0
  64. package/crates/vault-transport-xpc/src/tests.rs +818 -0
  65. package/crates/vault-transport-xpc/tests/e2e_flow.rs +773 -0
  66. package/dist/cli.cjs +35088 -0
  67. package/dist/cli.cjs.map +1 -0
  68. package/package.json +49 -43
  69. package/packages/cache/.turbo/turbo-build.log +52 -0
  70. package/packages/cache/dist/chunk-2QFWMUXT.cjs +43 -0
  71. package/packages/cache/dist/chunk-2QFWMUXT.cjs.map +1 -0
  72. package/packages/cache/dist/chunk-4U63TZTQ.js +43 -0
  73. package/packages/cache/dist/chunk-4U63TZTQ.js.map +1 -0
  74. package/packages/cache/dist/chunk-ALQ6H7KG.cjs +404 -0
  75. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +1 -0
  76. package/packages/cache/dist/chunk-FGJEEF5N.js +404 -0
  77. package/packages/cache/dist/chunk-FGJEEF5N.js.map +1 -0
  78. package/packages/cache/dist/chunk-UYNEHZHB.cjs +45 -0
  79. package/packages/cache/dist/chunk-UYNEHZHB.cjs.map +1 -0
  80. package/packages/cache/dist/chunk-VXVMPG3W.js +45 -0
  81. package/packages/cache/dist/chunk-VXVMPG3W.js.map +1 -0
  82. package/packages/cache/dist/client/index.cjs +11 -0
  83. package/packages/cache/dist/client/index.cjs.map +1 -0
  84. package/packages/cache/dist/client/index.d.cts +15 -0
  85. package/packages/cache/dist/client/index.d.ts +15 -0
  86. package/packages/cache/dist/client/index.js +11 -0
  87. package/packages/cache/dist/client/index.js.map +1 -0
  88. package/packages/cache/dist/errors/index.cjs +11 -0
  89. package/packages/cache/dist/errors/index.cjs.map +1 -0
  90. package/packages/cache/dist/errors/index.d.cts +26 -0
  91. package/packages/cache/dist/errors/index.d.ts +26 -0
  92. package/packages/cache/dist/errors/index.js +11 -0
  93. package/packages/cache/dist/errors/index.js.map +1 -0
  94. package/packages/cache/dist/index.cjs +29 -0
  95. package/packages/cache/dist/index.cjs.map +1 -0
  96. package/packages/cache/dist/index.d.cts +4 -0
  97. package/packages/cache/dist/index.d.ts +4 -0
  98. package/packages/cache/dist/index.js +29 -0
  99. package/packages/cache/dist/index.js.map +1 -0
  100. package/packages/cache/dist/service/index.cjs +15 -0
  101. package/packages/cache/dist/service/index.cjs.map +1 -0
  102. package/packages/cache/dist/service/index.d.cts +184 -0
  103. package/packages/cache/dist/service/index.d.ts +184 -0
  104. package/packages/cache/dist/service/index.js +15 -0
  105. package/packages/cache/dist/service/index.js.map +1 -0
  106. package/packages/cache/node_modules/.bin/jiti +17 -0
  107. package/packages/cache/node_modules/.bin/tsc +17 -0
  108. package/packages/cache/node_modules/.bin/tsserver +17 -0
  109. package/packages/cache/node_modules/.bin/tsup +17 -0
  110. package/packages/cache/node_modules/.bin/tsup-node +17 -0
  111. package/packages/cache/node_modules/.bin/tsx +17 -0
  112. package/packages/cache/node_modules/.bin/vitest +17 -0
  113. package/packages/cache/package.json +48 -0
  114. package/packages/cache/src/client/index.ts +56 -0
  115. package/packages/cache/src/errors/index.ts +53 -0
  116. package/packages/cache/src/index.ts +3 -0
  117. package/packages/cache/src/service/index.test.ts +263 -0
  118. package/packages/cache/src/service/index.ts +678 -0
  119. package/packages/cache/tsconfig.json +13 -0
  120. package/packages/cache/tsup.config.ts +13 -0
  121. package/packages/cache/vitest.config.ts +16 -0
  122. package/packages/config/.turbo/turbo-build.log +18 -0
  123. package/packages/config/dist/index.cjs +1037 -0
  124. package/packages/config/dist/index.cjs.map +1 -0
  125. package/packages/config/dist/index.d.ts +131 -0
  126. package/packages/config/node_modules/.bin/jiti +17 -0
  127. package/packages/config/node_modules/.bin/tsc +17 -0
  128. package/packages/config/node_modules/.bin/tsserver +17 -0
  129. package/packages/config/node_modules/.bin/tsup +17 -0
  130. package/packages/config/node_modules/.bin/tsup-node +17 -0
  131. package/packages/config/node_modules/.bin/tsx +17 -0
  132. package/packages/config/package.json +21 -0
  133. package/packages/config/src/index.js +1 -0
  134. package/packages/config/src/index.ts +1282 -0
  135. package/packages/config/tsconfig.json +4 -0
  136. package/packages/rpc/.turbo/turbo-build.log +32 -0
  137. package/packages/rpc/dist/_esm-BCLXDO2R.cjs +3660 -0
  138. package/packages/rpc/dist/_esm-BCLXDO2R.cjs.map +1 -0
  139. package/packages/rpc/dist/ccip-OWJLAW55.cjs +16 -0
  140. package/packages/rpc/dist/ccip-OWJLAW55.cjs.map +1 -0
  141. package/packages/rpc/dist/chunk-APQIFZ3B.cjs +6247 -0
  142. package/packages/rpc/dist/chunk-APQIFZ3B.cjs.map +1 -0
  143. package/packages/rpc/dist/chunk-CDO2GWRD.cjs +410 -0
  144. package/packages/rpc/dist/chunk-CDO2GWRD.cjs.map +1 -0
  145. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +2249 -0
  146. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs.map +1 -0
  147. package/packages/rpc/dist/chunk-TZDTAHWR.cjs +44 -0
  148. package/packages/rpc/dist/chunk-TZDTAHWR.cjs.map +1 -0
  149. package/packages/rpc/dist/index.cjs +7342 -0
  150. package/packages/rpc/dist/index.cjs.map +1 -0
  151. package/packages/rpc/dist/index.d.ts +3857 -0
  152. package/packages/rpc/dist/secp256k1-WCNM675D.cjs +18 -0
  153. package/packages/rpc/dist/secp256k1-WCNM675D.cjs.map +1 -0
  154. package/packages/rpc/node_modules/.bin/jiti +17 -0
  155. package/packages/rpc/node_modules/.bin/tsc +17 -0
  156. package/packages/rpc/node_modules/.bin/tsserver +17 -0
  157. package/packages/rpc/node_modules/.bin/tsup +17 -0
  158. package/packages/rpc/node_modules/.bin/tsup-node +17 -0
  159. package/packages/rpc/node_modules/.bin/tsx +17 -0
  160. package/packages/rpc/package.json +25 -0
  161. package/packages/rpc/src/index.ts +206 -0
  162. package/packages/rpc/tsconfig.json +4 -0
  163. package/packages/typescript/base.json +36 -0
  164. package/packages/typescript/nextjs.json +17 -0
  165. package/packages/typescript/package.json +10 -0
  166. package/packages/ui/.turbo/turbo-build.log +44 -0
  167. package/packages/ui/dist/chunk-MOAFBKSA.js +11 -0
  168. package/packages/ui/dist/chunk-MOAFBKSA.js.map +1 -0
  169. package/packages/ui/dist/components/badge.d.ts +12 -0
  170. package/packages/ui/dist/components/badge.js +31 -0
  171. package/packages/ui/dist/components/badge.js.map +1 -0
  172. package/packages/ui/dist/components/button.d.ts +13 -0
  173. package/packages/ui/dist/components/button.js +40 -0
  174. package/packages/ui/dist/components/button.js.map +1 -0
  175. package/packages/ui/dist/components/card.d.ts +10 -0
  176. package/packages/ui/dist/components/card.js +39 -0
  177. package/packages/ui/dist/components/card.js.map +1 -0
  178. package/packages/ui/dist/components/input.d.ts +5 -0
  179. package/packages/ui/dist/components/input.js +28 -0
  180. package/packages/ui/dist/components/input.js.map +1 -0
  181. package/packages/ui/dist/components/label.d.ts +5 -0
  182. package/packages/ui/dist/components/label.js +13 -0
  183. package/packages/ui/dist/components/label.js.map +1 -0
  184. package/packages/ui/dist/components/separator.d.ts +5 -0
  185. package/packages/ui/dist/components/separator.js +13 -0
  186. package/packages/ui/dist/components/separator.js.map +1 -0
  187. package/packages/ui/dist/components/textarea.d.ts +5 -0
  188. package/packages/ui/dist/components/textarea.js +27 -0
  189. package/packages/ui/dist/components/textarea.js.map +1 -0
  190. package/packages/ui/dist/tailwind.d.ts +56 -0
  191. package/packages/ui/dist/tailwind.js +60 -0
  192. package/packages/ui/dist/tailwind.js.map +1 -0
  193. package/packages/ui/dist/utils/cn.d.ts +5 -0
  194. package/packages/ui/dist/utils/cn.js +7 -0
  195. package/packages/ui/dist/utils/cn.js.map +1 -0
  196. package/packages/ui/node_modules/.bin/jiti +17 -0
  197. package/packages/ui/node_modules/.bin/tsc +17 -0
  198. package/packages/ui/node_modules/.bin/tsserver +17 -0
  199. package/packages/ui/node_modules/.bin/tsup +17 -0
  200. package/packages/ui/node_modules/.bin/tsup-node +17 -0
  201. package/packages/ui/node_modules/.bin/tsx +17 -0
  202. package/packages/ui/package.json +69 -0
  203. package/packages/ui/src/components/badge.tsx +27 -0
  204. package/packages/ui/src/components/button.tsx +40 -0
  205. package/packages/ui/src/components/card.tsx +31 -0
  206. package/packages/ui/src/components/input.tsx +21 -0
  207. package/packages/ui/src/components/label.tsx +6 -0
  208. package/packages/ui/src/components/separator.tsx +6 -0
  209. package/packages/ui/src/components/textarea.tsx +20 -0
  210. package/packages/ui/src/globals.css +70 -0
  211. package/packages/ui/src/tailwind.ts +56 -0
  212. package/packages/ui/src/utils/cn.ts +6 -0
  213. package/packages/ui/tsconfig.json +20 -0
  214. package/packages/ui/tsup.config.ts +20 -0
  215. package/pnpm-workspace.yaml +4 -0
  216. package/scripts/install-rust-binaries.mjs +84 -0
  217. package/scripts/launchd/install-user-daemon.sh +358 -0
  218. package/scripts/launchd/run-vault-daemon.sh +5 -0
  219. package/scripts/launchd/run-wlfi-agent-daemon.sh +73 -0
  220. package/scripts/launchd/uninstall-user-daemon.sh +103 -0
  221. package/src/cli.ts +2121 -0
  222. package/src/lib/admin-guard.js +1 -0
  223. package/src/lib/admin-guard.ts +185 -0
  224. package/src/lib/admin-passthrough.ts +33 -0
  225. package/src/lib/admin-reset.ts +751 -0
  226. package/src/lib/admin-setup.ts +1612 -0
  227. package/src/lib/agent-auth-clear.js +1 -0
  228. package/src/lib/agent-auth-clear.ts +58 -0
  229. package/src/lib/agent-auth-forwarding.js +1 -0
  230. package/src/lib/agent-auth-forwarding.ts +149 -0
  231. package/src/lib/agent-auth-migrate.js +1 -0
  232. package/src/lib/agent-auth-migrate.ts +150 -0
  233. package/src/lib/agent-auth-revoke.ts +103 -0
  234. package/src/lib/agent-auth-rotate.ts +107 -0
  235. package/src/lib/agent-auth-token.js +1 -0
  236. package/src/lib/agent-auth-token.ts +25 -0
  237. package/src/lib/agent-auth.ts +89 -0
  238. package/src/lib/asset-broadcast.js +1 -0
  239. package/src/lib/asset-broadcast.ts +285 -0
  240. package/src/lib/bootstrap-artifacts.js +1 -0
  241. package/src/lib/bootstrap-artifacts.ts +205 -0
  242. package/src/lib/bootstrap-credentials.js +1 -0
  243. package/src/lib/bootstrap-credentials.ts +832 -0
  244. package/src/lib/config-amounts.js +1 -0
  245. package/src/lib/config-amounts.ts +189 -0
  246. package/src/lib/config-mutation.ts +27 -0
  247. package/src/lib/fs-trust.js +1 -0
  248. package/src/lib/fs-trust.ts +537 -0
  249. package/src/lib/keychain.js +1 -0
  250. package/src/lib/keychain.ts +225 -0
  251. package/src/lib/local-admin-access.ts +106 -0
  252. package/src/lib/network-selection.js +1 -0
  253. package/src/lib/network-selection.ts +71 -0
  254. package/src/lib/passthrough-security.js +1 -0
  255. package/src/lib/passthrough-security.ts +114 -0
  256. package/src/lib/rpc-guard.js +1 -0
  257. package/src/lib/rpc-guard.ts +7 -0
  258. package/src/lib/rust-spawn-options.js +1 -0
  259. package/src/lib/rust-spawn-options.ts +98 -0
  260. package/src/lib/rust.js +1 -0
  261. package/src/lib/rust.ts +143 -0
  262. package/src/lib/signed-tx.js +1 -0
  263. package/src/lib/signed-tx.ts +116 -0
  264. package/src/lib/status-repair-cli.ts +116 -0
  265. package/src/lib/sudo.js +1 -0
  266. package/src/lib/sudo.ts +172 -0
  267. package/src/lib/vault-password-forwarding.js +1 -0
  268. package/src/lib/vault-password-forwarding.ts +155 -0
  269. package/src/lib/wallet-profile.js +1 -0
  270. package/src/lib/wallet-profile.ts +332 -0
  271. package/src/lib/wallet-repair.js +1 -0
  272. package/src/lib/wallet-repair.ts +304 -0
  273. package/src/lib/wallet-setup.js +1 -0
  274. package/src/lib/wallet-setup.ts +1466 -0
  275. package/src/lib/wallet-status.js +1 -0
  276. package/src/lib/wallet-status.ts +640 -0
  277. package/tsconfig.base.json +17 -0
  278. package/tsconfig.json +10 -0
  279. package/tsup.config.ts +25 -0
  280. package/turbo.json +41 -0
  281. package/LICENSE.md +0 -1
  282. package/dist/wlfa/index.cjs +0 -250
  283. package/dist/wlfa/index.d.cts +0 -1
  284. package/dist/wlfa/index.d.ts +0 -1
  285. package/dist/wlfa/index.js +0 -250
  286. package/dist/wlfc/index.cjs +0 -1894
  287. package/dist/wlfc/index.d.cts +0 -1
  288. package/dist/wlfc/index.d.ts +0 -1
  289. package/dist/wlfc/index.js +0 -1894
@@ -0,0 +1,1041 @@
1
+ #[async_trait]
2
+ impl<B> KeyManagerDaemonApi for InMemoryDaemon<B>
3
+ where
4
+ B: VaultSignerBackend,
5
+ {
6
+ async fn issue_lease(&self, vault_password: &str) -> Result<Lease, DaemonError> {
7
+ let _state_guard = self.state_persist_guard.lock().await;
8
+ let backup = self.backup_state_if_persistent()?;
9
+ self.authenticate_password(vault_password)?;
10
+ let now = OffsetDateTime::now_utc();
11
+ let expires_at = now.checked_add(self.config.lease_ttl).ok_or_else(|| {
12
+ DaemonError::InvalidConfig("lease_ttl causes timestamp overflow".to_string())
13
+ })?;
14
+ let lease = Lease {
15
+ lease_id: Uuid::new_v4(),
16
+ issued_at: now,
17
+ expires_at,
18
+ };
19
+ {
20
+ let mut leases = self.leases.write().map_err(|_| DaemonError::LockPoisoned)?;
21
+ // Retain only currently valid leases so corrupted/future-dated entries
22
+ // cannot permanently consume capacity.
23
+ leases.retain(|_, existing_lease| existing_lease.is_valid_at(now));
24
+ if leases.len() >= self.config.max_active_leases {
25
+ return Err(DaemonError::TooManyActiveLeases);
26
+ }
27
+ leases.insert(lease.lease_id, lease.clone());
28
+ }
29
+ self.persist_or_revert(backup)?;
30
+ Ok(lease)
31
+ }
32
+
33
+ async fn add_policy(
34
+ &self,
35
+ session: &AdminSession,
36
+ policy: SpendingPolicy,
37
+ ) -> Result<(), DaemonError> {
38
+ let _state_guard = self.state_persist_guard.lock().await;
39
+ let backup = self.backup_state_if_persistent()?;
40
+ self.authenticate(session, OffsetDateTime::now_utc())?;
41
+ validate_policy(&policy)?;
42
+
43
+ self.policies
44
+ .write()
45
+ .map_err(|_| DaemonError::LockPoisoned)?
46
+ .insert(policy.id, policy);
47
+
48
+ self.persist_or_revert(backup)?;
49
+ Ok(())
50
+ }
51
+
52
+ async fn list_policies(
53
+ &self,
54
+ session: &AdminSession,
55
+ ) -> Result<Vec<SpendingPolicy>, DaemonError> {
56
+ self.authenticate(session, OffsetDateTime::now_utc())?;
57
+ let mut policies: Vec<SpendingPolicy> = self
58
+ .policies
59
+ .read()
60
+ .map_err(|_| DaemonError::LockPoisoned)?
61
+ .values()
62
+ .cloned()
63
+ .collect();
64
+ policies.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
65
+ Ok(policies)
66
+ }
67
+
68
+ async fn disable_policy(
69
+ &self,
70
+ session: &AdminSession,
71
+ policy_id: Uuid,
72
+ ) -> Result<(), DaemonError> {
73
+ let _state_guard = self.state_persist_guard.lock().await;
74
+ let backup = self.backup_state_if_persistent()?;
75
+ self.authenticate(session, OffsetDateTime::now_utc())?;
76
+ {
77
+ let mut policies = self
78
+ .policies
79
+ .write()
80
+ .map_err(|_| DaemonError::LockPoisoned)?;
81
+ let policy = policies
82
+ .get_mut(&policy_id)
83
+ .ok_or(DaemonError::UnknownPolicy(policy_id))?;
84
+ policy.enabled = false;
85
+ }
86
+ self.persist_or_revert(backup)?;
87
+ Ok(())
88
+ }
89
+
90
+ async fn create_vault_key(
91
+ &self,
92
+ session: &AdminSession,
93
+ request: KeyCreateRequest,
94
+ ) -> Result<VaultKey, DaemonError> {
95
+ let _state_guard = self.state_persist_guard.lock().await;
96
+ let backup = self.backup_state_if_persistent()?;
97
+ self.authenticate(session, OffsetDateTime::now_utc())?;
98
+
99
+ let vault_key = self.signer_backend.create_vault_key(request).await?;
100
+
101
+ self.vault_keys
102
+ .write()
103
+ .map_err(|_| DaemonError::LockPoisoned)?
104
+ .insert(vault_key.id, vault_key.clone());
105
+
106
+ self.persist_or_revert(backup)?;
107
+ Ok(vault_key)
108
+ }
109
+
110
+ async fn export_vault_private_key(
111
+ &self,
112
+ session: &AdminSession,
113
+ vault_key_id: Uuid,
114
+ ) -> Result<Option<String>, DaemonError> {
115
+ self.authenticate(session, OffsetDateTime::now_utc())?;
116
+
117
+ if !self
118
+ .vault_keys
119
+ .read()
120
+ .map_err(|_| DaemonError::LockPoisoned)?
121
+ .contains_key(&vault_key_id)
122
+ {
123
+ return Err(DaemonError::UnknownVaultKey(vault_key_id));
124
+ }
125
+
126
+ let mut exported = self
127
+ .signer_backend
128
+ .export_persistable_key_material(&[vault_key_id])
129
+ .map_err(DaemonError::Signer)?;
130
+ Ok(exported.remove(&vault_key_id))
131
+ }
132
+
133
+ async fn create_agent_key(
134
+ &self,
135
+ session: &AdminSession,
136
+ vault_key_id: Uuid,
137
+ attachment: PolicyAttachment,
138
+ ) -> Result<AgentCredentials, DaemonError> {
139
+ let _state_guard = self.state_persist_guard.lock().await;
140
+ let backup = self.backup_state_if_persistent()?;
141
+ let now = OffsetDateTime::now_utc();
142
+ self.authenticate(session, now)?;
143
+
144
+ if !self
145
+ .vault_keys
146
+ .read()
147
+ .map_err(|_| DaemonError::LockPoisoned)?
148
+ .contains_key(&vault_key_id)
149
+ {
150
+ return Err(DaemonError::UnknownVaultKey(vault_key_id));
151
+ }
152
+
153
+ if let PolicyAttachment::PolicySet(ids) = &attachment {
154
+ if ids.is_empty() {
155
+ return Err(DaemonError::InvalidPolicyAttachment(
156
+ "policy set cannot be empty".to_string(),
157
+ ));
158
+ }
159
+ let policies = self
160
+ .policies
161
+ .read()
162
+ .map_err(|_| DaemonError::LockPoisoned)?;
163
+ for id in ids {
164
+ if !policies.contains_key(id) {
165
+ return Err(DaemonError::UnknownPolicy(*id));
166
+ }
167
+ }
168
+ }
169
+
170
+ let agent_key = AgentKey {
171
+ id: Uuid::new_v4(),
172
+ vault_key_id,
173
+ policies: attachment,
174
+ created_at: now,
175
+ };
176
+
177
+ self.agent_keys
178
+ .write()
179
+ .map_err(|_| DaemonError::LockPoisoned)?
180
+ .insert(agent_key.id, agent_key.clone());
181
+
182
+ let auth_token = generate_agent_auth_token();
183
+ self.agent_auth_tokens
184
+ .write()
185
+ .map_err(|_| DaemonError::LockPoisoned)?
186
+ .insert(agent_key.id, hash_agent_auth_token(&auth_token));
187
+
188
+ self.persist_or_revert(backup)?;
189
+ Ok(AgentCredentials {
190
+ agent_key,
191
+ auth_token,
192
+ })
193
+ }
194
+
195
+ async fn rotate_agent_auth_token(
196
+ &self,
197
+ session: &AdminSession,
198
+ agent_key_id: Uuid,
199
+ ) -> Result<String, DaemonError> {
200
+ self.authenticate(session, OffsetDateTime::now_utc())?;
201
+ let _signing_guard = self.signing_guard.lock().await;
202
+ let _state_guard = self.state_persist_guard.lock().await;
203
+ let backup = self.backup_state_if_persistent()?;
204
+
205
+ if !self
206
+ .agent_keys
207
+ .read()
208
+ .map_err(|_| DaemonError::LockPoisoned)?
209
+ .contains_key(&agent_key_id)
210
+ {
211
+ return Err(DaemonError::UnknownAgentKey(agent_key_id));
212
+ }
213
+
214
+ let auth_token = generate_agent_auth_token();
215
+ self.agent_auth_tokens
216
+ .write()
217
+ .map_err(|_| DaemonError::LockPoisoned)?
218
+ .insert(agent_key_id, hash_agent_auth_token(&auth_token));
219
+ self.persist_or_revert(backup)?;
220
+ Ok(auth_token)
221
+ }
222
+
223
+ async fn revoke_agent_key(
224
+ &self,
225
+ session: &AdminSession,
226
+ agent_key_id: Uuid,
227
+ ) -> Result<(), DaemonError> {
228
+ self.authenticate(session, OffsetDateTime::now_utc())?;
229
+ let _signing_guard = self.signing_guard.lock().await;
230
+ let _state_guard = self.state_persist_guard.lock().await;
231
+ let backup = self.backup_state_if_persistent()?;
232
+
233
+ let removed = self
234
+ .agent_keys
235
+ .write()
236
+ .map_err(|_| DaemonError::LockPoisoned)?
237
+ .remove(&agent_key_id)
238
+ .is_some();
239
+ if !removed {
240
+ return Err(DaemonError::UnknownAgentKey(agent_key_id));
241
+ }
242
+
243
+ self.agent_auth_tokens
244
+ .write()
245
+ .map_err(|_| DaemonError::LockPoisoned)?
246
+ .remove(&agent_key_id);
247
+ self.spend_log
248
+ .write()
249
+ .map_err(|_| DaemonError::LockPoisoned)?
250
+ .retain(|event| event.agent_key_id != agent_key_id);
251
+ self.nonce_reservations
252
+ .write()
253
+ .map_err(|_| DaemonError::LockPoisoned)?
254
+ .retain(|_, reservation| reservation.agent_key_id != agent_key_id);
255
+ self.persist_or_revert(backup)?;
256
+ Ok(())
257
+ }
258
+
259
+ async fn list_manual_approval_requests(
260
+ &self,
261
+ session: &AdminSession,
262
+ ) -> Result<Vec<ManualApprovalRequest>, DaemonError> {
263
+ self.authenticate(session, OffsetDateTime::now_utc())?;
264
+ let mut requests = self
265
+ .manual_approval_requests
266
+ .read()
267
+ .map_err(|_| DaemonError::LockPoisoned)?
268
+ .values()
269
+ .cloned()
270
+ .collect::<Vec<_>>();
271
+ requests.sort_by(|left, right| right.created_at.cmp(&left.created_at));
272
+ Ok(requests)
273
+ }
274
+
275
+ async fn decide_manual_approval_request(
276
+ &self,
277
+ session: &AdminSession,
278
+ approval_request_id: Uuid,
279
+ decision: ManualApprovalDecision,
280
+ rejection_reason: Option<String>,
281
+ ) -> Result<ManualApprovalRequest, DaemonError> {
282
+ self.authenticate(session, OffsetDateTime::now_utc())?;
283
+ let _state_guard = self.state_persist_guard.lock().await;
284
+ let backup = self.backup_state_if_persistent()?;
285
+ let now = OffsetDateTime::now_utc();
286
+
287
+ let updated = {
288
+ let mut requests = self
289
+ .manual_approval_requests
290
+ .write()
291
+ .map_err(|_| DaemonError::LockPoisoned)?;
292
+ let request = requests.get_mut(&approval_request_id).ok_or(
293
+ DaemonError::UnknownManualApprovalRequest(approval_request_id),
294
+ )?;
295
+ request.updated_at = now;
296
+ match decision {
297
+ ManualApprovalDecision::Approve => {
298
+ request.status = ManualApprovalStatus::Approved;
299
+ request.rejection_reason = None;
300
+ }
301
+ ManualApprovalDecision::Reject => {
302
+ request.status = ManualApprovalStatus::Rejected;
303
+ request.rejection_reason = rejection_reason.and_then(|value| {
304
+ let trimmed = value.trim().to_string();
305
+ (!trimmed.is_empty()).then_some(trimmed)
306
+ });
307
+ }
308
+ }
309
+ request.clone()
310
+ };
311
+
312
+ self.persist_or_revert(backup)?;
313
+ Ok(updated)
314
+ }
315
+
316
+ async fn set_relay_config(
317
+ &self,
318
+ session: &AdminSession,
319
+ relay_url: Option<String>,
320
+ frontend_url: Option<String>,
321
+ ) -> Result<RelayConfig, DaemonError> {
322
+ self.authenticate(session, OffsetDateTime::now_utc())?;
323
+ let _state_guard = self.state_persist_guard.lock().await;
324
+ let backup = self.backup_state_if_persistent()?;
325
+
326
+ let normalized = normalize_optional_url("relay_url", relay_url)?;
327
+ let normalized_frontend = normalize_optional_url("frontend_url", frontend_url)?;
328
+ {
329
+ let mut relay_config = self
330
+ .relay_config
331
+ .write()
332
+ .map_err(|_| DaemonError::LockPoisoned)?;
333
+ relay_config.relay_url = normalized;
334
+ relay_config.frontend_url = normalized_frontend;
335
+ }
336
+
337
+ let relay_config = self
338
+ .relay_config
339
+ .read()
340
+ .map_err(|_| DaemonError::LockPoisoned)?
341
+ .clone();
342
+ self.persist_or_revert(backup)?;
343
+ Ok(relay_config)
344
+ }
345
+
346
+ async fn get_relay_config(&self, session: &AdminSession) -> Result<RelayConfig, DaemonError> {
347
+ self.authenticate(session, OffsetDateTime::now_utc())?;
348
+ Ok(self
349
+ .relay_config
350
+ .read()
351
+ .map_err(|_| DaemonError::LockPoisoned)?
352
+ .clone())
353
+ }
354
+
355
+ async fn evaluate_for_agent(
356
+ &self,
357
+ request: SignRequest,
358
+ ) -> Result<PolicyEvaluation, DaemonError> {
359
+ let mut request = request;
360
+ let result = {
361
+ let now = OffsetDateTime::now_utc();
362
+ let (_, _, policy_evaluation) = self.evaluate_authorized_request(&request, now)?;
363
+ Ok(policy_evaluation)
364
+ };
365
+ request.zeroize_secrets();
366
+ result
367
+ }
368
+
369
+ async fn explain_for_agent(
370
+ &self,
371
+ request: SignRequest,
372
+ ) -> Result<PolicyExplanation, DaemonError> {
373
+ let mut request = request;
374
+ let result = {
375
+ let now = OffsetDateTime::now_utc();
376
+ let (_, _, policy_explanation) = self.explain_authorized_request(&request, now)?;
377
+ Ok(policy_explanation)
378
+ };
379
+ request.zeroize_secrets();
380
+ result
381
+ }
382
+
383
+ async fn reserve_nonce(
384
+ &self,
385
+ request: NonceReservationRequest,
386
+ ) -> Result<NonceReservation, DaemonError> {
387
+ let mut request = request;
388
+ let result = async {
389
+ let _signing_guard = self.signing_guard.lock().await;
390
+ let _state_guard = self.state_persist_guard.lock().await;
391
+ let backup = self.backup_state_if_persistent()?;
392
+ let now = OffsetDateTime::now_utc();
393
+ self.validate_request_timestamps(request.requested_at, request.expires_at, now)?;
394
+ if request.chain_id == 0 {
395
+ return Err(DaemonError::InvalidNonceReservation(
396
+ "chain_id must be greater than zero".to_string(),
397
+ ));
398
+ }
399
+ let agent_key =
400
+ self.authenticate_agent(request.agent_key_id, &request.agent_auth_token)?;
401
+ self.register_replay_id(request.request_id, request.expires_at, now)?;
402
+ self.prune_nonce_reservations(now)?;
403
+
404
+ let max_lease_expires = now + self.config.nonce_reservation_ttl;
405
+ let lease_expires = if request.expires_at < max_lease_expires {
406
+ request.expires_at
407
+ } else {
408
+ max_lease_expires
409
+ };
410
+ if lease_expires <= now {
411
+ return Err(DaemonError::InvalidNonceReservation(
412
+ "nonce reservation would be immediately expired".to_string(),
413
+ ));
414
+ }
415
+
416
+ let nonce = {
417
+ let mut nonce_heads = self
418
+ .nonce_heads
419
+ .write()
420
+ .map_err(|_| DaemonError::LockPoisoned)?;
421
+ let chain_heads = nonce_heads.entry(agent_key.vault_key_id).or_default();
422
+ let head = chain_heads
423
+ .entry(request.chain_id)
424
+ .or_insert(request.min_nonce);
425
+ if *head < request.min_nonce {
426
+ *head = request.min_nonce;
427
+ }
428
+ let nonce = *head;
429
+ *head = head.checked_add(1).ok_or_else(|| {
430
+ DaemonError::InvalidNonceReservation("nonce allocation overflow".to_string())
431
+ })?;
432
+ nonce
433
+ };
434
+
435
+ let reservation = NonceReservation {
436
+ reservation_id: Uuid::new_v4(),
437
+ agent_key_id: request.agent_key_id,
438
+ vault_key_id: agent_key.vault_key_id,
439
+ chain_id: request.chain_id,
440
+ nonce,
441
+ issued_at: now,
442
+ expires_at: lease_expires,
443
+ };
444
+ self.nonce_reservations
445
+ .write()
446
+ .map_err(|_| DaemonError::LockPoisoned)?
447
+ .insert(reservation.reservation_id, reservation.clone());
448
+
449
+ self.persist_or_revert(backup)?;
450
+ Ok(reservation)
451
+ }
452
+ .await;
453
+ request.zeroize_secrets();
454
+ result
455
+ }
456
+
457
+ async fn release_nonce(&self, request: NonceReleaseRequest) -> Result<(), DaemonError> {
458
+ let mut request = request;
459
+ let result = async {
460
+ let _signing_guard = self.signing_guard.lock().await;
461
+ let _state_guard = self.state_persist_guard.lock().await;
462
+ let backup = self.backup_state_if_persistent()?;
463
+ let now = OffsetDateTime::now_utc();
464
+ self.validate_request_timestamps(request.requested_at, request.expires_at, now)?;
465
+ self.authenticate_agent(request.agent_key_id, &request.agent_auth_token)?;
466
+ self.register_replay_id(request.request_id, request.expires_at, now)?;
467
+ self.prune_nonce_reservations(now)?;
468
+
469
+ let released = {
470
+ let mut reservations = self
471
+ .nonce_reservations
472
+ .write()
473
+ .map_err(|_| DaemonError::LockPoisoned)?;
474
+ let Some(existing) = reservations.get(&request.reservation_id) else {
475
+ return Err(DaemonError::UnknownNonceReservation(request.reservation_id));
476
+ };
477
+ if existing.agent_key_id != request.agent_key_id {
478
+ return Err(DaemonError::AgentAuthenticationFailed);
479
+ }
480
+ let released = existing.clone();
481
+ reservations.remove(&request.reservation_id);
482
+ released
483
+ };
484
+ self.reclaim_unused_nonce_heads(&[released])?;
485
+ self.persist_or_revert(backup)?;
486
+ Ok(())
487
+ }
488
+ .await;
489
+ request.zeroize_secrets();
490
+ result
491
+ }
492
+
493
+ async fn sign_for_agent(&self, request: SignRequest) -> Result<Signature, DaemonError> {
494
+ let mut request = request;
495
+ let result = async {
496
+ let _signing_guard = self.signing_guard.lock().await;
497
+ let _state_guard = self.state_persist_guard.lock().await;
498
+ let backup = self.backup_state_if_persistent()?;
499
+ let now = OffsetDateTime::now_utc();
500
+ let retention_start = now - Duration::days(8);
501
+
502
+ self.spend_log
503
+ .write()
504
+ .map_err(|_| DaemonError::LockPoisoned)?
505
+ .retain(|event| event.at >= retention_start);
506
+
507
+ let (agent_key, payload_action, policy_explanation) =
508
+ self.explain_authorized_request(&request, now)?;
509
+ let approved_manual_request_id = match policy_explanation.decision {
510
+ PolicyDecision::Allow => None,
511
+ PolicyDecision::Deny(PolicyError::ManualApprovalRequired { policy_id, .. }) => {
512
+ let payload_hash = payload_hash_hex(&request.payload);
513
+ match self.resolve_manual_approval_request(
514
+ &agent_key,
515
+ &payload_action,
516
+ &payload_hash,
517
+ vec![policy_id],
518
+ now,
519
+ )? {
520
+ ManualApprovalResolution::Approved(request_id) => request_id,
521
+ ManualApprovalResolution::Pending {
522
+ approval_request_id,
523
+ relay_config,
524
+ } => {
525
+ let frontend_url = self
526
+ .relay_private_key_hex
527
+ .read()
528
+ .ok()
529
+ .and_then(|value| {
530
+ manual_approval_capability_token(&value, approval_request_id)
531
+ .ok()
532
+ })
533
+ .and_then(|approval_capability| {
534
+ manual_approval_frontend_url(
535
+ &relay_config,
536
+ approval_request_id,
537
+ &approval_capability,
538
+ )
539
+ });
540
+ self.persist_or_revert(backup)?;
541
+ return Err(DaemonError::ManualApprovalRequired {
542
+ approval_request_id,
543
+ relay_url: relay_config.relay_url.clone(),
544
+ frontend_url,
545
+ });
546
+ }
547
+ ManualApprovalResolution::Rejected(approval_request_id) => {
548
+ return Err(DaemonError::ManualApprovalRejected {
549
+ approval_request_id,
550
+ });
551
+ }
552
+ }
553
+ }
554
+ PolicyDecision::Deny(err) => return Err(DaemonError::Policy(err)),
555
+ };
556
+
557
+ self.register_replay_id(request.request_id, request.expires_at, now)?;
558
+
559
+ let vault_key = self
560
+ .vault_keys
561
+ .read()
562
+ .map_err(|_| DaemonError::LockPoisoned)?
563
+ .get(&agent_key.vault_key_id)
564
+ .cloned()
565
+ .ok_or(DaemonError::UnknownVaultKey(agent_key.vault_key_id))?;
566
+
567
+ if let AgentAction::BroadcastTx { tx } = &payload_action {
568
+ self.consume_nonce_reservation(
569
+ request.agent_key_id,
570
+ agent_key.vault_key_id,
571
+ tx.chain_id,
572
+ tx.nonce,
573
+ now,
574
+ )?;
575
+ }
576
+
577
+ let signature = match &payload_action {
578
+ AgentAction::BroadcastTx { tx } => {
579
+ if tx.tx_type != 0x02 {
580
+ return Err(DaemonError::Signer(SignerError::Unsupported(format!(
581
+ "broadcast transaction type 0x{:02x} is unsupported for signing",
582
+ tx.tx_type
583
+ ))));
584
+ }
585
+ self.sign_broadcast_eip1559(&vault_key, tx).await?
586
+ }
587
+ AgentAction::Permit2Permit { .. }
588
+ | AgentAction::Eip3009TransferWithAuthorization { .. }
589
+ | AgentAction::Eip3009ReceiveWithAuthorization { .. } => {
590
+ self.sign_typed_data_action(&vault_key, &payload_action)
591
+ .await?
592
+ }
593
+ _ => {
594
+ self.signer_backend
595
+ .sign_payload(agent_key.vault_key_id, &request.payload)
596
+ .await?
597
+ }
598
+ };
599
+
600
+ if let Some(approval_request_id) = approved_manual_request_id {
601
+ self.complete_manual_approval_request(approval_request_id, now)?;
602
+ }
603
+
604
+ let event = SpendEvent {
605
+ agent_key_id: request.agent_key_id,
606
+ chain_id: payload_action.chain_id(),
607
+ asset: payload_action.asset(),
608
+ recipient: payload_action.recipient(),
609
+ amount_wei: payload_action.amount_wei(),
610
+ at: now,
611
+ };
612
+ self.spend_log
613
+ .write()
614
+ .map_err(|_| DaemonError::LockPoisoned)?
615
+ .push(event);
616
+ self.persist_or_revert(backup)?;
617
+
618
+ Ok(signature)
619
+ }
620
+ .await;
621
+ request.zeroize_secrets();
622
+ result
623
+ }
624
+ }
625
+
626
+ fn normalize_optional_url(
627
+ label: &str,
628
+ value: Option<String>,
629
+ ) -> Result<Option<String>, DaemonError> {
630
+ let Some(value) = value else {
631
+ return Ok(None);
632
+ };
633
+ let trimmed = value.trim();
634
+ if trimmed.is_empty() {
635
+ return Ok(None);
636
+ }
637
+ let parsed = reqwest::Url::parse(trimmed).map_err(|err| {
638
+ DaemonError::InvalidRelayConfig(format!("{label} must be a valid URL: {err}"))
639
+ })?;
640
+ if !parsed.username().is_empty() || parsed.password().is_some() {
641
+ return Err(DaemonError::InvalidRelayConfig(format!(
642
+ "{label} must not include embedded username or password"
643
+ )));
644
+ }
645
+ if parsed.query().is_some() {
646
+ return Err(DaemonError::InvalidRelayConfig(format!(
647
+ "{label} must not include a query string"
648
+ )));
649
+ }
650
+ if parsed.fragment().is_some() {
651
+ return Err(DaemonError::InvalidRelayConfig(format!(
652
+ "{label} must not include a fragment"
653
+ )));
654
+ }
655
+ let host = parsed.host_str().ok_or_else(|| {
656
+ DaemonError::InvalidRelayConfig(format!("{label} must include a hostname"))
657
+ })?;
658
+ match parsed.scheme() {
659
+ "https" => Ok(Some(trimmed.to_string())),
660
+ "http" if host.eq_ignore_ascii_case("localhost") => Ok(Some(trimmed.to_string())),
661
+ "http" => {
662
+ let is_loopback = host
663
+ .parse::<std::net::IpAddr>()
664
+ .map(|ip| ip.is_loopback())
665
+ .unwrap_or(false);
666
+ if is_loopback {
667
+ Ok(Some(trimmed.to_string()))
668
+ } else {
669
+ Err(DaemonError::InvalidRelayConfig(format!(
670
+ "{label} must use https unless it targets localhost or a loopback address"
671
+ )))
672
+ }
673
+ }
674
+ _ => Err(DaemonError::InvalidRelayConfig(format!(
675
+ "{label} must use http or https"
676
+ ))),
677
+ }
678
+ }
679
+
680
+ fn validate_admin_password(password: &str) -> Result<(), DaemonError> {
681
+ if password.trim().is_empty() {
682
+ return Err(DaemonError::InvalidConfig(
683
+ "admin_password must not be empty or whitespace".to_string(),
684
+ ));
685
+ }
686
+ if password.as_bytes().len() > MAX_AUTH_SECRET_BYTES {
687
+ return Err(DaemonError::InvalidConfig(format!(
688
+ "admin_password must not exceed {MAX_AUTH_SECRET_BYTES} bytes"
689
+ )));
690
+ }
691
+
692
+ Ok(())
693
+ }
694
+
695
+ fn hash_password(password: &str, config: &DaemonConfig) -> Result<String, DaemonError> {
696
+ let salt = SaltString::generate(&mut PasswordOsRng);
697
+
698
+ let params = ParamsBuilder::new()
699
+ .m_cost(config.argon2_memory_kib)
700
+ .t_cost(config.argon2_time_cost)
701
+ .p_cost(config.argon2_parallelism)
702
+ .build()
703
+ .map_err(|err| DaemonError::PasswordHash(format!("invalid argon2 params: {err}")))?;
704
+
705
+ Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
706
+ .hash_password(password.as_bytes(), &salt)
707
+ .map(|hash| hash.to_string())
708
+ .map_err(|err| DaemonError::PasswordHash(format!("hashing failed: {err}")))
709
+ }
710
+
711
+ fn validate_config(config: &DaemonConfig) -> Result<(), DaemonError> {
712
+ if config.lease_ttl <= Duration::ZERO {
713
+ return Err(DaemonError::InvalidConfig(
714
+ "lease_ttl must be greater than zero".to_string(),
715
+ ));
716
+ }
717
+ if config.max_active_leases == 0 {
718
+ return Err(DaemonError::InvalidConfig(
719
+ "max_active_leases must be greater than zero".to_string(),
720
+ ));
721
+ }
722
+ if config.max_sign_payload_bytes == 0 {
723
+ return Err(DaemonError::InvalidConfig(
724
+ "max_sign_payload_bytes must be greater than zero".to_string(),
725
+ ));
726
+ }
727
+ if config.max_request_ttl <= Duration::ZERO {
728
+ return Err(DaemonError::InvalidConfig(
729
+ "max_request_ttl must be greater than zero".to_string(),
730
+ ));
731
+ }
732
+ if config.max_request_clock_skew < Duration::ZERO {
733
+ return Err(DaemonError::InvalidConfig(
734
+ "max_request_clock_skew must be non-negative".to_string(),
735
+ ));
736
+ }
737
+ if config.nonce_reservation_ttl <= Duration::ZERO {
738
+ return Err(DaemonError::InvalidConfig(
739
+ "nonce_reservation_ttl must be greater than zero".to_string(),
740
+ ));
741
+ }
742
+ if config.max_failed_admin_auth_attempts == 0 {
743
+ return Err(DaemonError::InvalidConfig(
744
+ "max_failed_admin_auth_attempts must be greater than zero".to_string(),
745
+ ));
746
+ }
747
+ if config.admin_auth_lockout <= Duration::ZERO {
748
+ return Err(DaemonError::InvalidConfig(
749
+ "admin_auth_lockout must be greater than zero".to_string(),
750
+ ));
751
+ }
752
+ if config.argon2_memory_kib == 0 {
753
+ return Err(DaemonError::InvalidConfig(
754
+ "argon2_memory_kib must be greater than zero".to_string(),
755
+ ));
756
+ }
757
+ if config.argon2_time_cost == 0 {
758
+ return Err(DaemonError::InvalidConfig(
759
+ "argon2_time_cost must be greater than zero".to_string(),
760
+ ));
761
+ }
762
+ if config.argon2_parallelism == 0 {
763
+ return Err(DaemonError::InvalidConfig(
764
+ "argon2_parallelism must be greater than zero".to_string(),
765
+ ));
766
+ }
767
+ Ok(())
768
+ }
769
+
770
+ fn map_domain_to_signer_error(err: vault_domain::DomainError) -> DaemonError {
771
+ DaemonError::Signer(SignerError::Unsupported(format!(
772
+ "action cannot be signed: {err}"
773
+ )))
774
+ }
775
+
776
+ fn parse_verifying_key(public_key_hex: &str) -> Result<VerifyingKey, DaemonError> {
777
+ let bytes =
778
+ hex::decode(public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex)).map_err(|_| {
779
+ DaemonError::Signer(SignerError::Internal(
780
+ "vault public key is not valid hex".to_string(),
781
+ ))
782
+ })?;
783
+ VerifyingKey::from_sec1_bytes(&bytes).map_err(|err| {
784
+ DaemonError::Signer(SignerError::Internal(format!(
785
+ "vault public key is not valid secp256k1 SEC1 bytes: {err}"
786
+ )))
787
+ })
788
+ }
789
+
790
+ fn validate_loaded_state(state: &PersistedDaemonState) -> Result<(), DaemonError> {
791
+ for policy in state.policies.values() {
792
+ validate_policy(policy)?;
793
+ }
794
+
795
+ for signer_key_id in state.software_signer_private_keys.keys() {
796
+ if !state.vault_keys.contains_key(signer_key_id) {
797
+ return Err(DaemonError::Persistence(format!(
798
+ "loaded state contains signer key material for unknown vault key {}",
799
+ signer_key_id
800
+ )));
801
+ }
802
+ }
803
+
804
+ for agent_key in state.agent_keys.values() {
805
+ if !state.vault_keys.contains_key(&agent_key.vault_key_id) {
806
+ return Err(DaemonError::Persistence(format!(
807
+ "loaded state references unknown vault key {}",
808
+ agent_key.vault_key_id
809
+ )));
810
+ }
811
+ if let PolicyAttachment::PolicySet(policy_ids) = &agent_key.policies {
812
+ if policy_ids.is_empty() {
813
+ return Err(DaemonError::Persistence(format!(
814
+ "loaded state contains empty policy attachment for agent {}",
815
+ agent_key.id
816
+ )));
817
+ }
818
+ for policy_id in policy_ids {
819
+ if !state.policies.contains_key(policy_id) {
820
+ return Err(DaemonError::Persistence(format!(
821
+ "loaded state references unknown policy {} for agent {}",
822
+ policy_id, agent_key.id
823
+ )));
824
+ }
825
+ }
826
+ }
827
+ }
828
+
829
+ for agent_key_id in state.agent_auth_tokens.keys() {
830
+ if !state.agent_keys.contains_key(agent_key_id) {
831
+ return Err(DaemonError::Persistence(format!(
832
+ "loaded state contains auth token for unknown agent {}",
833
+ agent_key_id
834
+ )));
835
+ }
836
+ }
837
+
838
+ for vault_key_id in state.nonce_heads.keys() {
839
+ if !state.vault_keys.contains_key(vault_key_id) {
840
+ return Err(DaemonError::Persistence(format!(
841
+ "loaded state contains nonce head for unknown vault key {}",
842
+ vault_key_id
843
+ )));
844
+ }
845
+ }
846
+
847
+ for reservation in state.nonce_reservations.values() {
848
+ let Some(agent_key) = state.agent_keys.get(&reservation.agent_key_id) else {
849
+ return Err(DaemonError::Persistence(format!(
850
+ "loaded nonce reservation {} references unknown agent {}",
851
+ reservation.reservation_id, reservation.agent_key_id
852
+ )));
853
+ };
854
+ if reservation.vault_key_id != agent_key.vault_key_id {
855
+ return Err(DaemonError::Persistence(format!(
856
+ "loaded nonce reservation {} vault key mismatch for agent {}",
857
+ reservation.reservation_id, reservation.agent_key_id
858
+ )));
859
+ }
860
+ if reservation.chain_id == 0 {
861
+ return Err(DaemonError::Persistence(format!(
862
+ "loaded nonce reservation {} has invalid chain_id 0",
863
+ reservation.reservation_id
864
+ )));
865
+ }
866
+ if reservation.expires_at <= reservation.issued_at {
867
+ return Err(DaemonError::Persistence(format!(
868
+ "loaded nonce reservation {} has invalid timestamps",
869
+ reservation.reservation_id
870
+ )));
871
+ }
872
+ }
873
+
874
+ for request in state.manual_approval_requests.values() {
875
+ let Some(agent_key) = state.agent_keys.get(&request.agent_key_id) else {
876
+ return Err(DaemonError::Persistence(format!(
877
+ "loaded manual approval request {} references unknown agent {}",
878
+ request.id, request.agent_key_id
879
+ )));
880
+ };
881
+ if request.vault_key_id != agent_key.vault_key_id {
882
+ return Err(DaemonError::Persistence(format!(
883
+ "loaded manual approval request {} vault key mismatch for agent {}",
884
+ request.id, request.agent_key_id
885
+ )));
886
+ }
887
+ if request.chain_id == 0 {
888
+ return Err(DaemonError::Persistence(format!(
889
+ "loaded manual approval request {} has invalid chain_id 0",
890
+ request.id
891
+ )));
892
+ }
893
+ if request.request_payload_hash_hex.trim().is_empty() {
894
+ return Err(DaemonError::Persistence(format!(
895
+ "loaded manual approval request {} has empty payload hash",
896
+ request.id
897
+ )));
898
+ }
899
+ if request.updated_at < request.created_at {
900
+ return Err(DaemonError::Persistence(format!(
901
+ "loaded manual approval request {} has invalid timestamps",
902
+ request.id
903
+ )));
904
+ }
905
+ if matches!(request.status, ManualApprovalStatus::Completed)
906
+ && request.completed_at.is_none()
907
+ {
908
+ return Err(DaemonError::Persistence(format!(
909
+ "loaded manual approval request {} is completed without completed_at",
910
+ request.id
911
+ )));
912
+ }
913
+ for policy_id in &request.triggered_by_policy_ids {
914
+ let Some(policy) = state.policies.get(policy_id) else {
915
+ return Err(DaemonError::Persistence(format!(
916
+ "loaded manual approval request {} references unknown policy {}",
917
+ request.id, policy_id
918
+ )));
919
+ };
920
+ if !matches!(policy.policy_type, vault_domain::PolicyType::ManualApproval) {
921
+ return Err(DaemonError::Persistence(format!(
922
+ "loaded manual approval request {} references non-manual policy {}",
923
+ request.id, policy_id
924
+ )));
925
+ }
926
+ }
927
+ }
928
+
929
+ let relay_url = normalize_optional_url("relay_url", state.relay_config.relay_url.clone())?;
930
+ if relay_url != state.relay_config.relay_url {
931
+ return Err(DaemonError::Persistence(
932
+ "loaded relay configuration must be normalized".to_string(),
933
+ ));
934
+ }
935
+ let frontend_url =
936
+ normalize_optional_url("frontend_url", state.relay_config.frontend_url.clone())?;
937
+ if frontend_url != state.relay_config.frontend_url {
938
+ return Err(DaemonError::Persistence(
939
+ "loaded relay frontend configuration must be normalized".to_string(),
940
+ ));
941
+ }
942
+
943
+ let private_key_bytes = hex::decode(
944
+ state.relay_private_key_hex.trim().trim_start_matches("0x"),
945
+ )
946
+ .map_err(|err| {
947
+ DaemonError::Persistence(format!("loaded relay private key is invalid hex: {err}"))
948
+ })?;
949
+ if private_key_bytes.len() != 32 {
950
+ return Err(DaemonError::Persistence(
951
+ "loaded relay private key must be 32 bytes".to_string(),
952
+ ));
953
+ }
954
+ let mut private_key = [0u8; 32];
955
+ private_key.copy_from_slice(&private_key_bytes);
956
+ let secret = x25519_dalek::StaticSecret::from(private_key);
957
+ let public = x25519_dalek::PublicKey::from(&secret);
958
+ let expected_public_key_hex = hex::encode(public.as_bytes());
959
+ if state.relay_config.daemon_public_key_hex != expected_public_key_hex {
960
+ return Err(DaemonError::Persistence(
961
+ "loaded relay public key does not match stored private key".to_string(),
962
+ ));
963
+ }
964
+
965
+ let daemon_id_bytes = hex::decode(
966
+ state
967
+ .relay_config
968
+ .daemon_id_hex
969
+ .trim()
970
+ .trim_start_matches("0x"),
971
+ )
972
+ .map_err(|err| DaemonError::Persistence(format!("loaded daemon id is invalid hex: {err}")))?;
973
+ if daemon_id_bytes.len() != 32 {
974
+ return Err(DaemonError::Persistence(
975
+ "loaded daemon id must be 32 bytes".to_string(),
976
+ ));
977
+ }
978
+
979
+ Ok(())
980
+ }
981
+
982
+ fn validate_policy(policy: &SpendingPolicy) -> Result<(), DaemonError> {
983
+ if policy.max_amount_wei == 0 {
984
+ return Err(DaemonError::InvalidPolicy(
985
+ "max_amount_wei must be greater than zero".to_string(),
986
+ ));
987
+ }
988
+
989
+ if matches!(
990
+ &policy.recipients,
991
+ vault_domain::EntityScope::Set(values) if values.is_empty()
992
+ ) {
993
+ return Err(DaemonError::InvalidPolicy(
994
+ "recipient set scope must not be empty".to_string(),
995
+ ));
996
+ }
997
+
998
+ if matches!(
999
+ &policy.assets,
1000
+ vault_domain::EntityScope::Set(values) if values.is_empty()
1001
+ ) {
1002
+ return Err(DaemonError::InvalidPolicy(
1003
+ "asset set scope must not be empty".to_string(),
1004
+ ));
1005
+ }
1006
+
1007
+ if matches!(
1008
+ &policy.networks,
1009
+ vault_domain::EntityScope::Set(values)
1010
+ if values.is_empty() || values.iter().any(|chain_id| *chain_id == 0)
1011
+ ) {
1012
+ return Err(DaemonError::InvalidPolicy(
1013
+ "network set scope must not be empty and must not contain chain_id 0".to_string(),
1014
+ ));
1015
+ }
1016
+
1017
+ Ok(())
1018
+ }
1019
+
1020
+ fn generate_agent_auth_token() -> String {
1021
+ format!("{}.{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
1022
+ }
1023
+
1024
+ fn hash_agent_auth_token(token: &str) -> [u8; 32] {
1025
+ let digest = Sha256::digest(token.as_bytes());
1026
+ let mut bytes = [0u8; 32];
1027
+ bytes.copy_from_slice(&digest);
1028
+ bytes
1029
+ }
1030
+
1031
+ fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
1032
+ if left.len() != right.len() {
1033
+ return false;
1034
+ }
1035
+
1036
+ let mut diff = 0u8;
1037
+ for (left, right) in left.iter().zip(right.iter()) {
1038
+ diff |= left ^ right;
1039
+ }
1040
+ diff == 0
1041
+ }