@wlfi-agent/cli 1.4.13 → 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 -1839
  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 -1839
@@ -0,0 +1,1256 @@
1
+ #[derive(Debug, Clone, Default)]
2
+ struct AdminAuthState {
3
+ failed_attempts: u32,
4
+ locked_until: Option<OffsetDateTime>,
5
+ }
6
+
7
+ enum ManualApprovalResolution {
8
+ Approved(Option<Uuid>),
9
+ Pending {
10
+ approval_request_id: Uuid,
11
+ relay_config: RelayConfig,
12
+ },
13
+ Rejected(Uuid),
14
+ }
15
+
16
+ pub struct InMemoryDaemon<B>
17
+ where
18
+ B: VaultSignerBackend,
19
+ {
20
+ signer_backend: B,
21
+ policy_engine: PolicyEngine,
22
+ admin_password_hash: String,
23
+ config: DaemonConfig,
24
+ leases: Arc<RwLock<HashMap<Uuid, Lease>>>,
25
+ policies: Arc<RwLock<HashMap<Uuid, SpendingPolicy>>>,
26
+ vault_keys: Arc<RwLock<HashMap<Uuid, VaultKey>>>,
27
+ agent_keys: Arc<RwLock<HashMap<Uuid, AgentKey>>>,
28
+ agent_auth_tokens: Arc<RwLock<HashMap<Uuid, [u8; 32]>>>,
29
+ replay_ids: Arc<RwLock<HashMap<Uuid, OffsetDateTime>>>,
30
+ nonce_heads: Arc<RwLock<HashMap<Uuid, HashMap<u64, u64>>>>,
31
+ nonce_reservations: Arc<RwLock<HashMap<Uuid, NonceReservation>>>,
32
+ spend_log: Arc<RwLock<Vec<SpendEvent>>>,
33
+ manual_approval_requests: Arc<RwLock<HashMap<Uuid, ManualApprovalRequest>>>,
34
+ relay_config: Arc<RwLock<RelayConfig>>,
35
+ relay_private_key_hex: Arc<RwLock<String>>,
36
+ admin_auth_state: Arc<RwLock<AdminAuthState>>,
37
+ state_store: Option<EncryptedStateStore>,
38
+ signing_guard: tokio::sync::Mutex<()>,
39
+ state_persist_guard: tokio::sync::Mutex<()>,
40
+ }
41
+
42
+ impl<B> InMemoryDaemon<B>
43
+ where
44
+ B: VaultSignerBackend,
45
+ {
46
+ /// Builds daemon and hashes admin password using Argon2.
47
+ pub fn new(
48
+ admin_password: &str,
49
+ signer_backend: B,
50
+ config: DaemonConfig,
51
+ ) -> Result<Self, DaemonError> {
52
+ validate_config(&config)?;
53
+ validate_admin_password(admin_password)?;
54
+ let admin_password_hash = hash_password(admin_password, &config)?;
55
+ Self::new_with_loaded_state(
56
+ signer_backend,
57
+ admin_password_hash,
58
+ config,
59
+ PersistedDaemonState::default(),
60
+ None,
61
+ )
62
+ }
63
+
64
+ /// Builds daemon with encrypted persistent state on local filesystem.
65
+ pub fn new_with_persistent_store(
66
+ admin_password: &str,
67
+ signer_backend: B,
68
+ config: DaemonConfig,
69
+ store_config: PersistentStoreConfig,
70
+ ) -> Result<Self, DaemonError> {
71
+ validate_config(&config)?;
72
+ validate_admin_password(admin_password)?;
73
+ let admin_password_hash = hash_password(admin_password, &config)?;
74
+ let (state_store, state) =
75
+ EncryptedStateStore::open_or_initialize(admin_password, &config, store_config)
76
+ .map_err(DaemonError::Persistence)?;
77
+ Self::new_with_loaded_state(
78
+ signer_backend,
79
+ admin_password_hash,
80
+ config,
81
+ state,
82
+ Some(state_store),
83
+ )
84
+ }
85
+
86
+ fn new_with_loaded_state(
87
+ signer_backend: B,
88
+ admin_password_hash: String,
89
+ config: DaemonConfig,
90
+ mut state: PersistedDaemonState,
91
+ state_store: Option<EncryptedStateStore>,
92
+ ) -> Result<Self, DaemonError> {
93
+ ensure_relay_identity(&mut state);
94
+ validate_loaded_state(&state)?;
95
+ signer_backend
96
+ .restore_persistable_key_material(&state.software_signer_private_keys)
97
+ .map_err(DaemonError::Signer)?;
98
+ Ok(Self {
99
+ signer_backend,
100
+ policy_engine: PolicyEngine,
101
+ admin_password_hash,
102
+ config,
103
+ leases: Arc::new(RwLock::new(state.leases)),
104
+ policies: Arc::new(RwLock::new(state.policies)),
105
+ vault_keys: Arc::new(RwLock::new(state.vault_keys)),
106
+ agent_keys: Arc::new(RwLock::new(state.agent_keys)),
107
+ agent_auth_tokens: Arc::new(RwLock::new(state.agent_auth_tokens)),
108
+ replay_ids: Arc::new(RwLock::new(state.replay_ids)),
109
+ nonce_heads: Arc::new(RwLock::new(state.nonce_heads)),
110
+ nonce_reservations: Arc::new(RwLock::new(state.nonce_reservations)),
111
+ spend_log: Arc::new(RwLock::new(state.spend_log)),
112
+ manual_approval_requests: Arc::new(RwLock::new(state.manual_approval_requests)),
113
+ relay_config: Arc::new(RwLock::new(state.relay_config)),
114
+ relay_private_key_hex: Arc::new(RwLock::new(state.relay_private_key_hex)),
115
+ admin_auth_state: Arc::new(RwLock::new(AdminAuthState::default())),
116
+ state_store,
117
+ signing_guard: tokio::sync::Mutex::new(()),
118
+ state_persist_guard: tokio::sync::Mutex::new(()),
119
+ })
120
+ }
121
+
122
+ /// Handles a serialized daemon RPC call.
123
+ pub async fn handle_rpc(
124
+ &self,
125
+ request: DaemonRpcRequest,
126
+ ) -> Result<DaemonRpcResponse, DaemonError> {
127
+ match request {
128
+ DaemonRpcRequest::IssueLease { vault_password } => {
129
+ let mut vault_password = vault_password;
130
+ let result = async {
131
+ Ok(DaemonRpcResponse::Lease(
132
+ self.issue_lease(&vault_password).await?,
133
+ ))
134
+ }
135
+ .await;
136
+ vault_password.zeroize();
137
+ result
138
+ }
139
+ DaemonRpcRequest::AddPolicy { session, policy } => {
140
+ let mut session = session;
141
+ let result = async {
142
+ self.add_policy(&session, policy).await?;
143
+ Ok(DaemonRpcResponse::Unit)
144
+ }
145
+ .await;
146
+ session.zeroize_secrets();
147
+ result
148
+ }
149
+ DaemonRpcRequest::ListPolicies { session } => {
150
+ let mut session = session;
151
+ let result = async {
152
+ Ok(DaemonRpcResponse::Policies(
153
+ self.list_policies(&session).await?,
154
+ ))
155
+ }
156
+ .await;
157
+ session.zeroize_secrets();
158
+ result
159
+ }
160
+ DaemonRpcRequest::DisablePolicy { session, policy_id } => {
161
+ let mut session = session;
162
+ let result = async {
163
+ self.disable_policy(&session, policy_id).await?;
164
+ Ok(DaemonRpcResponse::Unit)
165
+ }
166
+ .await;
167
+ session.zeroize_secrets();
168
+ result
169
+ }
170
+ DaemonRpcRequest::CreateVaultKey { session, request } => {
171
+ let mut session = session;
172
+ let result = async {
173
+ Ok(DaemonRpcResponse::VaultKey(
174
+ self.create_vault_key(&session, request).await?,
175
+ ))
176
+ }
177
+ .await;
178
+ session.zeroize_secrets();
179
+ result
180
+ }
181
+ DaemonRpcRequest::CreateAgentKey {
182
+ session,
183
+ vault_key_id,
184
+ attachment,
185
+ } => {
186
+ let mut session = session;
187
+ let result = async {
188
+ Ok(DaemonRpcResponse::AgentCredentials(
189
+ self.create_agent_key(&session, vault_key_id, attachment)
190
+ .await?,
191
+ ))
192
+ }
193
+ .await;
194
+ session.zeroize_secrets();
195
+ result
196
+ }
197
+ DaemonRpcRequest::ExportVaultPrivateKey {
198
+ session,
199
+ vault_key_id,
200
+ } => {
201
+ let mut session = session;
202
+ let result = async {
203
+ Ok(DaemonRpcResponse::PrivateKey(
204
+ self.export_vault_private_key(&session, vault_key_id).await?,
205
+ ))
206
+ }
207
+ .await;
208
+ session.zeroize_secrets();
209
+ result
210
+ }
211
+ DaemonRpcRequest::RotateAgentAuthToken {
212
+ session,
213
+ agent_key_id,
214
+ } => {
215
+ let mut session = session;
216
+ let result = async {
217
+ Ok(DaemonRpcResponse::AuthToken(
218
+ self.rotate_agent_auth_token(&session, agent_key_id).await?,
219
+ ))
220
+ }
221
+ .await;
222
+ session.zeroize_secrets();
223
+ result
224
+ }
225
+ DaemonRpcRequest::RevokeAgentKey {
226
+ session,
227
+ agent_key_id,
228
+ } => {
229
+ let mut session = session;
230
+ let result = async {
231
+ self.revoke_agent_key(&session, agent_key_id).await?;
232
+ Ok(DaemonRpcResponse::Unit)
233
+ }
234
+ .await;
235
+ session.zeroize_secrets();
236
+ result
237
+ }
238
+ DaemonRpcRequest::ListManualApprovalRequests { session } => {
239
+ let mut session = session;
240
+ let result = async {
241
+ Ok(DaemonRpcResponse::ManualApprovalRequests(
242
+ self.list_manual_approval_requests(&session).await?,
243
+ ))
244
+ }
245
+ .await;
246
+ session.zeroize_secrets();
247
+ result
248
+ }
249
+ DaemonRpcRequest::DecideManualApprovalRequest {
250
+ session,
251
+ approval_request_id,
252
+ decision,
253
+ rejection_reason,
254
+ } => {
255
+ let mut session = session;
256
+ let result = async {
257
+ Ok(DaemonRpcResponse::ManualApprovalRequest(
258
+ self.decide_manual_approval_request(
259
+ &session,
260
+ approval_request_id,
261
+ decision,
262
+ rejection_reason,
263
+ )
264
+ .await?,
265
+ ))
266
+ }
267
+ .await;
268
+ session.zeroize_secrets();
269
+ result
270
+ }
271
+ DaemonRpcRequest::SetRelayConfig {
272
+ session,
273
+ relay_url,
274
+ frontend_url,
275
+ } => {
276
+ let mut session = session;
277
+ let result = async {
278
+ Ok(DaemonRpcResponse::RelayConfig(
279
+ self.set_relay_config(&session, relay_url, frontend_url)
280
+ .await?,
281
+ ))
282
+ }
283
+ .await;
284
+ session.zeroize_secrets();
285
+ result
286
+ }
287
+ DaemonRpcRequest::GetRelayConfig { session } => {
288
+ let mut session = session;
289
+ let result = async {
290
+ Ok(DaemonRpcResponse::RelayConfig(
291
+ self.get_relay_config(&session).await?,
292
+ ))
293
+ }
294
+ .await;
295
+ session.zeroize_secrets();
296
+ result
297
+ }
298
+ DaemonRpcRequest::EvaluateForAgent { request } => Ok(
299
+ DaemonRpcResponse::PolicyEvaluation(self.evaluate_for_agent(request).await?),
300
+ ),
301
+ DaemonRpcRequest::ExplainForAgent { request } => Ok(
302
+ DaemonRpcResponse::PolicyExplanation(self.explain_for_agent(request).await?),
303
+ ),
304
+ DaemonRpcRequest::ReserveNonce { request } => Ok(DaemonRpcResponse::NonceReservation(
305
+ self.reserve_nonce(request).await?,
306
+ )),
307
+ DaemonRpcRequest::ReleaseNonce { request } => {
308
+ self.release_nonce(request).await?;
309
+ Ok(DaemonRpcResponse::Unit)
310
+ }
311
+ DaemonRpcRequest::SignForAgent { request } => Ok(DaemonRpcResponse::Signature(
312
+ self.sign_for_agent(request).await?,
313
+ )),
314
+ }
315
+ }
316
+
317
+ fn authenticate(&self, session: &AdminSession, now: OffsetDateTime) -> Result<(), DaemonError> {
318
+ self.authenticate_password(&session.vault_password)?;
319
+
320
+ let lease = self
321
+ .leases
322
+ .read()
323
+ .map_err(|_| DaemonError::LockPoisoned)?
324
+ .get(&session.lease.lease_id)
325
+ .cloned()
326
+ .ok_or(DaemonError::UnknownLease)?;
327
+
328
+ if !lease.is_valid_at(now) {
329
+ return Err(DaemonError::InvalidLease);
330
+ }
331
+
332
+ Ok(())
333
+ }
334
+
335
+ fn authenticate_password(&self, vault_password: &str) -> Result<(), DaemonError> {
336
+ let now = OffsetDateTime::now_utc();
337
+ self.enforce_admin_auth_lockout(now)?;
338
+
339
+ if vault_password.as_bytes().len() > MAX_AUTH_SECRET_BYTES {
340
+ self.record_failed_admin_auth(now)?;
341
+ return Err(DaemonError::AuthenticationFailed);
342
+ }
343
+
344
+ let parsed = PasswordHash::new(&self.admin_password_hash)
345
+ .map_err(|err| DaemonError::PasswordHash(format!("invalid hash in memory: {err}")))?;
346
+
347
+ let verification_result = Argon2::default()
348
+ .verify_password(vault_password.as_bytes(), &parsed)
349
+ .map_err(|_| DaemonError::AuthenticationFailed);
350
+
351
+ match verification_result {
352
+ Ok(()) => {
353
+ self.reset_admin_auth_state()?;
354
+ Ok(())
355
+ }
356
+ Err(err) => {
357
+ self.record_failed_admin_auth(now)?;
358
+ Err(err)
359
+ }
360
+ }
361
+ }
362
+
363
+ fn enforce_admin_auth_lockout(&self, now: OffsetDateTime) -> Result<(), DaemonError> {
364
+ let mut state = self
365
+ .admin_auth_state
366
+ .write()
367
+ .map_err(|_| DaemonError::LockPoisoned)?;
368
+
369
+ if let Some(locked_until) = state.locked_until {
370
+ if locked_until > now {
371
+ return Err(DaemonError::AuthenticationFailed);
372
+ }
373
+ state.locked_until = None;
374
+ state.failed_attempts = 0;
375
+ }
376
+
377
+ Ok(())
378
+ }
379
+
380
+ fn reset_admin_auth_state(&self) -> Result<(), DaemonError> {
381
+ let mut state = self
382
+ .admin_auth_state
383
+ .write()
384
+ .map_err(|_| DaemonError::LockPoisoned)?;
385
+ state.failed_attempts = 0;
386
+ state.locked_until = None;
387
+ Ok(())
388
+ }
389
+
390
+ fn record_failed_admin_auth(&self, now: OffsetDateTime) -> Result<(), DaemonError> {
391
+ let mut state = self
392
+ .admin_auth_state
393
+ .write()
394
+ .map_err(|_| DaemonError::LockPoisoned)?;
395
+
396
+ if let Some(locked_until) = state.locked_until {
397
+ if locked_until > now {
398
+ return Ok(());
399
+ }
400
+ state.locked_until = None;
401
+ state.failed_attempts = 0;
402
+ }
403
+
404
+ state.failed_attempts = state.failed_attempts.saturating_add(1);
405
+ if state.failed_attempts >= self.config.max_failed_admin_auth_attempts {
406
+ state.failed_attempts = 0;
407
+ state.locked_until = Some(now.checked_add(self.config.admin_auth_lockout).ok_or_else(
408
+ || {
409
+ DaemonError::InvalidConfig(
410
+ "admin_auth_lockout causes timestamp overflow".to_string(),
411
+ )
412
+ },
413
+ )?);
414
+ }
415
+
416
+ Ok(())
417
+ }
418
+
419
+ fn validate_request_timestamps(
420
+ &self,
421
+ requested_at: OffsetDateTime,
422
+ expires_at: OffsetDateTime,
423
+ now: OffsetDateTime,
424
+ ) -> Result<(), DaemonError> {
425
+ if expires_at <= now {
426
+ return Err(DaemonError::RequestExpired);
427
+ }
428
+ if expires_at <= requested_at {
429
+ return Err(DaemonError::InvalidRequestTimestamps);
430
+ }
431
+ if requested_at > now + self.config.max_request_clock_skew {
432
+ return Err(DaemonError::InvalidRequestTimestamps);
433
+ }
434
+ let ttl = expires_at - requested_at;
435
+ if ttl > self.config.max_request_ttl {
436
+ return Err(DaemonError::InvalidRequestTimestamps);
437
+ }
438
+ Ok(())
439
+ }
440
+
441
+ fn authenticate_agent(
442
+ &self,
443
+ agent_key_id: Uuid,
444
+ agent_auth_token: &str,
445
+ ) -> Result<AgentKey, DaemonError> {
446
+ if agent_auth_token.as_bytes().len() > MAX_AUTH_SECRET_BYTES {
447
+ return Err(DaemonError::AgentAuthenticationFailed);
448
+ }
449
+
450
+ let expected_auth_hash = self
451
+ .agent_auth_tokens
452
+ .read()
453
+ .map_err(|_| DaemonError::LockPoisoned)?
454
+ .get(&agent_key_id)
455
+ .copied()
456
+ .ok_or(DaemonError::AgentAuthenticationFailed)?;
457
+ let presented_auth_hash = hash_agent_auth_token(agent_auth_token);
458
+ if !constant_time_eq(&expected_auth_hash, &presented_auth_hash) {
459
+ return Err(DaemonError::AgentAuthenticationFailed);
460
+ }
461
+
462
+ self.agent_keys
463
+ .read()
464
+ .map_err(|_| DaemonError::LockPoisoned)?
465
+ .get(&agent_key_id)
466
+ .cloned()
467
+ .ok_or(DaemonError::AgentAuthenticationFailed)
468
+ }
469
+
470
+ fn explain_authorized_request(
471
+ &self,
472
+ request: &SignRequest,
473
+ now: OffsetDateTime,
474
+ ) -> Result<(AgentKey, AgentAction, PolicyExplanation), DaemonError> {
475
+ self.validate_request_timestamps(request.requested_at, request.expires_at, now)?;
476
+ if request.payload.len() > self.config.max_sign_payload_bytes {
477
+ return Err(DaemonError::PayloadTooLarge {
478
+ max_bytes: self.config.max_sign_payload_bytes,
479
+ });
480
+ }
481
+
482
+ let agent_key = self.authenticate_agent(request.agent_key_id, &request.agent_auth_token)?;
483
+
484
+ let payload_action: AgentAction = serde_json::from_slice(&request.payload)
485
+ .map_err(|_| DaemonError::PayloadActionMismatch)?;
486
+ payload_action
487
+ .validate()
488
+ .map_err(|_| DaemonError::PayloadActionMismatch)?;
489
+ if payload_action != request.action {
490
+ return Err(DaemonError::PayloadActionMismatch);
491
+ }
492
+ let canonical_payload =
493
+ serde_json::to_vec(&request.action).map_err(|_| DaemonError::PayloadActionMismatch)?;
494
+ if request.payload != canonical_payload {
495
+ return Err(DaemonError::PayloadActionMismatch);
496
+ }
497
+
498
+ let policies: Vec<SpendingPolicy> = self
499
+ .policies
500
+ .read()
501
+ .map_err(|_| DaemonError::LockPoisoned)?
502
+ .values()
503
+ .cloned()
504
+ .collect();
505
+
506
+ let retention_start = now - Duration::days(8);
507
+ let spend_history: Vec<SpendEvent> = self
508
+ .spend_log
509
+ .read()
510
+ .map_err(|_| DaemonError::LockPoisoned)?
511
+ .iter()
512
+ .filter(|event| event.at >= retention_start)
513
+ .cloned()
514
+ .collect();
515
+
516
+ let policy_explanation = self.policy_engine.explain(
517
+ &policies,
518
+ &agent_key.policies,
519
+ &payload_action,
520
+ &spend_history,
521
+ request.agent_key_id,
522
+ now,
523
+ );
524
+
525
+ Ok((agent_key, payload_action, policy_explanation))
526
+ }
527
+
528
+ fn evaluate_authorized_request(
529
+ &self,
530
+ request: &SignRequest,
531
+ now: OffsetDateTime,
532
+ ) -> Result<(AgentKey, AgentAction, PolicyEvaluation), DaemonError> {
533
+ let (agent_key, payload_action, policy_explanation) =
534
+ self.explain_authorized_request(request, now)?;
535
+ match policy_explanation.decision {
536
+ PolicyDecision::Allow => Ok((
537
+ agent_key,
538
+ payload_action,
539
+ PolicyEvaluation {
540
+ evaluated_policy_ids: policy_explanation.evaluated_policy_ids,
541
+ },
542
+ )),
543
+ PolicyDecision::Deny(err) => Err(DaemonError::Policy(err)),
544
+ }
545
+ }
546
+
547
+ fn prune_replay_ids(&self, now: OffsetDateTime) -> Result<(), DaemonError> {
548
+ self.replay_ids
549
+ .write()
550
+ .map_err(|_| DaemonError::LockPoisoned)?
551
+ .retain(|_, expires_at| *expires_at > now);
552
+ Ok(())
553
+ }
554
+
555
+ fn register_replay_id(
556
+ &self,
557
+ request_id: Uuid,
558
+ expires_at: OffsetDateTime,
559
+ now: OffsetDateTime,
560
+ ) -> Result<(), DaemonError> {
561
+ self.prune_replay_ids(now)?;
562
+ let mut replay_ids = self
563
+ .replay_ids
564
+ .write()
565
+ .map_err(|_| DaemonError::LockPoisoned)?;
566
+ if replay_ids.contains_key(&request_id) {
567
+ return Err(DaemonError::RequestReplayDetected);
568
+ }
569
+ replay_ids.insert(request_id, expires_at);
570
+ Ok(())
571
+ }
572
+
573
+ fn prune_nonce_reservations(&self, now: OffsetDateTime) -> Result<(), DaemonError> {
574
+ let removed = {
575
+ let mut reservations = self
576
+ .nonce_reservations
577
+ .write()
578
+ .map_err(|_| DaemonError::LockPoisoned)?;
579
+ let removed = reservations
580
+ .values()
581
+ .filter(|reservation| reservation.expires_at <= now)
582
+ .cloned()
583
+ .collect::<Vec<_>>();
584
+ reservations.retain(|_, reservation| reservation.expires_at > now);
585
+ removed
586
+ };
587
+ self.reclaim_unused_nonce_heads(&removed)?;
588
+ Ok(())
589
+ }
590
+
591
+ fn reclaim_unused_nonce_heads(
592
+ &self,
593
+ removed: &[NonceReservation],
594
+ ) -> Result<(), DaemonError> {
595
+ if removed.is_empty() {
596
+ return Ok(());
597
+ }
598
+
599
+ let mut removed_by_scope = HashMap::<(Uuid, u64), std::collections::BTreeSet<u64>>::new();
600
+ for reservation in removed {
601
+ removed_by_scope
602
+ .entry((reservation.vault_key_id, reservation.chain_id))
603
+ .or_default()
604
+ .insert(reservation.nonce);
605
+ }
606
+
607
+ let mut nonce_heads = self
608
+ .nonce_heads
609
+ .write()
610
+ .map_err(|_| DaemonError::LockPoisoned)?;
611
+ for ((vault_key_id, chain_id), removed_nonces) in removed_by_scope {
612
+ let Some(chain_heads) = nonce_heads.get_mut(&vault_key_id) else {
613
+ continue;
614
+ };
615
+ let Some(head) = chain_heads.get_mut(&chain_id) else {
616
+ continue;
617
+ };
618
+
619
+ while *head > 0 {
620
+ let candidate = *head - 1;
621
+ if removed_nonces.contains(&candidate) {
622
+ *head = candidate;
623
+ } else {
624
+ break;
625
+ }
626
+ }
627
+ }
628
+ Ok(())
629
+ }
630
+
631
+ fn consume_nonce_reservation(
632
+ &self,
633
+ agent_key_id: Uuid,
634
+ vault_key_id: Uuid,
635
+ chain_id: u64,
636
+ nonce: u64,
637
+ now: OffsetDateTime,
638
+ ) -> Result<(), DaemonError> {
639
+ self.prune_nonce_reservations(now)?;
640
+ let mut reservations = self
641
+ .nonce_reservations
642
+ .write()
643
+ .map_err(|_| DaemonError::LockPoisoned)?;
644
+ let reservation_id = reservations
645
+ .iter()
646
+ .find_map(|(reservation_id, reservation)| {
647
+ if reservation.agent_key_id == agent_key_id
648
+ && reservation.vault_key_id == vault_key_id
649
+ && reservation.chain_id == chain_id
650
+ && reservation.nonce == nonce
651
+ {
652
+ Some(*reservation_id)
653
+ } else {
654
+ None
655
+ }
656
+ })
657
+ .ok_or(DaemonError::MissingNonceReservation { chain_id, nonce })?;
658
+ reservations.remove(&reservation_id);
659
+ Ok(())
660
+ }
661
+
662
+ fn resolve_manual_approval_request(
663
+ &self,
664
+ agent_key: &AgentKey,
665
+ payload_action: &AgentAction,
666
+ payload_hash: &str,
667
+ triggered_by_policy_ids: Vec<Uuid>,
668
+ now: OffsetDateTime,
669
+ ) -> Result<ManualApprovalResolution, DaemonError> {
670
+ let relay_config = self
671
+ .relay_config
672
+ .read()
673
+ .map_err(|_| DaemonError::LockPoisoned)?
674
+ .clone();
675
+ let mut requests = self
676
+ .manual_approval_requests
677
+ .write()
678
+ .map_err(|_| DaemonError::LockPoisoned)?;
679
+
680
+ let existing = requests
681
+ .values()
682
+ .filter(|existing| {
683
+ existing.agent_key_id == agent_key.id
684
+ && existing.request_payload_hash_hex == payload_hash
685
+ && existing.status != ManualApprovalStatus::Completed
686
+ })
687
+ .max_by(|left, right| left.created_at.cmp(&right.created_at))
688
+ .cloned();
689
+
690
+ if let Some(existing) = existing {
691
+ return Ok(match existing.status {
692
+ ManualApprovalStatus::Approved => {
693
+ ManualApprovalResolution::Approved(Some(existing.id))
694
+ }
695
+ ManualApprovalStatus::Pending => ManualApprovalResolution::Pending {
696
+ approval_request_id: existing.id,
697
+ relay_config,
698
+ },
699
+ ManualApprovalStatus::Rejected => ManualApprovalResolution::Rejected(existing.id),
700
+ ManualApprovalStatus::Completed => ManualApprovalResolution::Approved(None),
701
+ });
702
+ }
703
+
704
+ let approval_request_id = Uuid::new_v4();
705
+ requests.insert(
706
+ approval_request_id,
707
+ ManualApprovalRequest {
708
+ id: approval_request_id,
709
+ agent_key_id: agent_key.id,
710
+ vault_key_id: agent_key.vault_key_id,
711
+ request_payload_hash_hex: payload_hash.to_string(),
712
+ action: payload_action.clone(),
713
+ chain_id: payload_action.chain_id(),
714
+ asset: payload_action.asset(),
715
+ recipient: payload_action.recipient(),
716
+ amount_wei: payload_action.amount_wei(),
717
+ created_at: now,
718
+ updated_at: now,
719
+ status: ManualApprovalStatus::Pending,
720
+ triggered_by_policy_ids,
721
+ completed_at: None,
722
+ rejection_reason: None,
723
+ },
724
+ );
725
+
726
+ Ok(ManualApprovalResolution::Pending {
727
+ approval_request_id,
728
+ relay_config,
729
+ })
730
+ }
731
+
732
+ fn complete_manual_approval_request(
733
+ &self,
734
+ approval_request_id: Uuid,
735
+ now: OffsetDateTime,
736
+ ) -> Result<(), DaemonError> {
737
+ let mut requests = self
738
+ .manual_approval_requests
739
+ .write()
740
+ .map_err(|_| DaemonError::LockPoisoned)?;
741
+ let request = requests.get_mut(&approval_request_id).ok_or(
742
+ DaemonError::UnknownManualApprovalRequest(approval_request_id),
743
+ )?;
744
+ request.status = ManualApprovalStatus::Completed;
745
+ request.updated_at = now;
746
+ request.completed_at = Some(now);
747
+ Ok(())
748
+ }
749
+
750
+ pub fn relay_registration_snapshot(&self) -> Result<RelayRegistrationSnapshot, DaemonError> {
751
+ let relay_config = self
752
+ .relay_config
753
+ .read()
754
+ .map_err(|_| DaemonError::LockPoisoned)?
755
+ .clone();
756
+ let policies = self
757
+ .policies
758
+ .read()
759
+ .map_err(|_| DaemonError::LockPoisoned)?
760
+ .values()
761
+ .cloned()
762
+ .collect::<Vec<_>>();
763
+ let agent_keys = self
764
+ .agent_keys
765
+ .read()
766
+ .map_err(|_| DaemonError::LockPoisoned)?
767
+ .values()
768
+ .cloned()
769
+ .collect::<Vec<_>>();
770
+ let manual_approval_requests = self
771
+ .manual_approval_requests
772
+ .read()
773
+ .map_err(|_| DaemonError::LockPoisoned)?
774
+ .values()
775
+ .cloned()
776
+ .collect::<Vec<_>>();
777
+ let latest_vault_key = self
778
+ .vault_keys
779
+ .read()
780
+ .map_err(|_| DaemonError::LockPoisoned)?
781
+ .values()
782
+ .cloned()
783
+ .max_by(|left, right| left.created_at.cmp(&right.created_at));
784
+ let vault_public_key_hex = latest_vault_key
785
+ .as_ref()
786
+ .map(|key| key.public_key_hex.clone());
787
+ let ethereum_address = latest_vault_key
788
+ .as_ref()
789
+ .map(|key| ethereum_address_from_public_key_hex(&key.public_key_hex))
790
+ .transpose()?;
791
+
792
+ Ok(RelayRegistrationSnapshot {
793
+ relay_config,
794
+ relay_private_key_hex: self
795
+ .relay_private_key_hex
796
+ .read()
797
+ .map_err(|_| DaemonError::LockPoisoned)?
798
+ .clone(),
799
+ vault_public_key_hex,
800
+ ethereum_address,
801
+ policies,
802
+ agent_keys,
803
+ manual_approval_requests,
804
+ })
805
+ }
806
+
807
+ pub async fn apply_relay_manual_approval_decision(
808
+ &self,
809
+ vault_password: &str,
810
+ approval_request_id: Uuid,
811
+ decision: ManualApprovalDecision,
812
+ rejection_reason: Option<String>,
813
+ ) -> Result<ManualApprovalRequest, DaemonError> {
814
+ let lease = self.issue_lease(vault_password).await?;
815
+ let session = AdminSession {
816
+ vault_password: vault_password.to_string(),
817
+ lease,
818
+ };
819
+ self.decide_manual_approval_request(
820
+ &session,
821
+ approval_request_id,
822
+ decision,
823
+ rejection_reason,
824
+ )
825
+ .await
826
+ }
827
+
828
+ pub fn decrypt_relay_envelope(
829
+ &self,
830
+ algorithm: &str,
831
+ encapsulated_key_hex: &str,
832
+ nonce_hex: &str,
833
+ ciphertext_hex: &str,
834
+ ) -> Result<Vec<u8>, DaemonError> {
835
+ use chacha20poly1305::aead::Aead;
836
+ use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
837
+
838
+ if algorithm.trim() != "x25519-xchacha20poly1305-v1" {
839
+ return Err(DaemonError::InvalidRelayConfig(format!(
840
+ "unsupported relay encryption algorithm: {algorithm}"
841
+ )));
842
+ }
843
+
844
+ let private_key_hex = self
845
+ .relay_private_key_hex
846
+ .read()
847
+ .map_err(|_| DaemonError::LockPoisoned)?
848
+ .clone();
849
+ let private_key_bytes = hex::decode(private_key_hex.trim().trim_start_matches("0x"))
850
+ .map_err(|err| {
851
+ DaemonError::InvalidRelayConfig(format!("relay private key is invalid hex: {err}"))
852
+ })?;
853
+ if private_key_bytes.len() != 32 {
854
+ return Err(DaemonError::InvalidRelayConfig(
855
+ "relay private key must be 32 bytes".to_string(),
856
+ ));
857
+ }
858
+ let encapsulated_key = hex::decode(encapsulated_key_hex.trim().trim_start_matches("0x"))
859
+ .map_err(|err| {
860
+ DaemonError::InvalidRelayConfig(format!(
861
+ "relay encapsulated key is invalid hex: {err}"
862
+ ))
863
+ })?;
864
+ if encapsulated_key.len() != 32 {
865
+ return Err(DaemonError::InvalidRelayConfig(
866
+ "relay encapsulated key must be 32 bytes".to_string(),
867
+ ));
868
+ }
869
+ let nonce = hex::decode(nonce_hex.trim().trim_start_matches("0x")).map_err(|err| {
870
+ DaemonError::InvalidRelayConfig(format!("relay nonce is invalid hex: {err}"))
871
+ })?;
872
+ if nonce.len() != 24 {
873
+ return Err(DaemonError::InvalidRelayConfig(
874
+ "relay nonce must be 24 bytes".to_string(),
875
+ ));
876
+ }
877
+ let ciphertext =
878
+ hex::decode(ciphertext_hex.trim().trim_start_matches("0x")).map_err(|err| {
879
+ DaemonError::InvalidRelayConfig(format!("relay ciphertext is invalid hex: {err}"))
880
+ })?;
881
+
882
+ let mut private_key = [0u8; 32];
883
+ private_key.copy_from_slice(&private_key_bytes);
884
+ let mut peer_public = [0u8; 32];
885
+ peer_public.copy_from_slice(&encapsulated_key);
886
+ let secret = x25519_dalek::StaticSecret::from(private_key);
887
+ let peer = x25519_dalek::PublicKey::from(peer_public);
888
+ let shared_secret = secret.diffie_hellman(&peer);
889
+ let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
890
+
891
+ cipher
892
+ .decrypt(
893
+ chacha20poly1305::XNonce::from_slice(&nonce),
894
+ ciphertext.as_ref(),
895
+ )
896
+ .map_err(|_| {
897
+ DaemonError::InvalidRelayConfig(
898
+ "failed to decrypt relay update payload".to_string(),
899
+ )
900
+ })
901
+ }
902
+
903
+ fn snapshot_state(&self) -> Result<PersistedDaemonState, DaemonError> {
904
+ let leases = self
905
+ .leases
906
+ .read()
907
+ .map_err(|_| DaemonError::LockPoisoned)?
908
+ .clone();
909
+ let policies = self
910
+ .policies
911
+ .read()
912
+ .map_err(|_| DaemonError::LockPoisoned)?
913
+ .clone();
914
+ let vault_keys = self
915
+ .vault_keys
916
+ .read()
917
+ .map_err(|_| DaemonError::LockPoisoned)?
918
+ .clone();
919
+ let vault_key_ids = vault_keys.keys().copied().collect::<Vec<_>>();
920
+ let software_signer_private_keys = self
921
+ .signer_backend
922
+ .export_persistable_key_material(&vault_key_ids)
923
+ .map_err(DaemonError::Signer)?;
924
+
925
+ Ok(PersistedDaemonState {
926
+ leases,
927
+ policies,
928
+ vault_keys,
929
+ software_signer_private_keys,
930
+ agent_keys: self
931
+ .agent_keys
932
+ .read()
933
+ .map_err(|_| DaemonError::LockPoisoned)?
934
+ .clone(),
935
+ agent_auth_tokens: self
936
+ .agent_auth_tokens
937
+ .read()
938
+ .map_err(|_| DaemonError::LockPoisoned)?
939
+ .clone(),
940
+ replay_ids: self
941
+ .replay_ids
942
+ .read()
943
+ .map_err(|_| DaemonError::LockPoisoned)?
944
+ .clone(),
945
+ nonce_heads: self
946
+ .nonce_heads
947
+ .read()
948
+ .map_err(|_| DaemonError::LockPoisoned)?
949
+ .clone(),
950
+ nonce_reservations: self
951
+ .nonce_reservations
952
+ .read()
953
+ .map_err(|_| DaemonError::LockPoisoned)?
954
+ .clone(),
955
+ spend_log: self
956
+ .spend_log
957
+ .read()
958
+ .map_err(|_| DaemonError::LockPoisoned)?
959
+ .clone(),
960
+ manual_approval_requests: self
961
+ .manual_approval_requests
962
+ .read()
963
+ .map_err(|_| DaemonError::LockPoisoned)?
964
+ .clone(),
965
+ relay_config: self
966
+ .relay_config
967
+ .read()
968
+ .map_err(|_| DaemonError::LockPoisoned)?
969
+ .clone(),
970
+ relay_private_key_hex: self
971
+ .relay_private_key_hex
972
+ .read()
973
+ .map_err(|_| DaemonError::LockPoisoned)?
974
+ .clone(),
975
+ })
976
+ }
977
+
978
+ fn restore_state(&self, snapshot: PersistedDaemonState) -> Result<(), DaemonError> {
979
+ let PersistedDaemonState {
980
+ leases,
981
+ policies,
982
+ vault_keys,
983
+ software_signer_private_keys,
984
+ agent_keys,
985
+ agent_auth_tokens,
986
+ replay_ids,
987
+ nonce_heads,
988
+ nonce_reservations,
989
+ spend_log,
990
+ manual_approval_requests,
991
+ relay_config,
992
+ relay_private_key_hex,
993
+ } = snapshot;
994
+ self.signer_backend
995
+ .restore_persistable_key_material(&software_signer_private_keys)
996
+ .map_err(DaemonError::Signer)?;
997
+ *self.leases.write().map_err(|_| DaemonError::LockPoisoned)? = leases;
998
+ *self
999
+ .policies
1000
+ .write()
1001
+ .map_err(|_| DaemonError::LockPoisoned)? = policies;
1002
+ *self
1003
+ .vault_keys
1004
+ .write()
1005
+ .map_err(|_| DaemonError::LockPoisoned)? = vault_keys;
1006
+ *self
1007
+ .agent_keys
1008
+ .write()
1009
+ .map_err(|_| DaemonError::LockPoisoned)? = agent_keys;
1010
+ *self
1011
+ .agent_auth_tokens
1012
+ .write()
1013
+ .map_err(|_| DaemonError::LockPoisoned)? = agent_auth_tokens;
1014
+ *self
1015
+ .replay_ids
1016
+ .write()
1017
+ .map_err(|_| DaemonError::LockPoisoned)? = replay_ids;
1018
+ *self
1019
+ .nonce_heads
1020
+ .write()
1021
+ .map_err(|_| DaemonError::LockPoisoned)? = nonce_heads;
1022
+ *self
1023
+ .nonce_reservations
1024
+ .write()
1025
+ .map_err(|_| DaemonError::LockPoisoned)? = nonce_reservations;
1026
+ *self
1027
+ .spend_log
1028
+ .write()
1029
+ .map_err(|_| DaemonError::LockPoisoned)? = spend_log;
1030
+ *self
1031
+ .manual_approval_requests
1032
+ .write()
1033
+ .map_err(|_| DaemonError::LockPoisoned)? = manual_approval_requests;
1034
+ *self
1035
+ .relay_config
1036
+ .write()
1037
+ .map_err(|_| DaemonError::LockPoisoned)? = relay_config;
1038
+ *self
1039
+ .relay_private_key_hex
1040
+ .write()
1041
+ .map_err(|_| DaemonError::LockPoisoned)? = relay_private_key_hex;
1042
+ Ok(())
1043
+ }
1044
+
1045
+ fn backup_state_if_persistent(&self) -> Result<Option<PersistedDaemonState>, DaemonError> {
1046
+ if self.state_store.is_none() {
1047
+ return Ok(None);
1048
+ }
1049
+ Ok(Some(self.snapshot_state()?))
1050
+ }
1051
+
1052
+ fn persist_state_if_enabled(&self) -> Result<(), DaemonError> {
1053
+ let Some(store) = &self.state_store else {
1054
+ return Ok(());
1055
+ };
1056
+ let snapshot = self.snapshot_state()?;
1057
+ store.save(&snapshot).map_err(DaemonError::Persistence)
1058
+ }
1059
+
1060
+ fn persist_or_revert(&self, backup: Option<PersistedDaemonState>) -> Result<(), DaemonError> {
1061
+ match self.persist_state_if_enabled() {
1062
+ Ok(()) => Ok(()),
1063
+ Err(err) => {
1064
+ if let Some(snapshot) = backup {
1065
+ self.restore_state(snapshot)?;
1066
+ }
1067
+ Err(err)
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ async fn sign_typed_data_action(
1073
+ &self,
1074
+ vault_key: &VaultKey,
1075
+ action: &AgentAction,
1076
+ ) -> Result<Signature, DaemonError> {
1077
+ let digest = action
1078
+ .signing_hash()
1079
+ .map_err(map_domain_to_signer_error)?
1080
+ .ok_or_else(|| {
1081
+ DaemonError::Signer(SignerError::Unsupported(
1082
+ "action does not produce an eip-712 signing digest".to_string(),
1083
+ ))
1084
+ })?;
1085
+ self.sign_digest_with_recovery(vault_key, digest, "typed-data signing")
1086
+ .await
1087
+ }
1088
+
1089
+ async fn sign_digest_with_recovery(
1090
+ &self,
1091
+ vault_key: &VaultKey,
1092
+ digest_bytes: [u8; 32],
1093
+ operation: &str,
1094
+ ) -> Result<Signature, DaemonError> {
1095
+ let der_signature = self
1096
+ .signer_backend
1097
+ .sign_digest(vault_key.id, digest_bytes)
1098
+ .await?;
1099
+
1100
+ let parsed = K256Signature::from_der(&der_signature.bytes).map_err(|err| {
1101
+ DaemonError::Signer(SignerError::Internal(format!(
1102
+ "backend returned invalid DER signature for {operation}: {err}"
1103
+ )))
1104
+ })?;
1105
+ let parsed = parsed.normalize_s().unwrap_or(parsed);
1106
+ let verifying_key = parse_verifying_key(&vault_key.public_key_hex)?;
1107
+ let recovery_id =
1108
+ RecoveryId::trial_recovery_from_prehash(&verifying_key, &digest_bytes, &parsed)
1109
+ .map_err(|err| {
1110
+ DaemonError::Signer(SignerError::Internal(format!(
1111
+ "unable to derive signature recovery id for {operation}: {err}"
1112
+ )))
1113
+ })?;
1114
+
1115
+ let mut compact = [0u8; 64];
1116
+ compact.copy_from_slice(&parsed.to_bytes());
1117
+ let mut r = [0u8; 32];
1118
+ let mut s = [0u8; 32];
1119
+ r.copy_from_slice(&compact[..32]);
1120
+ s.copy_from_slice(&compact[32..]);
1121
+ let v = u8::from(recovery_id);
1122
+
1123
+ Ok(Signature {
1124
+ bytes: parsed.to_der().as_bytes().to_vec(),
1125
+ r_hex: Some(format!("0x{}", hex::encode(r))),
1126
+ s_hex: Some(format!("0x{}", hex::encode(s))),
1127
+ v: Some(u64::from(v)),
1128
+ raw_tx_hex: None,
1129
+ tx_hash_hex: None,
1130
+ })
1131
+ }
1132
+
1133
+ async fn sign_broadcast_eip1559(
1134
+ &self,
1135
+ vault_key: &VaultKey,
1136
+ tx: &vault_domain::BroadcastTx,
1137
+ ) -> Result<Signature, DaemonError> {
1138
+ let signing_message = tx
1139
+ .eip1559_signing_message()
1140
+ .map_err(map_domain_to_signer_error)?;
1141
+ let digest_bytes = alloy_primitives::keccak256(&signing_message).0;
1142
+
1143
+ let mut signature = self
1144
+ .sign_digest_with_recovery(vault_key, digest_bytes, "tx signing")
1145
+ .await?;
1146
+
1147
+ let r_hex = signature.r_hex.clone().ok_or_else(|| {
1148
+ DaemonError::Signer(SignerError::Internal("missing tx r value".to_string()))
1149
+ })?;
1150
+ let s_hex = signature.s_hex.clone().ok_or_else(|| {
1151
+ DaemonError::Signer(SignerError::Internal("missing tx s value".to_string()))
1152
+ })?;
1153
+ let v = signature.v.ok_or_else(|| {
1154
+ DaemonError::Signer(SignerError::Internal("missing tx recovery id".to_string()))
1155
+ })? as u8;
1156
+
1157
+ let r_bytes = hex::decode(r_hex.trim_start_matches("0x")).map_err(|err| {
1158
+ DaemonError::Signer(SignerError::Internal(format!(
1159
+ "failed to decode tx r value: {err}"
1160
+ )))
1161
+ })?;
1162
+ let s_bytes = hex::decode(s_hex.trim_start_matches("0x")).map_err(|err| {
1163
+ DaemonError::Signer(SignerError::Internal(format!(
1164
+ "failed to decode tx s value: {err}"
1165
+ )))
1166
+ })?;
1167
+ let mut r = [0u8; 32];
1168
+ let mut s = [0u8; 32];
1169
+ r.copy_from_slice(&r_bytes);
1170
+ s.copy_from_slice(&s_bytes);
1171
+
1172
+ let raw_tx = tx
1173
+ .eip1559_signed_raw_transaction(v, r, s)
1174
+ .map_err(map_domain_to_signer_error)?;
1175
+ let tx_hash = alloy_primitives::keccak256(&raw_tx);
1176
+ signature.raw_tx_hex = Some(format!("0x{}", hex::encode(raw_tx)));
1177
+ signature.tx_hash_hex = Some(format!("0x{}", hex::encode(tx_hash)));
1178
+ Ok(signature)
1179
+ }
1180
+ }
1181
+
1182
+ const DEFAULT_RELAY_URL: &str = "http://localhost:8787";
1183
+
1184
+ fn ensure_relay_identity(state: &mut PersistedDaemonState) {
1185
+ if state.relay_private_key_hex.trim().is_empty() {
1186
+ let private_bytes = rand::random::<[u8; 32]>();
1187
+ state.relay_private_key_hex = hex::encode(private_bytes);
1188
+ }
1189
+ if state
1190
+ .relay_config
1191
+ .relay_url
1192
+ .as_deref()
1193
+ .map_or(true, |value| value.trim().is_empty())
1194
+ {
1195
+ state.relay_config.relay_url = Some(DEFAULT_RELAY_URL.to_string());
1196
+ }
1197
+ if state.relay_config.daemon_id_hex.trim().is_empty() {
1198
+ state.relay_config.daemon_id_hex = hex::encode(rand::random::<[u8; 32]>());
1199
+ }
1200
+ if state.relay_config.daemon_public_key_hex.trim().is_empty() {
1201
+ let private_hex = state.relay_private_key_hex.trim().trim_start_matches("0x");
1202
+ if let Ok(private_bytes) = hex::decode(private_hex) {
1203
+ if private_bytes.len() == 32 {
1204
+ let mut private_key = [0u8; 32];
1205
+ private_key.copy_from_slice(&private_bytes);
1206
+ let secret = x25519_dalek::StaticSecret::from(private_key);
1207
+ let public = x25519_dalek::PublicKey::from(&secret);
1208
+ state.relay_config.daemon_public_key_hex = hex::encode(public.as_bytes());
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ fn ethereum_address_from_public_key_hex(public_key_hex: &str) -> Result<String, DaemonError> {
1215
+ let verifying_key = parse_verifying_key(public_key_hex)?;
1216
+ let encoded = verifying_key.to_encoded_point(false);
1217
+ let public_key = encoded.as_bytes();
1218
+ if public_key.len() != 65 || public_key[0] != 0x04 {
1219
+ return Err(DaemonError::Signer(SignerError::Internal(
1220
+ "vault public key must be an uncompressed secp256k1 SEC1 point".to_string(),
1221
+ )));
1222
+ }
1223
+ let digest = alloy_primitives::keccak256(&public_key[1..]);
1224
+ let address = &digest.0[12..];
1225
+ Ok(format!("0x{}", hex::encode(address)))
1226
+ }
1227
+
1228
+ fn manual_approval_frontend_url(
1229
+ relay_config: &RelayConfig,
1230
+ approval_request_id: Uuid,
1231
+ approval_capability: &str,
1232
+ ) -> Option<String> {
1233
+ relay_config
1234
+ .frontend_url
1235
+ .as_ref()
1236
+ .or(relay_config.relay_url.as_ref())
1237
+ .map(|frontend_url| {
1238
+ let daemon_id = relay_config.daemon_id_hex.trim();
1239
+ if daemon_id.is_empty() {
1240
+ format!(
1241
+ "{}/approvals/{approval_request_id}?approvalCapability={approval_capability}",
1242
+ frontend_url.trim_end_matches('/')
1243
+ )
1244
+ } else {
1245
+ format!(
1246
+ "{}/approvals/{approval_request_id}?daemonId={daemon_id}&approvalCapability={approval_capability}",
1247
+ frontend_url.trim_end_matches('/')
1248
+ )
1249
+ }
1250
+ })
1251
+ }
1252
+
1253
+ fn payload_hash_hex(payload: &[u8]) -> String {
1254
+ let digest = Sha256::digest(payload);
1255
+ hex::encode(digest)
1256
+ }