@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,1640 @@
1
+ //! Unix-domain socket transport for daemon RPC calls.
2
+ //!
3
+ //! This transport is intended for local long-running daemon deployments where
4
+ //! CLIs/SDK clients communicate with a separate process over a filesystem
5
+ //! socket path.
6
+
7
+ #![forbid(unsafe_code)]
8
+
9
+ use std::collections::BTreeSet;
10
+ use std::path::{Path, PathBuf};
11
+ use std::sync::Arc;
12
+ use std::time::Duration;
13
+
14
+ use async_trait::async_trait;
15
+ use serde::{Deserialize, Serialize};
16
+ use thiserror::Error;
17
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
18
+ use tokio::net::{UnixListener, UnixStream};
19
+ use tokio::task::JoinHandle;
20
+ use uuid::Uuid;
21
+ use vault_daemon::{
22
+ DaemonError, DaemonRpcRequest, DaemonRpcResponse, InMemoryDaemon, KeyManagerDaemonApi,
23
+ };
24
+ use vault_domain::{
25
+ AdminSession, AgentCredentials, Lease, ManualApprovalDecision, ManualApprovalRequest,
26
+ NonceReleaseRequest, NonceReservation, NonceReservationRequest, PolicyAttachment, RelayConfig,
27
+ SignRequest, Signature, SpendingPolicy, VaultKey,
28
+ };
29
+ use vault_policy::{PolicyEvaluation, PolicyExplanation};
30
+ use vault_signer::{KeyCreateRequest, SignerError, VaultSignerBackend};
31
+ use zeroize::Zeroize;
32
+
33
+ const MAX_WIRE_BODY_BYTES: usize = 256 * 1024;
34
+
35
+ /// Validates that a client daemon socket path is an existing trusted unix socket in a secure directory.
36
+ pub fn assert_trusted_daemon_socket_path(path: &Path) -> Result<PathBuf, String> {
37
+ if path.as_os_str().is_empty() {
38
+ return Err("daemon socket path must not be empty".to_string());
39
+ }
40
+
41
+ if is_symlink(path)? {
42
+ return Err(format!(
43
+ "socket path '{}' must not be a symlink",
44
+ path.display()
45
+ ));
46
+ }
47
+
48
+ let Some(parent) = path.parent() else {
49
+ return Err(format!(
50
+ "socket path '{}' must have a parent directory",
51
+ path.display()
52
+ ));
53
+ };
54
+ if parent.as_os_str().is_empty() {
55
+ return Err(format!(
56
+ "socket path '{}' must have a parent directory",
57
+ path.display()
58
+ ));
59
+ }
60
+ if is_symlink(parent)? {
61
+ return Err(format!(
62
+ "socket directory '{}' must not be a symlink",
63
+ parent.display()
64
+ ));
65
+ }
66
+ ensure_secure_socket_directory(parent)?;
67
+
68
+ let metadata = std::fs::symlink_metadata(path)
69
+ .map_err(|err| format!("failed to inspect socket path '{}': {err}", path.display()))?;
70
+ #[cfg(unix)]
71
+ {
72
+ use std::os::unix::fs::{FileTypeExt, MetadataExt};
73
+
74
+ if !metadata.file_type().is_socket() {
75
+ return Err(format!(
76
+ "socket path '{}' must be a unix socket",
77
+ path.display()
78
+ ));
79
+ }
80
+ let uid = metadata.uid();
81
+ let allowed = allowed_owner_uids()?;
82
+ if !allowed.contains(&uid) {
83
+ return Err(format!(
84
+ "socket path '{}' must be owned by current user, sudo caller, or root (found uid {uid})",
85
+ path.display(),
86
+ ));
87
+ }
88
+ }
89
+
90
+ Ok(path.to_path_buf())
91
+ }
92
+
93
+ /// Validates that a client daemon socket path is an existing root-owned unix socket in a secure directory.
94
+ pub fn assert_root_owned_daemon_socket_path(path: &Path) -> Result<PathBuf, String> {
95
+ let resolved = assert_trusted_daemon_socket_path(path)?;
96
+
97
+ #[cfg(unix)]
98
+ {
99
+ use std::os::unix::fs::MetadataExt;
100
+
101
+ let metadata = std::fs::symlink_metadata(&resolved).map_err(|err| {
102
+ format!(
103
+ "failed to inspect socket path '{}': {err}",
104
+ resolved.display()
105
+ )
106
+ })?;
107
+ if metadata.uid() != 0 {
108
+ return Err(format!(
109
+ "socket path '{}' must be owned by root",
110
+ resolved.display()
111
+ ));
112
+ }
113
+ }
114
+
115
+ Ok(resolved)
116
+ }
117
+
118
+ /// Errors returned by unix socket transport.
119
+ #[derive(Debug, Error)]
120
+ pub enum UnixTransportError {
121
+ /// Message serialization or deserialization failed.
122
+ #[error("serialization error: {0}")]
123
+ Serialization(String),
124
+ /// Protocol-level failure.
125
+ #[error("protocol error: {0}")]
126
+ Protocol(String),
127
+ /// Underlying daemon returned an error.
128
+ #[error("daemon error: {0}")]
129
+ Daemon(#[from] DaemonError),
130
+ /// Filesystem/socket operation failed.
131
+ #[error("io error: {0}")]
132
+ Io(String),
133
+ /// Timed out waiting for I/O.
134
+ #[error("transport timeout")]
135
+ Timeout,
136
+ /// Client process is not authorized by daemon peer-euid policy.
137
+ #[error("unauthorized peer euid (allowed {allowed:?}, got {actual})")]
138
+ UnauthorizedPeerEuid { allowed: Vec<u32>, actual: u32 },
139
+ }
140
+
141
+ #[derive(Debug, Clone, Serialize, Deserialize)]
142
+ struct WireRequest {
143
+ body_json: String,
144
+ }
145
+
146
+ #[derive(Debug, Clone, Serialize, Deserialize)]
147
+ struct WireResponse {
148
+ ok: bool,
149
+ body_json: String,
150
+ }
151
+
152
+ #[derive(Debug, Clone, Serialize, Deserialize)]
153
+ #[serde(tag = "kind", content = "data")]
154
+ enum WireDaemonError {
155
+ AuthenticationFailed,
156
+ UnknownLease,
157
+ InvalidLease,
158
+ TooManyActiveLeases,
159
+ UnknownVaultKey(Uuid),
160
+ UnknownAgentKey(Uuid),
161
+ UnknownPolicy(Uuid),
162
+ UnknownManualApprovalRequest(Uuid),
163
+ AgentAuthenticationFailed,
164
+ PayloadActionMismatch,
165
+ PayloadTooLarge {
166
+ max_bytes: usize,
167
+ },
168
+ InvalidRequestTimestamps,
169
+ RequestExpired,
170
+ RequestReplayDetected,
171
+ InvalidPolicyAttachment(String),
172
+ InvalidNonceReservation(String),
173
+ UnknownNonceReservation(Uuid),
174
+ MissingNonceReservation {
175
+ chain_id: u64,
176
+ nonce: u64,
177
+ },
178
+ InvalidPolicy(String),
179
+ InvalidRelayConfig(String),
180
+ ManualApprovalRequired {
181
+ approval_request_id: Uuid,
182
+ relay_url: Option<String>,
183
+ frontend_url: Option<String>,
184
+ },
185
+ ManualApprovalRejected {
186
+ approval_request_id: Uuid,
187
+ },
188
+ Policy(vault_policy::PolicyError),
189
+ Signer(SignerError),
190
+ PasswordHash(String),
191
+ InvalidConfig(String),
192
+ LockPoisoned,
193
+ Transport(String),
194
+ Persistence(String),
195
+ }
196
+
197
+ impl From<DaemonError> for WireDaemonError {
198
+ fn from(value: DaemonError) -> Self {
199
+ match value {
200
+ DaemonError::AuthenticationFailed => Self::AuthenticationFailed,
201
+ DaemonError::UnknownLease => Self::UnknownLease,
202
+ DaemonError::InvalidLease => Self::InvalidLease,
203
+ DaemonError::TooManyActiveLeases => Self::TooManyActiveLeases,
204
+ DaemonError::UnknownVaultKey(id) => Self::UnknownVaultKey(id),
205
+ DaemonError::UnknownAgentKey(id) => Self::UnknownAgentKey(id),
206
+ DaemonError::UnknownPolicy(id) => Self::UnknownPolicy(id),
207
+ DaemonError::UnknownManualApprovalRequest(id) => Self::UnknownManualApprovalRequest(id),
208
+ DaemonError::AgentAuthenticationFailed => Self::AgentAuthenticationFailed,
209
+ DaemonError::PayloadActionMismatch => Self::PayloadActionMismatch,
210
+ DaemonError::PayloadTooLarge { max_bytes } => Self::PayloadTooLarge { max_bytes },
211
+ DaemonError::InvalidRequestTimestamps => Self::InvalidRequestTimestamps,
212
+ DaemonError::RequestExpired => Self::RequestExpired,
213
+ DaemonError::RequestReplayDetected => Self::RequestReplayDetected,
214
+ DaemonError::InvalidPolicyAttachment(msg) => Self::InvalidPolicyAttachment(msg),
215
+ DaemonError::InvalidNonceReservation(msg) => Self::InvalidNonceReservation(msg),
216
+ DaemonError::UnknownNonceReservation(id) => Self::UnknownNonceReservation(id),
217
+ DaemonError::MissingNonceReservation { chain_id, nonce } => {
218
+ Self::MissingNonceReservation { chain_id, nonce }
219
+ }
220
+ DaemonError::InvalidPolicy(msg) => Self::InvalidPolicy(msg),
221
+ DaemonError::InvalidRelayConfig(msg) => Self::InvalidRelayConfig(msg),
222
+ DaemonError::ManualApprovalRequired {
223
+ approval_request_id,
224
+ relay_url,
225
+ frontend_url,
226
+ } => Self::ManualApprovalRequired {
227
+ approval_request_id,
228
+ relay_url,
229
+ frontend_url,
230
+ },
231
+ DaemonError::ManualApprovalRejected {
232
+ approval_request_id,
233
+ } => Self::ManualApprovalRejected {
234
+ approval_request_id,
235
+ },
236
+ DaemonError::Policy(err) => Self::Policy(err),
237
+ DaemonError::Signer(err) => Self::Signer(err),
238
+ DaemonError::PasswordHash(msg) => Self::PasswordHash(msg),
239
+ DaemonError::InvalidConfig(msg) => Self::InvalidConfig(msg),
240
+ DaemonError::LockPoisoned => Self::LockPoisoned,
241
+ DaemonError::Transport(msg) => Self::Transport(msg),
242
+ DaemonError::Persistence(msg) => Self::Persistence(msg),
243
+ }
244
+ }
245
+ }
246
+
247
+ impl WireDaemonError {
248
+ fn into_daemon_error(self) -> DaemonError {
249
+ match self {
250
+ WireDaemonError::AuthenticationFailed => DaemonError::AuthenticationFailed,
251
+ WireDaemonError::UnknownLease => DaemonError::UnknownLease,
252
+ WireDaemonError::InvalidLease => DaemonError::InvalidLease,
253
+ WireDaemonError::TooManyActiveLeases => DaemonError::TooManyActiveLeases,
254
+ WireDaemonError::UnknownVaultKey(id) => DaemonError::UnknownVaultKey(id),
255
+ WireDaemonError::UnknownAgentKey(id) => DaemonError::UnknownAgentKey(id),
256
+ WireDaemonError::UnknownPolicy(id) => DaemonError::UnknownPolicy(id),
257
+ WireDaemonError::UnknownManualApprovalRequest(id) => {
258
+ DaemonError::UnknownManualApprovalRequest(id)
259
+ }
260
+ WireDaemonError::AgentAuthenticationFailed => DaemonError::AgentAuthenticationFailed,
261
+ WireDaemonError::PayloadActionMismatch => DaemonError::PayloadActionMismatch,
262
+ WireDaemonError::PayloadTooLarge { max_bytes } => {
263
+ DaemonError::PayloadTooLarge { max_bytes }
264
+ }
265
+ WireDaemonError::InvalidRequestTimestamps => DaemonError::InvalidRequestTimestamps,
266
+ WireDaemonError::RequestExpired => DaemonError::RequestExpired,
267
+ WireDaemonError::RequestReplayDetected => DaemonError::RequestReplayDetected,
268
+ WireDaemonError::InvalidPolicyAttachment(msg) => {
269
+ DaemonError::InvalidPolicyAttachment(msg)
270
+ }
271
+ WireDaemonError::InvalidNonceReservation(msg) => {
272
+ DaemonError::InvalidNonceReservation(msg)
273
+ }
274
+ WireDaemonError::UnknownNonceReservation(id) => {
275
+ DaemonError::UnknownNonceReservation(id)
276
+ }
277
+ WireDaemonError::MissingNonceReservation { chain_id, nonce } => {
278
+ DaemonError::MissingNonceReservation { chain_id, nonce }
279
+ }
280
+ WireDaemonError::InvalidPolicy(msg) => DaemonError::InvalidPolicy(msg),
281
+ WireDaemonError::InvalidRelayConfig(msg) => DaemonError::InvalidRelayConfig(msg),
282
+ WireDaemonError::ManualApprovalRequired {
283
+ approval_request_id,
284
+ relay_url,
285
+ frontend_url,
286
+ } => DaemonError::ManualApprovalRequired {
287
+ approval_request_id,
288
+ relay_url,
289
+ frontend_url,
290
+ },
291
+ WireDaemonError::ManualApprovalRejected {
292
+ approval_request_id,
293
+ } => DaemonError::ManualApprovalRejected {
294
+ approval_request_id,
295
+ },
296
+ WireDaemonError::Policy(err) => DaemonError::Policy(err),
297
+ WireDaemonError::Signer(err) => DaemonError::Signer(err),
298
+ WireDaemonError::PasswordHash(msg) => DaemonError::PasswordHash(msg),
299
+ WireDaemonError::InvalidConfig(msg) => DaemonError::InvalidConfig(msg),
300
+ WireDaemonError::LockPoisoned => DaemonError::LockPoisoned,
301
+ WireDaemonError::Transport(msg) => DaemonError::Transport(msg),
302
+ WireDaemonError::Persistence(msg) => DaemonError::Persistence(msg),
303
+ }
304
+ }
305
+ }
306
+
307
+ /// Client adapter backed by unix-domain socket transport.
308
+ #[derive(Debug, Clone)]
309
+ pub struct UnixDaemonClient {
310
+ socket_path: PathBuf,
311
+ timeout: Duration,
312
+ expected_server_euid: u32,
313
+ }
314
+
315
+ impl UnixDaemonClient {
316
+ /// Creates a unix transport client.
317
+ #[must_use]
318
+ pub fn new(socket_path: PathBuf, timeout: Duration) -> Self {
319
+ Self::new_with_expected_server_euid(socket_path, timeout, nix::unistd::geteuid().as_raw())
320
+ }
321
+
322
+ /// Creates a unix transport client with explicit expected daemon euid.
323
+ ///
324
+ /// This allows hardened callers to pin daemon identity (for example `0` for root daemon).
325
+ #[must_use]
326
+ pub fn new_with_expected_server_euid(
327
+ socket_path: PathBuf,
328
+ timeout: Duration,
329
+ expected_server_euid: u32,
330
+ ) -> Self {
331
+ Self {
332
+ socket_path,
333
+ timeout,
334
+ expected_server_euid,
335
+ }
336
+ }
337
+
338
+ /// Performs a single daemon RPC call.
339
+ pub async fn call_rpc(
340
+ &self,
341
+ request: DaemonRpcRequest,
342
+ ) -> Result<DaemonRpcResponse, UnixTransportError> {
343
+ let mut request = request;
344
+ let mut stream = tokio::time::timeout(self.timeout, UnixStream::connect(&self.socket_path))
345
+ .await
346
+ .map_err(|_| UnixTransportError::Timeout)?
347
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
348
+ let peer_euid = peer_euid(&stream).map_err(UnixTransportError::Io)?;
349
+ if peer_euid != self.expected_server_euid {
350
+ return Err(UnixTransportError::UnauthorizedPeerEuid {
351
+ allowed: vec![self.expected_server_euid],
352
+ actual: peer_euid,
353
+ });
354
+ }
355
+
356
+ let body_json = serde_json::to_string(&request)
357
+ .map_err(|err| UnixTransportError::Serialization(err.to_string()));
358
+ request.zeroize_secrets();
359
+ let mut body_json = body_json?;
360
+ if body_json.len() > MAX_WIRE_BODY_BYTES {
361
+ body_json.zeroize();
362
+ return Err(UnixTransportError::Protocol(format!(
363
+ "wire request body exceeds max bytes ({MAX_WIRE_BODY_BYTES})"
364
+ )));
365
+ }
366
+ let mut wire_request = WireRequest { body_json };
367
+ let write_result = write_frame(&mut stream, &wire_request, self.timeout).await;
368
+ wire_request.body_json.zeroize();
369
+ write_result?;
370
+
371
+ let mut response: WireResponse = read_frame(&mut stream, self.timeout).await?;
372
+ if response.ok {
373
+ let parsed = serde_json::from_str::<DaemonRpcResponse>(&response.body_json)
374
+ .map_err(|err| UnixTransportError::Serialization(err.to_string()));
375
+ response.body_json.zeroize();
376
+ parsed
377
+ } else {
378
+ let daemon_error = match serde_json::from_str::<WireDaemonError>(&response.body_json) {
379
+ Ok(err) => Err(UnixTransportError::Daemon(err.into_daemon_error())),
380
+ Err(_) => Err(UnixTransportError::Daemon(DaemonError::Transport(
381
+ response.body_json.clone(),
382
+ ))),
383
+ };
384
+ response.body_json.zeroize();
385
+ daemon_error
386
+ }
387
+ }
388
+ }
389
+
390
+ /// Unix daemon socket server bound to a local path.
391
+ pub struct UnixDaemonServer {
392
+ listener: UnixListener,
393
+ socket_path: PathBuf,
394
+ allowed_admin_peer_euids: BTreeSet<u32>,
395
+ allowed_agent_peer_euids: BTreeSet<u32>,
396
+ }
397
+
398
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
399
+ enum RpcAccessLevel {
400
+ Admin,
401
+ Agent,
402
+ }
403
+
404
+ fn rpc_access_level(request: &DaemonRpcRequest) -> RpcAccessLevel {
405
+ match request {
406
+ DaemonRpcRequest::IssueLease { .. }
407
+ | DaemonRpcRequest::AddPolicy { .. }
408
+ | DaemonRpcRequest::ListPolicies { .. }
409
+ | DaemonRpcRequest::DisablePolicy { .. }
410
+ | DaemonRpcRequest::CreateVaultKey { .. }
411
+ | DaemonRpcRequest::CreateAgentKey { .. }
412
+ | DaemonRpcRequest::ExportVaultPrivateKey { .. }
413
+ | DaemonRpcRequest::RotateAgentAuthToken { .. }
414
+ | DaemonRpcRequest::RevokeAgentKey { .. }
415
+ | DaemonRpcRequest::ListManualApprovalRequests { .. }
416
+ | DaemonRpcRequest::DecideManualApprovalRequest { .. }
417
+ | DaemonRpcRequest::SetRelayConfig { .. }
418
+ | DaemonRpcRequest::GetRelayConfig { .. } => RpcAccessLevel::Admin,
419
+ DaemonRpcRequest::EvaluateForAgent { .. }
420
+ | DaemonRpcRequest::ExplainForAgent { .. }
421
+ | DaemonRpcRequest::ReserveNonce { .. }
422
+ | DaemonRpcRequest::ReleaseNonce { .. }
423
+ | DaemonRpcRequest::SignForAgent { .. } => RpcAccessLevel::Agent,
424
+ }
425
+ }
426
+
427
+ fn validate_allowed_peer_euids(
428
+ label: &str,
429
+ values: &BTreeSet<u32>,
430
+ ) -> Result<(), UnixTransportError> {
431
+ if values.is_empty() {
432
+ return Err(UnixTransportError::Protocol(format!(
433
+ "allowed {label} peer euid set must not be empty"
434
+ )));
435
+ }
436
+
437
+ Ok(())
438
+ }
439
+
440
+ fn socket_mode_for_allowed_peer_euids(
441
+ allowed_admin_peer_euids: &BTreeSet<u32>,
442
+ allowed_agent_peer_euids: &BTreeSet<u32>,
443
+ ) -> u32 {
444
+ let only_root_admin =
445
+ allowed_admin_peer_euids.len() == 1 && allowed_admin_peer_euids.contains(&0);
446
+ let only_root_agent =
447
+ allowed_agent_peer_euids.len() == 1 && allowed_agent_peer_euids.contains(&0);
448
+
449
+ if only_root_admin && only_root_agent {
450
+ 0o600
451
+ } else {
452
+ 0o666
453
+ }
454
+ }
455
+
456
+ fn combined_allowed_peer_euids(
457
+ allowed_admin_peer_euids: &BTreeSet<u32>,
458
+ allowed_agent_peer_euids: &BTreeSet<u32>,
459
+ ) -> BTreeSet<u32> {
460
+ allowed_admin_peer_euids
461
+ .union(allowed_agent_peer_euids)
462
+ .copied()
463
+ .collect()
464
+ }
465
+
466
+ impl UnixDaemonServer {
467
+ /// Binds server to `socket_path` and restricts clients to `allowed_peer_euids`.
468
+ pub async fn bind(
469
+ socket_path: PathBuf,
470
+ allowed_peer_euids: BTreeSet<u32>,
471
+ ) -> Result<Self, UnixTransportError> {
472
+ Self::bind_with_allowed_peer_euids(
473
+ socket_path,
474
+ allowed_peer_euids.clone(),
475
+ allowed_peer_euids,
476
+ )
477
+ .await
478
+ }
479
+
480
+ /// Binds server to `socket_path` with separate admin and agent peer-euid allowlists.
481
+ pub async fn bind_with_allowed_peer_euids(
482
+ socket_path: PathBuf,
483
+ allowed_admin_peer_euids: BTreeSet<u32>,
484
+ allowed_agent_peer_euids: BTreeSet<u32>,
485
+ ) -> Result<Self, UnixTransportError> {
486
+ validate_allowed_peer_euids("admin", &allowed_admin_peer_euids)?;
487
+ validate_allowed_peer_euids("agent", &allowed_agent_peer_euids)?;
488
+
489
+ ensure_socket_parent(&socket_path).map_err(UnixTransportError::Io)?;
490
+ remove_existing_socket_file(&socket_path).map_err(UnixTransportError::Io)?;
491
+
492
+ let listener = UnixListener::bind(&socket_path)
493
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
494
+ #[cfg(unix)]
495
+ {
496
+ use std::os::unix::fs::PermissionsExt;
497
+ let socket_mode = socket_mode_for_allowed_peer_euids(
498
+ &allowed_admin_peer_euids,
499
+ &allowed_agent_peer_euids,
500
+ );
501
+ std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(socket_mode))
502
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
503
+ }
504
+
505
+ Ok(Self {
506
+ listener,
507
+ socket_path,
508
+ allowed_admin_peer_euids,
509
+ allowed_agent_peer_euids,
510
+ })
511
+ }
512
+
513
+ /// Runs accept loop until `shutdown` resolves.
514
+ pub async fn run_until_shutdown<B, S>(
515
+ self,
516
+ daemon: Arc<InMemoryDaemon<B>>,
517
+ shutdown: S,
518
+ ) -> Result<(), UnixTransportError>
519
+ where
520
+ B: VaultSignerBackend + 'static,
521
+ S: std::future::Future<Output = ()>,
522
+ {
523
+ tokio::pin!(shutdown);
524
+ let mut workers: Vec<JoinHandle<()>> = Vec::new();
525
+ loop {
526
+ tokio::select! {
527
+ _ = &mut shutdown => {
528
+ break;
529
+ }
530
+ accept_result = self.listener.accept() => {
531
+ let (stream, _) = accept_result
532
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
533
+ let daemon = daemon.clone();
534
+ let allowed_admin_peer_euids = self.allowed_admin_peer_euids.clone();
535
+ let allowed_agent_peer_euids = self.allowed_agent_peer_euids.clone();
536
+ workers.push(tokio::spawn(async move {
537
+ let _ = handle_connection(
538
+ stream,
539
+ daemon,
540
+ allowed_admin_peer_euids,
541
+ allowed_agent_peer_euids,
542
+ )
543
+ .await;
544
+ }));
545
+ }
546
+ }
547
+ }
548
+
549
+ for worker in workers {
550
+ let _ = worker.await;
551
+ }
552
+ Ok(())
553
+ }
554
+
555
+ /// Returns bound socket path.
556
+ #[must_use]
557
+ pub fn socket_path(&self) -> &Path {
558
+ &self.socket_path
559
+ }
560
+ }
561
+
562
+ impl Drop for UnixDaemonServer {
563
+ fn drop(&mut self) {
564
+ let _ = std::fs::remove_file(&self.socket_path);
565
+ }
566
+ }
567
+
568
+ async fn handle_connection<B>(
569
+ mut stream: UnixStream,
570
+ daemon: Arc<InMemoryDaemon<B>>,
571
+ allowed_admin_peer_euids: BTreeSet<u32>,
572
+ allowed_agent_peer_euids: BTreeSet<u32>,
573
+ ) -> Result<(), UnixTransportError>
574
+ where
575
+ B: VaultSignerBackend + 'static,
576
+ {
577
+ let peer_euid = peer_euid(&stream).map_err(UnixTransportError::Io)?;
578
+ let globally_allowed_peer_euids =
579
+ combined_allowed_peer_euids(&allowed_admin_peer_euids, &allowed_agent_peer_euids);
580
+ if !globally_allowed_peer_euids.contains(&peer_euid) {
581
+ return Err(UnixTransportError::UnauthorizedPeerEuid {
582
+ allowed: globally_allowed_peer_euids.into_iter().collect(),
583
+ actual: peer_euid,
584
+ });
585
+ }
586
+
587
+ let mut request: WireRequest = read_frame(&mut stream, Duration::from_secs(10)).await?;
588
+ if request.body_json.len() > MAX_WIRE_BODY_BYTES {
589
+ request.body_json.zeroize();
590
+ return Err(UnixTransportError::Protocol(format!(
591
+ "wire request body exceeds max bytes ({MAX_WIRE_BODY_BYTES})"
592
+ )));
593
+ }
594
+
595
+ let daemon_request = serde_json::from_str(&request.body_json)
596
+ .map_err(|err| UnixTransportError::Serialization(err.to_string()));
597
+ request.body_json.zeroize();
598
+ let mut daemon_request: DaemonRpcRequest = daemon_request?;
599
+ let allowed_peer_euids = match rpc_access_level(&daemon_request) {
600
+ RpcAccessLevel::Admin => &allowed_admin_peer_euids,
601
+ RpcAccessLevel::Agent => &allowed_agent_peer_euids,
602
+ };
603
+ if !allowed_peer_euids.contains(&peer_euid) {
604
+ daemon_request.zeroize_secrets();
605
+ return Err(UnixTransportError::UnauthorizedPeerEuid {
606
+ allowed: allowed_peer_euids.iter().copied().collect(),
607
+ actual: peer_euid,
608
+ });
609
+ }
610
+
611
+ let response = match daemon.handle_rpc(daemon_request).await {
612
+ Ok(success) => {
613
+ let mut success = success;
614
+ let body_json = serde_json::to_string(&success)
615
+ .map_err(|err| UnixTransportError::Serialization(err.to_string()));
616
+ success.zeroize_secrets();
617
+ WireResponse {
618
+ ok: true,
619
+ body_json: body_json?,
620
+ }
621
+ }
622
+ Err(err) => WireResponse {
623
+ ok: false,
624
+ body_json: serde_json::to_string(&WireDaemonError::from(err))
625
+ .map_err(|ser| UnixTransportError::Serialization(ser.to_string()))?,
626
+ },
627
+ };
628
+ let mut response = response;
629
+ let write_result = write_frame(&mut stream, &response, Duration::from_secs(10)).await;
630
+ response.body_json.zeroize();
631
+ write_result?;
632
+ Ok(())
633
+ }
634
+
635
+ async fn write_frame<T: Serialize>(
636
+ stream: &mut UnixStream,
637
+ value: &T,
638
+ timeout: Duration,
639
+ ) -> Result<(), UnixTransportError> {
640
+ let mut payload = serde_json::to_vec(value)
641
+ .map_err(|err| UnixTransportError::Serialization(err.to_string()))?;
642
+ if payload.len() > MAX_WIRE_BODY_BYTES {
643
+ payload.zeroize();
644
+ return Err(UnixTransportError::Protocol(format!(
645
+ "wire body exceeds max bytes ({MAX_WIRE_BODY_BYTES})"
646
+ )));
647
+ }
648
+ let len = payload.len() as u32;
649
+ let result = async {
650
+ tokio::time::timeout(timeout, stream.write_all(&len.to_be_bytes()))
651
+ .await
652
+ .map_err(|_| UnixTransportError::Timeout)?
653
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
654
+ tokio::time::timeout(timeout, stream.write_all(&payload))
655
+ .await
656
+ .map_err(|_| UnixTransportError::Timeout)?
657
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
658
+ Ok(())
659
+ }
660
+ .await;
661
+ payload.zeroize();
662
+ result
663
+ }
664
+
665
+ async fn read_frame<T: for<'de> Deserialize<'de>>(
666
+ stream: &mut UnixStream,
667
+ timeout: Duration,
668
+ ) -> Result<T, UnixTransportError> {
669
+ let mut len_buf = [0u8; 4];
670
+ tokio::time::timeout(timeout, stream.read_exact(&mut len_buf))
671
+ .await
672
+ .map_err(|_| UnixTransportError::Timeout)?
673
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
674
+ let len = u32::from_be_bytes(len_buf) as usize;
675
+ if len > MAX_WIRE_BODY_BYTES {
676
+ return Err(UnixTransportError::Protocol(format!(
677
+ "wire frame exceeds max bytes ({MAX_WIRE_BODY_BYTES})"
678
+ )));
679
+ }
680
+
681
+ let mut payload = vec![0u8; len];
682
+ let result = async {
683
+ tokio::time::timeout(timeout, stream.read_exact(&mut payload))
684
+ .await
685
+ .map_err(|_| UnixTransportError::Timeout)?
686
+ .map_err(|err| UnixTransportError::Io(err.to_string()))?;
687
+ serde_json::from_slice::<T>(&payload)
688
+ .map_err(|err| UnixTransportError::Serialization(err.to_string()))
689
+ }
690
+ .await;
691
+ payload.zeroize();
692
+ result
693
+ }
694
+
695
+ fn ensure_socket_parent(path: &Path) -> Result<(), String> {
696
+ if is_symlink(path)? {
697
+ return Err(format!(
698
+ "socket path '{}' must not be a symlink",
699
+ path.display()
700
+ ));
701
+ }
702
+ if let Some(parent) = path.parent() {
703
+ if !parent.as_os_str().is_empty() {
704
+ std::fs::create_dir_all(parent).map_err(|err| {
705
+ format!(
706
+ "failed to create socket directory '{}': {err}",
707
+ parent.display()
708
+ )
709
+ })?;
710
+ if is_symlink(parent)? {
711
+ return Err(format!(
712
+ "socket directory '{}' must not be a symlink",
713
+ parent.display()
714
+ ));
715
+ }
716
+ ensure_secure_socket_directory(parent)?;
717
+ }
718
+ }
719
+ Ok(())
720
+ }
721
+
722
+ #[cfg(unix)]
723
+ fn ensure_secure_socket_directory(path: &Path) -> Result<(), String> {
724
+ use std::os::unix::fs::MetadataExt;
725
+
726
+ const GROUP_OTHER_WRITE_MODE_MASK: u32 = 0o022;
727
+ const STICKY_BIT_MODE: u32 = 0o1000;
728
+
729
+ fn allowed_owner_uids() -> Result<BTreeSet<u32>, String> {
730
+ let mut allowed = BTreeSet::new();
731
+ allowed.insert(nix::unistd::geteuid().as_raw());
732
+ if nix::unistd::geteuid().as_raw() == 0 {
733
+ if let Some(raw) = std::env::var_os("SUDO_UID") {
734
+ let rendered = raw.to_string_lossy();
735
+ let parsed = rendered
736
+ .parse::<u32>()
737
+ .map_err(|_| format!("invalid SUDO_UID value '{rendered}'"))?;
738
+ allowed.insert(parsed);
739
+ }
740
+ }
741
+ Ok(allowed)
742
+ }
743
+
744
+ fn validate_directory(
745
+ path: &Path,
746
+ metadata: &std::fs::Metadata,
747
+ allow_sticky_group_other_write: bool,
748
+ ) -> Result<(), String> {
749
+ if !metadata.is_dir() {
750
+ return Err(format!(
751
+ "socket directory '{}' must be a directory",
752
+ path.display()
753
+ ));
754
+ }
755
+
756
+ let uid = metadata.uid();
757
+ if uid != 0 {
758
+ let allowed = allowed_owner_uids()?;
759
+ if !allowed.contains(&uid) {
760
+ return Err(format!(
761
+ "socket directory '{}' must be owned by current user, sudo caller, or root (found uid {uid})",
762
+ path.display()
763
+ ));
764
+ }
765
+ }
766
+
767
+ let mode = metadata.mode() & 0o7777;
768
+ if mode & GROUP_OTHER_WRITE_MODE_MASK != 0
769
+ && !(allow_sticky_group_other_write && mode & STICKY_BIT_MODE != 0)
770
+ {
771
+ return Err(format!(
772
+ "socket directory '{}' must not be writable by group/other",
773
+ path.display()
774
+ ));
775
+ }
776
+
777
+ Ok(())
778
+ }
779
+
780
+ let metadata = std::fs::metadata(path).map_err(|err| {
781
+ format!(
782
+ "failed to inspect socket directory '{}': {err}",
783
+ path.display()
784
+ )
785
+ })?;
786
+ validate_directory(path, &metadata, false)?;
787
+
788
+ let canonical = std::fs::canonicalize(path).map_err(|err| {
789
+ format!(
790
+ "failed to canonicalize socket directory '{}': {err}",
791
+ path.display()
792
+ )
793
+ })?;
794
+ for ancestor in canonical.ancestors().skip(1) {
795
+ let metadata = std::fs::metadata(ancestor).map_err(|err| {
796
+ format!(
797
+ "failed to inspect ancestor socket directory '{}': {err}",
798
+ ancestor.display()
799
+ )
800
+ })?;
801
+ validate_directory(ancestor, &metadata, true)?;
802
+ }
803
+
804
+ Ok(())
805
+ }
806
+
807
+ #[cfg(unix)]
808
+ fn allowed_owner_uids() -> Result<BTreeSet<u32>, String> {
809
+ let mut allowed = BTreeSet::new();
810
+ allowed.insert(nix::unistd::geteuid().as_raw());
811
+ allowed.insert(0);
812
+ if nix::unistd::geteuid().as_raw() == 0 {
813
+ if let Some(raw) = std::env::var_os("SUDO_UID") {
814
+ let rendered = raw.to_string_lossy();
815
+ let parsed = rendered
816
+ .parse::<u32>()
817
+ .map_err(|_| format!("invalid SUDO_UID value '{rendered}'"))?;
818
+ allowed.insert(parsed);
819
+ }
820
+ }
821
+ Ok(allowed)
822
+ }
823
+
824
+ #[cfg(not(unix))]
825
+ fn ensure_secure_socket_directory(_path: &Path) -> Result<(), String> {
826
+ Ok(())
827
+ }
828
+
829
+ fn remove_existing_socket_file(path: &Path) -> Result<(), String> {
830
+ if !path.exists() {
831
+ return Ok(());
832
+ }
833
+ let metadata = std::fs::symlink_metadata(path)
834
+ .map_err(|err| format!("failed to inspect socket path '{}': {err}", path.display()))?;
835
+ #[cfg(unix)]
836
+ {
837
+ use std::os::unix::fs::FileTypeExt;
838
+ if !metadata.file_type().is_socket() {
839
+ return Err(format!(
840
+ "socket path '{}' exists and is not a unix socket",
841
+ path.display()
842
+ ));
843
+ }
844
+ }
845
+ std::fs::remove_file(path)
846
+ .map_err(|err| format!("failed to remove stale socket '{}': {err}", path.display()))
847
+ }
848
+
849
+ fn is_symlink(path: &Path) -> Result<bool, String> {
850
+ match std::fs::symlink_metadata(path) {
851
+ Ok(metadata) => Ok(metadata.file_type().is_symlink()),
852
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
853
+ Err(err) => Err(format!(
854
+ "failed to inspect metadata for '{}': {err}",
855
+ path.display()
856
+ )),
857
+ }
858
+ }
859
+
860
+ fn peer_euid(stream: &UnixStream) -> Result<u32, String> {
861
+ let creds = stream.peer_cred().map_err(|err| err.to_string())?;
862
+ Ok(creds.uid())
863
+ }
864
+
865
+ #[async_trait]
866
+ impl KeyManagerDaemonApi for UnixDaemonClient {
867
+ async fn issue_lease(&self, vault_password: &str) -> Result<Lease, DaemonError> {
868
+ match self
869
+ .call_rpc(DaemonRpcRequest::IssueLease {
870
+ vault_password: vault_password.to_string(),
871
+ })
872
+ .await
873
+ {
874
+ Ok(DaemonRpcResponse::Lease(lease)) => Ok(lease),
875
+ Ok(_) => Err(DaemonError::Transport(
876
+ "unexpected response type".to_string(),
877
+ )),
878
+ Err(UnixTransportError::Daemon(err)) => Err(err),
879
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
880
+ }
881
+ }
882
+
883
+ async fn add_policy(
884
+ &self,
885
+ session: &AdminSession,
886
+ policy: SpendingPolicy,
887
+ ) -> Result<(), DaemonError> {
888
+ match self
889
+ .call_rpc(DaemonRpcRequest::AddPolicy {
890
+ session: session.clone(),
891
+ policy,
892
+ })
893
+ .await
894
+ {
895
+ Ok(DaemonRpcResponse::Unit) => Ok(()),
896
+ Ok(_) => Err(DaemonError::Transport(
897
+ "unexpected response type".to_string(),
898
+ )),
899
+ Err(UnixTransportError::Daemon(err)) => Err(err),
900
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
901
+ }
902
+ }
903
+
904
+ async fn list_policies(
905
+ &self,
906
+ session: &AdminSession,
907
+ ) -> Result<Vec<SpendingPolicy>, DaemonError> {
908
+ match self
909
+ .call_rpc(DaemonRpcRequest::ListPolicies {
910
+ session: session.clone(),
911
+ })
912
+ .await
913
+ {
914
+ Ok(DaemonRpcResponse::Policies(policies)) => Ok(policies),
915
+ Ok(_) => Err(DaemonError::Transport(
916
+ "unexpected response type".to_string(),
917
+ )),
918
+ Err(UnixTransportError::Daemon(err)) => Err(err),
919
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
920
+ }
921
+ }
922
+
923
+ async fn disable_policy(
924
+ &self,
925
+ session: &AdminSession,
926
+ policy_id: Uuid,
927
+ ) -> Result<(), DaemonError> {
928
+ match self
929
+ .call_rpc(DaemonRpcRequest::DisablePolicy {
930
+ session: session.clone(),
931
+ policy_id,
932
+ })
933
+ .await
934
+ {
935
+ Ok(DaemonRpcResponse::Unit) => Ok(()),
936
+ Ok(_) => Err(DaemonError::Transport(
937
+ "unexpected response type".to_string(),
938
+ )),
939
+ Err(UnixTransportError::Daemon(err)) => Err(err),
940
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
941
+ }
942
+ }
943
+
944
+ async fn create_vault_key(
945
+ &self,
946
+ session: &AdminSession,
947
+ request: KeyCreateRequest,
948
+ ) -> Result<VaultKey, DaemonError> {
949
+ match self
950
+ .call_rpc(DaemonRpcRequest::CreateVaultKey {
951
+ session: session.clone(),
952
+ request,
953
+ })
954
+ .await
955
+ {
956
+ Ok(DaemonRpcResponse::VaultKey(key)) => Ok(key),
957
+ Ok(_) => Err(DaemonError::Transport(
958
+ "unexpected response type".to_string(),
959
+ )),
960
+ Err(UnixTransportError::Daemon(err)) => Err(err),
961
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
962
+ }
963
+ }
964
+
965
+ async fn create_agent_key(
966
+ &self,
967
+ session: &AdminSession,
968
+ vault_key_id: Uuid,
969
+ attachment: PolicyAttachment,
970
+ ) -> Result<AgentCredentials, DaemonError> {
971
+ match self
972
+ .call_rpc(DaemonRpcRequest::CreateAgentKey {
973
+ session: session.clone(),
974
+ vault_key_id,
975
+ attachment,
976
+ })
977
+ .await
978
+ {
979
+ Ok(DaemonRpcResponse::AgentCredentials(agent_credentials)) => Ok(agent_credentials),
980
+ Ok(_) => Err(DaemonError::Transport(
981
+ "unexpected response type".to_string(),
982
+ )),
983
+ Err(UnixTransportError::Daemon(err)) => Err(err),
984
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
985
+ }
986
+ }
987
+
988
+ async fn export_vault_private_key(
989
+ &self,
990
+ session: &AdminSession,
991
+ vault_key_id: Uuid,
992
+ ) -> Result<Option<String>, DaemonError> {
993
+ match self
994
+ .call_rpc(DaemonRpcRequest::ExportVaultPrivateKey {
995
+ session: session.clone(),
996
+ vault_key_id,
997
+ })
998
+ .await
999
+ {
1000
+ Ok(DaemonRpcResponse::PrivateKey(private_key)) => Ok(private_key),
1001
+ Ok(_) => Err(DaemonError::Transport(
1002
+ "unexpected response type".to_string(),
1003
+ )),
1004
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1005
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1006
+ }
1007
+ }
1008
+
1009
+ async fn rotate_agent_auth_token(
1010
+ &self,
1011
+ session: &AdminSession,
1012
+ agent_key_id: Uuid,
1013
+ ) -> Result<String, DaemonError> {
1014
+ match self
1015
+ .call_rpc(DaemonRpcRequest::RotateAgentAuthToken {
1016
+ session: session.clone(),
1017
+ agent_key_id,
1018
+ })
1019
+ .await
1020
+ {
1021
+ Ok(DaemonRpcResponse::AuthToken(token)) => Ok(token),
1022
+ Ok(_) => Err(DaemonError::Transport(
1023
+ "unexpected response type".to_string(),
1024
+ )),
1025
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1026
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1027
+ }
1028
+ }
1029
+
1030
+ async fn revoke_agent_key(
1031
+ &self,
1032
+ session: &AdminSession,
1033
+ agent_key_id: Uuid,
1034
+ ) -> Result<(), DaemonError> {
1035
+ match self
1036
+ .call_rpc(DaemonRpcRequest::RevokeAgentKey {
1037
+ session: session.clone(),
1038
+ agent_key_id,
1039
+ })
1040
+ .await
1041
+ {
1042
+ Ok(DaemonRpcResponse::Unit) => Ok(()),
1043
+ Ok(_) => Err(DaemonError::Transport(
1044
+ "unexpected response type".to_string(),
1045
+ )),
1046
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1047
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1048
+ }
1049
+ }
1050
+
1051
+ async fn list_manual_approval_requests(
1052
+ &self,
1053
+ session: &AdminSession,
1054
+ ) -> Result<Vec<ManualApprovalRequest>, DaemonError> {
1055
+ match self
1056
+ .call_rpc(DaemonRpcRequest::ListManualApprovalRequests {
1057
+ session: session.clone(),
1058
+ })
1059
+ .await
1060
+ {
1061
+ Ok(DaemonRpcResponse::ManualApprovalRequests(requests)) => Ok(requests),
1062
+ Ok(_) => Err(DaemonError::Transport(
1063
+ "unexpected response type".to_string(),
1064
+ )),
1065
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1066
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1067
+ }
1068
+ }
1069
+
1070
+ async fn decide_manual_approval_request(
1071
+ &self,
1072
+ session: &AdminSession,
1073
+ approval_request_id: Uuid,
1074
+ decision: ManualApprovalDecision,
1075
+ rejection_reason: Option<String>,
1076
+ ) -> Result<ManualApprovalRequest, DaemonError> {
1077
+ match self
1078
+ .call_rpc(DaemonRpcRequest::DecideManualApprovalRequest {
1079
+ session: session.clone(),
1080
+ approval_request_id,
1081
+ decision,
1082
+ rejection_reason,
1083
+ })
1084
+ .await
1085
+ {
1086
+ Ok(DaemonRpcResponse::ManualApprovalRequest(request)) => Ok(request),
1087
+ Ok(_) => Err(DaemonError::Transport(
1088
+ "unexpected response type".to_string(),
1089
+ )),
1090
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1091
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1092
+ }
1093
+ }
1094
+
1095
+ async fn set_relay_config(
1096
+ &self,
1097
+ session: &AdminSession,
1098
+ relay_url: Option<String>,
1099
+ frontend_url: Option<String>,
1100
+ ) -> Result<RelayConfig, DaemonError> {
1101
+ match self
1102
+ .call_rpc(DaemonRpcRequest::SetRelayConfig {
1103
+ session: session.clone(),
1104
+ relay_url,
1105
+ frontend_url,
1106
+ })
1107
+ .await
1108
+ {
1109
+ Ok(DaemonRpcResponse::RelayConfig(relay_config)) => Ok(relay_config),
1110
+ Ok(_) => Err(DaemonError::Transport(
1111
+ "unexpected response type".to_string(),
1112
+ )),
1113
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1114
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1115
+ }
1116
+ }
1117
+
1118
+ async fn get_relay_config(&self, session: &AdminSession) -> Result<RelayConfig, DaemonError> {
1119
+ match self
1120
+ .call_rpc(DaemonRpcRequest::GetRelayConfig {
1121
+ session: session.clone(),
1122
+ })
1123
+ .await
1124
+ {
1125
+ Ok(DaemonRpcResponse::RelayConfig(relay_config)) => Ok(relay_config),
1126
+ Ok(_) => Err(DaemonError::Transport(
1127
+ "unexpected response type".to_string(),
1128
+ )),
1129
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1130
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1131
+ }
1132
+ }
1133
+
1134
+ async fn evaluate_for_agent(
1135
+ &self,
1136
+ request: SignRequest,
1137
+ ) -> Result<PolicyEvaluation, DaemonError> {
1138
+ match self
1139
+ .call_rpc(DaemonRpcRequest::EvaluateForAgent { request })
1140
+ .await
1141
+ {
1142
+ Ok(DaemonRpcResponse::PolicyEvaluation(evaluation)) => Ok(evaluation),
1143
+ Ok(_) => Err(DaemonError::Transport(
1144
+ "unexpected response type".to_string(),
1145
+ )),
1146
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1147
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1148
+ }
1149
+ }
1150
+
1151
+ async fn explain_for_agent(
1152
+ &self,
1153
+ request: SignRequest,
1154
+ ) -> Result<PolicyExplanation, DaemonError> {
1155
+ match self
1156
+ .call_rpc(DaemonRpcRequest::ExplainForAgent { request })
1157
+ .await
1158
+ {
1159
+ Ok(DaemonRpcResponse::PolicyExplanation(explanation)) => Ok(explanation),
1160
+ Ok(_) => Err(DaemonError::Transport(
1161
+ "unexpected response type".to_string(),
1162
+ )),
1163
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1164
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1165
+ }
1166
+ }
1167
+
1168
+ async fn reserve_nonce(
1169
+ &self,
1170
+ request: NonceReservationRequest,
1171
+ ) -> Result<NonceReservation, DaemonError> {
1172
+ match self
1173
+ .call_rpc(DaemonRpcRequest::ReserveNonce { request })
1174
+ .await
1175
+ {
1176
+ Ok(DaemonRpcResponse::NonceReservation(reservation)) => Ok(reservation),
1177
+ Ok(_) => Err(DaemonError::Transport(
1178
+ "unexpected response type".to_string(),
1179
+ )),
1180
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1181
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1182
+ }
1183
+ }
1184
+
1185
+ async fn release_nonce(&self, request: NonceReleaseRequest) -> Result<(), DaemonError> {
1186
+ match self
1187
+ .call_rpc(DaemonRpcRequest::ReleaseNonce { request })
1188
+ .await
1189
+ {
1190
+ Ok(DaemonRpcResponse::Unit) => Ok(()),
1191
+ Ok(_) => Err(DaemonError::Transport(
1192
+ "unexpected response type".to_string(),
1193
+ )),
1194
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1195
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1196
+ }
1197
+ }
1198
+
1199
+ async fn sign_for_agent(&self, request: SignRequest) -> Result<Signature, DaemonError> {
1200
+ match self
1201
+ .call_rpc(DaemonRpcRequest::SignForAgent { request })
1202
+ .await
1203
+ {
1204
+ Ok(DaemonRpcResponse::Signature(sig)) => Ok(sig),
1205
+ Ok(_) => Err(DaemonError::Transport(
1206
+ "unexpected response type".to_string(),
1207
+ )),
1208
+ Err(UnixTransportError::Daemon(err)) => Err(err),
1209
+ Err(err) => Err(DaemonError::Transport(err.to_string())),
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ #[cfg(test)]
1215
+ mod tests {
1216
+ use super::{
1217
+ assert_root_owned_daemon_socket_path, combined_allowed_peer_euids, ensure_socket_parent,
1218
+ handle_connection, read_frame, rpc_access_level, socket_mode_for_allowed_peer_euids,
1219
+ write_frame, RpcAccessLevel, UnixDaemonClient, UnixDaemonServer, UnixTransportError,
1220
+ WireRequest, WireResponse,
1221
+ };
1222
+ use std::collections::BTreeSet;
1223
+ use std::path::Path;
1224
+ use std::sync::Arc;
1225
+ use std::time::Duration;
1226
+ use time::OffsetDateTime;
1227
+ use tokio::net::UnixStream;
1228
+ use uuid::Uuid;
1229
+ use vault_daemon::{DaemonConfig, InMemoryDaemon, KeyManagerDaemonApi};
1230
+ use vault_domain::{AgentAction, SignRequest};
1231
+ use vault_signer::SoftwareSignerBackend;
1232
+
1233
+ fn unique_socket_path(label: &str) -> std::path::PathBuf {
1234
+ std::env::temp_dir().join(format!(
1235
+ "wlfi-{}-{}-{}.sock",
1236
+ label,
1237
+ std::process::id(),
1238
+ &uuid::Uuid::new_v4().simple().to_string()[..8]
1239
+ ))
1240
+ }
1241
+
1242
+ fn singleton_allowed_set(euid: u32) -> BTreeSet<u32> {
1243
+ let mut set = BTreeSet::new();
1244
+ set.insert(euid);
1245
+ set
1246
+ }
1247
+
1248
+ fn dummy_agent_request() -> SignRequest {
1249
+ SignRequest {
1250
+ request_id: Uuid::new_v4(),
1251
+ agent_key_id: Uuid::new_v4(),
1252
+ agent_auth_token: "agent-secret".to_string(),
1253
+ payload: vec![1, 2, 3],
1254
+ action: AgentAction::TransferNative {
1255
+ chain_id: 1,
1256
+ to: "0x2222222222222222222222222222222222222222"
1257
+ .parse()
1258
+ .expect("recipient"),
1259
+ amount_wei: 1,
1260
+ },
1261
+ requested_at: OffsetDateTime::now_utc(),
1262
+ expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(1),
1263
+ }
1264
+ }
1265
+
1266
+ #[cfg(unix)]
1267
+ fn socket_mode(path: &Path) -> u32 {
1268
+ use std::os::unix::fs::PermissionsExt;
1269
+
1270
+ std::fs::metadata(path)
1271
+ .expect("socket metadata")
1272
+ .permissions()
1273
+ .mode()
1274
+ & 0o777
1275
+ }
1276
+
1277
+ #[test]
1278
+ #[cfg(unix)]
1279
+ fn ensure_socket_parent_rejects_group_writable_ancestor_directory() {
1280
+ use std::os::unix::fs::PermissionsExt;
1281
+
1282
+ let root = std::env::temp_dir().join(format!(
1283
+ "wlfi-socket-parent-ancestor-{}-{}",
1284
+ std::process::id(),
1285
+ uuid::Uuid::new_v4().simple()
1286
+ ));
1287
+ let shared = root.join("shared");
1288
+ let nested = shared.join("nested");
1289
+ std::fs::create_dir_all(&nested).expect("create nested directory");
1290
+ std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777))
1291
+ .expect("set insecure ancestor permissions");
1292
+ std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o700))
1293
+ .expect("set nested permissions");
1294
+
1295
+ let path = nested.join("daemon.sock");
1296
+ let err = ensure_socket_parent(&path).expect_err("must reject");
1297
+ assert!(err.contains("must not be writable by group/other"));
1298
+
1299
+ std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))
1300
+ .expect("restore cleanup permissions");
1301
+ std::fs::remove_dir_all(&root).expect("cleanup");
1302
+ }
1303
+
1304
+ #[test]
1305
+ #[cfg(unix)]
1306
+ fn assert_root_owned_daemon_socket_path_rejects_symlink() {
1307
+ use std::os::unix::fs::symlink;
1308
+
1309
+ let root = std::env::temp_dir().join(format!(
1310
+ "wlfi-client-socket-symlink-{}-{}",
1311
+ std::process::id(),
1312
+ uuid::Uuid::new_v4().simple()
1313
+ ));
1314
+ std::fs::create_dir_all(&root).expect("create root directory");
1315
+
1316
+ let target = root.join("daemon.sock.target");
1317
+ std::fs::write(&target, "not a socket").expect("write target");
1318
+ let linked = root.join("daemon.sock");
1319
+ symlink(&target, &linked).expect("create symlink");
1320
+
1321
+ let err = assert_root_owned_daemon_socket_path(&linked).expect_err("must reject");
1322
+ assert!(err.contains("must not be a symlink"));
1323
+
1324
+ std::fs::remove_dir_all(&root).expect("cleanup");
1325
+ }
1326
+
1327
+ #[test]
1328
+ #[cfg(unix)]
1329
+ fn assert_root_owned_daemon_socket_path_rejects_non_socket_files() {
1330
+ let root = std::env::temp_dir().join(format!(
1331
+ "wlfi-client-socket-file-{}-{}",
1332
+ std::process::id(),
1333
+ uuid::Uuid::new_v4().simple()
1334
+ ));
1335
+ std::fs::create_dir_all(&root).expect("create root directory");
1336
+
1337
+ let file = root.join("daemon.sock");
1338
+ std::fs::write(&file, "not a socket").expect("write file");
1339
+
1340
+ let err = assert_root_owned_daemon_socket_path(&file).expect_err("must reject");
1341
+ assert!(err.contains("must be a unix socket"));
1342
+
1343
+ std::fs::remove_dir_all(&root).expect("cleanup");
1344
+ }
1345
+
1346
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1347
+ async fn unix_round_trip_for_issue_lease() {
1348
+ let socket_path = unique_socket_path("lease");
1349
+ let daemon = Arc::new(
1350
+ InMemoryDaemon::new(
1351
+ "vault-password",
1352
+ SoftwareSignerBackend::default(),
1353
+ DaemonConfig::default(),
1354
+ )
1355
+ .expect("daemon"),
1356
+ );
1357
+ let server = UnixDaemonServer::bind(
1358
+ socket_path.clone(),
1359
+ singleton_allowed_set(nix::unistd::geteuid().as_raw()),
1360
+ )
1361
+ .await
1362
+ .expect("server");
1363
+ let server_task = tokio::spawn({
1364
+ let daemon = daemon.clone();
1365
+ async move {
1366
+ server
1367
+ .run_until_shutdown(daemon, async {
1368
+ std::future::pending::<()>().await;
1369
+ })
1370
+ .await
1371
+ }
1372
+ });
1373
+
1374
+ tokio::time::sleep(Duration::from_millis(50)).await;
1375
+ let client = UnixDaemonClient::new(socket_path.clone(), Duration::from_secs(2));
1376
+ let lease = client.issue_lease("vault-password").await.expect("lease");
1377
+ assert_eq!(lease.lease_id.get_version_num(), 4);
1378
+
1379
+ server_task.abort();
1380
+ let _ = server_task.await;
1381
+ }
1382
+
1383
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1384
+ async fn client_rejects_unexpected_daemon_euid() {
1385
+ let socket_path = unique_socket_path("peer-euid");
1386
+ let daemon = Arc::new(
1387
+ InMemoryDaemon::new(
1388
+ "vault-password",
1389
+ SoftwareSignerBackend::default(),
1390
+ DaemonConfig::default(),
1391
+ )
1392
+ .expect("daemon"),
1393
+ );
1394
+ let allowed_euid = nix::unistd::geteuid().as_raw();
1395
+ let server =
1396
+ UnixDaemonServer::bind(socket_path.clone(), singleton_allowed_set(allowed_euid))
1397
+ .await
1398
+ .expect("server");
1399
+ let server_task = tokio::spawn({
1400
+ let daemon = daemon.clone();
1401
+ async move {
1402
+ server
1403
+ .run_until_shutdown(daemon, async {
1404
+ std::future::pending::<()>().await;
1405
+ })
1406
+ .await
1407
+ }
1408
+ });
1409
+
1410
+ tokio::time::sleep(Duration::from_millis(50)).await;
1411
+ let mismatched_expected_euid = allowed_euid.saturating_add(1);
1412
+ let client = UnixDaemonClient::new_with_expected_server_euid(
1413
+ socket_path.clone(),
1414
+ Duration::from_secs(2),
1415
+ mismatched_expected_euid,
1416
+ );
1417
+ let err = client
1418
+ .issue_lease("vault-password")
1419
+ .await
1420
+ .expect_err("must fail");
1421
+ assert!(
1422
+ err.to_string().contains("unauthorized peer euid"),
1423
+ "unexpected error: {err}"
1424
+ );
1425
+
1426
+ server_task.abort();
1427
+ let _ = server_task.await;
1428
+ }
1429
+
1430
+ #[test]
1431
+ fn rpc_access_level_classifies_admin_and_agent_requests() {
1432
+ let admin_request = vault_daemon::DaemonRpcRequest::IssueLease {
1433
+ vault_password: "vault-password".to_string(),
1434
+ };
1435
+ let agent_request = vault_daemon::DaemonRpcRequest::EvaluateForAgent {
1436
+ request: dummy_agent_request(),
1437
+ };
1438
+
1439
+ assert_eq!(rpc_access_level(&admin_request), RpcAccessLevel::Admin);
1440
+ assert_eq!(rpc_access_level(&agent_request), RpcAccessLevel::Agent);
1441
+ }
1442
+
1443
+ #[test]
1444
+ fn socket_mode_is_private_only_when_both_allowlists_are_root_only() {
1445
+ assert_eq!(
1446
+ socket_mode_for_allowed_peer_euids(
1447
+ &singleton_allowed_set(0),
1448
+ &singleton_allowed_set(0)
1449
+ ),
1450
+ 0o600
1451
+ );
1452
+
1453
+ let current_euid = nix::unistd::geteuid().as_raw();
1454
+ assert_eq!(
1455
+ socket_mode_for_allowed_peer_euids(
1456
+ &singleton_allowed_set(0),
1457
+ &singleton_allowed_set(current_euid)
1458
+ ),
1459
+ 0o666
1460
+ );
1461
+ }
1462
+
1463
+ #[test]
1464
+ fn combined_allowed_peer_euids_deduplicates_split_allowlists() {
1465
+ let current_euid = nix::unistd::geteuid().as_raw();
1466
+ let combined = combined_allowed_peer_euids(
1467
+ &BTreeSet::from([0, current_euid]),
1468
+ &BTreeSet::from([current_euid, current_euid.saturating_add(1)]),
1469
+ );
1470
+
1471
+ assert_eq!(
1472
+ combined,
1473
+ BTreeSet::from([0, current_euid, current_euid.saturating_add(1)])
1474
+ );
1475
+ }
1476
+
1477
+ #[tokio::test(flavor = "current_thread")]
1478
+ async fn handle_connection_rejects_admin_requests_from_agent_only_peers() {
1479
+ let daemon = Arc::new(
1480
+ InMemoryDaemon::new(
1481
+ "vault-password",
1482
+ SoftwareSignerBackend::default(),
1483
+ DaemonConfig::default(),
1484
+ )
1485
+ .expect("daemon"),
1486
+ );
1487
+ let current_euid = nix::unistd::geteuid().as_raw();
1488
+ let (mut client_stream, server_stream) = UnixStream::pair().expect("stream pair");
1489
+ let wire_request = WireRequest {
1490
+ body_json: serde_json::to_string(&vault_daemon::DaemonRpcRequest::IssueLease {
1491
+ vault_password: "vault-password".to_string(),
1492
+ })
1493
+ .expect("serialize request"),
1494
+ };
1495
+ write_frame(&mut client_stream, &wire_request, Duration::from_secs(2))
1496
+ .await
1497
+ .expect("write request");
1498
+
1499
+ let err = handle_connection(
1500
+ server_stream,
1501
+ daemon,
1502
+ singleton_allowed_set(current_euid.saturating_add(1)),
1503
+ singleton_allowed_set(current_euid),
1504
+ )
1505
+ .await
1506
+ .expect_err("must reject admin request");
1507
+
1508
+ assert!(
1509
+ matches!(err, UnixTransportError::UnauthorizedPeerEuid { actual, .. } if actual == current_euid)
1510
+ );
1511
+ assert!(
1512
+ read_frame::<WireResponse>(&mut client_stream, Duration::from_millis(50))
1513
+ .await
1514
+ .is_err()
1515
+ );
1516
+ }
1517
+
1518
+ #[tokio::test(flavor = "current_thread")]
1519
+ async fn handle_connection_rejects_globally_unauthorized_peer_without_frame_read() {
1520
+ let daemon = Arc::new(
1521
+ InMemoryDaemon::new(
1522
+ "vault-password",
1523
+ SoftwareSignerBackend::default(),
1524
+ DaemonConfig::default(),
1525
+ )
1526
+ .expect("daemon"),
1527
+ );
1528
+ let current_euid = nix::unistd::geteuid().as_raw();
1529
+ let (_client_stream, server_stream) = UnixStream::pair().expect("stream pair");
1530
+
1531
+ let result = tokio::time::timeout(
1532
+ Duration::from_millis(200),
1533
+ handle_connection(
1534
+ server_stream,
1535
+ daemon,
1536
+ singleton_allowed_set(current_euid.saturating_add(1)),
1537
+ singleton_allowed_set(current_euid.saturating_add(2)),
1538
+ ),
1539
+ )
1540
+ .await
1541
+ .expect("globally unauthorized peer should be rejected before read timeout");
1542
+
1543
+ let err = result.expect_err("must reject globally unauthorized peer");
1544
+ assert!(
1545
+ matches!(err, UnixTransportError::UnauthorizedPeerEuid { actual, .. } if actual == current_euid)
1546
+ );
1547
+ }
1548
+
1549
+ #[tokio::test(flavor = "current_thread")]
1550
+ async fn handle_connection_prioritizes_unauthorized_over_deserialization_for_globally_unauthorized_peer(
1551
+ ) {
1552
+ let daemon = Arc::new(
1553
+ InMemoryDaemon::new(
1554
+ "vault-password",
1555
+ SoftwareSignerBackend::default(),
1556
+ DaemonConfig::default(),
1557
+ )
1558
+ .expect("daemon"),
1559
+ );
1560
+ let current_euid = nix::unistd::geteuid().as_raw();
1561
+ let (mut client_stream, server_stream) = UnixStream::pair().expect("stream pair");
1562
+ let wire_request = WireRequest {
1563
+ body_json: "{not-json".to_string(),
1564
+ };
1565
+ write_frame(&mut client_stream, &wire_request, Duration::from_secs(2))
1566
+ .await
1567
+ .expect("write malformed request");
1568
+
1569
+ let err = handle_connection(
1570
+ server_stream,
1571
+ daemon,
1572
+ singleton_allowed_set(current_euid.saturating_add(1)),
1573
+ singleton_allowed_set(current_euid.saturating_add(2)),
1574
+ )
1575
+ .await
1576
+ .expect_err("must reject globally unauthorized peer");
1577
+
1578
+ assert!(
1579
+ matches!(err, UnixTransportError::UnauthorizedPeerEuid { actual, .. } if actual == current_euid)
1580
+ );
1581
+ }
1582
+
1583
+ #[tokio::test(flavor = "current_thread")]
1584
+ async fn handle_connection_rejects_agent_requests_from_admin_only_peers() {
1585
+ let daemon = Arc::new(
1586
+ InMemoryDaemon::new(
1587
+ "vault-password",
1588
+ SoftwareSignerBackend::default(),
1589
+ DaemonConfig::default(),
1590
+ )
1591
+ .expect("daemon"),
1592
+ );
1593
+ let current_euid = nix::unistd::geteuid().as_raw();
1594
+ let (mut client_stream, server_stream) = UnixStream::pair().expect("stream pair");
1595
+ let wire_request = WireRequest {
1596
+ body_json: serde_json::to_string(&vault_daemon::DaemonRpcRequest::EvaluateForAgent {
1597
+ request: dummy_agent_request(),
1598
+ })
1599
+ .expect("serialize request"),
1600
+ };
1601
+ write_frame(&mut client_stream, &wire_request, Duration::from_secs(2))
1602
+ .await
1603
+ .expect("write request");
1604
+
1605
+ let err = handle_connection(
1606
+ server_stream,
1607
+ daemon,
1608
+ singleton_allowed_set(current_euid),
1609
+ singleton_allowed_set(current_euid.saturating_add(1)),
1610
+ )
1611
+ .await
1612
+ .expect_err("must reject agent request");
1613
+
1614
+ assert!(
1615
+ matches!(err, UnixTransportError::UnauthorizedPeerEuid { actual, .. } if actual == current_euid)
1616
+ );
1617
+ assert!(
1618
+ read_frame::<WireResponse>(&mut client_stream, Duration::from_millis(50))
1619
+ .await
1620
+ .is_err()
1621
+ );
1622
+ }
1623
+
1624
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1625
+ async fn bind_with_split_allowlists_keeps_root_only_socket_private() {
1626
+ let socket_path = unique_socket_path("split-root-only-mode");
1627
+ let server = UnixDaemonServer::bind_with_allowed_peer_euids(
1628
+ socket_path.clone(),
1629
+ singleton_allowed_set(0),
1630
+ singleton_allowed_set(0),
1631
+ )
1632
+ .await
1633
+ .expect("server");
1634
+
1635
+ #[cfg(unix)]
1636
+ assert_eq!(socket_mode(&socket_path), 0o600);
1637
+
1638
+ drop(server);
1639
+ }
1640
+ }