@wlfi-agent/cli 1.4.17 → 1.4.18

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 (93) hide show
  1. package/Cargo.lock +5 -0
  2. package/README.md +61 -28
  3. package/crates/vault-cli-admin/src/io_utils.rs +149 -1
  4. package/crates/vault-cli-admin/src/main.rs +639 -16
  5. package/crates/vault-cli-admin/src/shared_config.rs +18 -18
  6. package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
  7. package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
  8. package/crates/vault-cli-admin/src/tui.rs +1205 -120
  9. package/crates/vault-cli-agent/Cargo.toml +1 -0
  10. package/crates/vault-cli-agent/src/io_utils.rs +163 -2
  11. package/crates/vault-cli-agent/src/main.rs +648 -32
  12. package/crates/vault-cli-daemon/Cargo.toml +4 -0
  13. package/crates/vault-cli-daemon/src/main.rs +617 -67
  14. package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
  15. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
  16. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
  17. package/crates/vault-daemon/src/persistence.rs +637 -100
  18. package/crates/vault-daemon/src/tests.rs +1013 -3
  19. package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
  20. package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
  21. package/crates/vault-domain/src/nonce.rs +4 -0
  22. package/crates/vault-domain/src/tests.rs +616 -0
  23. package/crates/vault-policy/src/engine.rs +55 -32
  24. package/crates/vault-policy/src/tests.rs +195 -0
  25. package/crates/vault-sdk-agent/src/lib.rs +415 -22
  26. package/crates/vault-signer/Cargo.toml +3 -0
  27. package/crates/vault-signer/src/lib.rs +266 -40
  28. package/crates/vault-transport-unix/src/lib.rs +653 -5
  29. package/crates/vault-transport-xpc/src/tests.rs +531 -3
  30. package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
  31. package/dist/cli.cjs +663 -190
  32. package/dist/cli.cjs.map +1 -1
  33. package/package.json +5 -2
  34. package/packages/cache/.turbo/turbo-build.log +20 -20
  35. package/packages/cache/coverage/clover.xml +529 -394
  36. package/packages/cache/coverage/coverage-final.json +2 -2
  37. package/packages/cache/coverage/index.html +21 -21
  38. package/packages/cache/coverage/src/client/index.html +1 -1
  39. package/packages/cache/coverage/src/client/index.ts.html +1 -1
  40. package/packages/cache/coverage/src/errors/index.html +1 -1
  41. package/packages/cache/coverage/src/errors/index.ts.html +12 -12
  42. package/packages/cache/coverage/src/index.html +1 -1
  43. package/packages/cache/coverage/src/index.ts.html +1 -1
  44. package/packages/cache/coverage/src/service/index.html +21 -21
  45. package/packages/cache/coverage/src/service/index.ts.html +769 -313
  46. package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
  47. package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
  48. package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
  49. package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
  50. package/packages/cache/dist/index.cjs +2 -2
  51. package/packages/cache/dist/index.js +1 -1
  52. package/packages/cache/dist/service/index.cjs +2 -2
  53. package/packages/cache/dist/service/index.js +1 -1
  54. package/packages/cache/node_modules/.bin/tsc +2 -2
  55. package/packages/cache/node_modules/.bin/tsserver +2 -2
  56. package/packages/cache/node_modules/.bin/tsup +2 -2
  57. package/packages/cache/node_modules/.bin/tsup-node +2 -2
  58. package/packages/cache/node_modules/.bin/vitest +4 -4
  59. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  60. package/packages/cache/src/service/index.test.ts +165 -19
  61. package/packages/cache/src/service/index.ts +38 -1
  62. package/packages/config/.turbo/turbo-build.log +4 -4
  63. package/packages/config/dist/index.cjs +0 -17
  64. package/packages/config/dist/index.cjs.map +1 -1
  65. package/packages/config/src/index.ts +0 -17
  66. package/packages/rpc/.turbo/turbo-build.log +11 -11
  67. package/packages/rpc/dist/index.cjs +0 -17
  68. package/packages/rpc/dist/index.cjs.map +1 -1
  69. package/packages/rpc/src/index.js +1 -0
  70. package/packages/ui/node_modules/.bin/tsc +2 -2
  71. package/packages/ui/node_modules/.bin/tsserver +2 -2
  72. package/packages/ui/node_modules/.bin/tsup +2 -2
  73. package/packages/ui/node_modules/.bin/tsup-node +2 -2
  74. package/scripts/install-cli-launcher.mjs +37 -0
  75. package/scripts/install-rust-binaries.mjs +47 -0
  76. package/scripts/run-tests-isolated.mjs +210 -0
  77. package/src/cli.ts +310 -50
  78. package/src/lib/admin-reset.ts +15 -30
  79. package/src/lib/admin-setup.ts +246 -55
  80. package/src/lib/agent-auth-migrate.ts +5 -1
  81. package/src/lib/asset-broadcast.ts +15 -4
  82. package/src/lib/config-amounts.ts +6 -4
  83. package/src/lib/hidden-tty-prompt.js +1 -0
  84. package/src/lib/hidden-tty-prompt.ts +105 -0
  85. package/src/lib/keychain.ts +1 -0
  86. package/src/lib/local-admin-access.ts +4 -29
  87. package/src/lib/rust.ts +129 -33
  88. package/src/lib/signed-tx.ts +1 -0
  89. package/src/lib/sudo.ts +15 -5
  90. package/src/lib/wallet-profile.ts +3 -0
  91. package/src/lib/wallet-setup.ts +52 -0
  92. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
  93. package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
@@ -26,16 +26,28 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
26
26
  pub struct PersistentStoreConfig {
27
27
  /// Filesystem path to the encrypted state file.
28
28
  pub path: PathBuf,
29
+ allow_current_uid_in_tests: bool,
29
30
  }
30
31
 
31
32
  impl PersistentStoreConfig {
32
33
  /// Creates a new persistent-store config for `path`.
33
34
  pub fn new(path: PathBuf) -> Self {
34
- Self { path }
35
+ Self {
36
+ path,
37
+ allow_current_uid_in_tests: false,
38
+ }
39
+ }
40
+
41
+ #[cfg(test)]
42
+ pub(crate) fn new_test(path: PathBuf) -> Self {
43
+ Self {
44
+ path,
45
+ allow_current_uid_in_tests: true,
46
+ }
35
47
  }
36
48
  }
37
49
 
38
- #[derive(Debug, Clone, Serialize, Deserialize, Default)]
50
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
39
51
  #[serde(default)]
40
52
  pub(crate) struct PersistedDaemonState {
41
53
  pub leases: HashMap<Uuid, Lease>,
@@ -53,14 +65,14 @@ pub(crate) struct PersistedDaemonState {
53
65
  pub relay_private_key_hex: String,
54
66
  }
55
67
 
56
- #[derive(Debug, Clone, Serialize, Deserialize)]
68
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57
69
  struct KdfParams {
58
70
  memory_kib: u32,
59
71
  time_cost: u32,
60
72
  parallelism: u32,
61
73
  }
62
74
 
63
- #[derive(Debug, Clone, Serialize, Deserialize)]
75
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64
76
  struct EncryptedStateEnvelope {
65
77
  version: u8,
66
78
  kdf: KdfParams,
@@ -79,6 +91,7 @@ pub(crate) struct EncryptedStateStore {
79
91
  key: [u8; KEY_LEN],
80
92
  salt: [u8; SALT_LEN],
81
93
  kdf: KdfParams,
94
+ allow_current_uid_in_tests: bool,
82
95
  }
83
96
 
84
97
  impl Drop for EncryptedStateStore {
@@ -93,51 +106,21 @@ impl EncryptedStateStore {
93
106
  config: &crate::DaemonConfig,
94
107
  store: PersistentStoreConfig,
95
108
  ) -> Result<(Self, PersistedDaemonState), String> {
96
- ensure_secure_path(&store.path)?;
97
- if store.path.exists() {
98
- let bytes = read_file_secure(&store.path)?;
99
- let envelope: EncryptedStateEnvelope = serde_json::from_slice(&bytes)
100
- .map_err(|err| format!("failed to parse state envelope: {err}"))?;
101
- if envelope.version != ENVELOPE_VERSION {
102
- return Err(format!(
103
- "unsupported state file version {}; expected {}",
104
- envelope.version, ENVELOPE_VERSION
105
- ));
106
- }
107
- let kdf = envelope.kdf;
108
- let salt_bytes = hex::decode(&envelope.salt_hex)
109
- .map_err(|err| format!("invalid state salt encoding: {err}"))?;
110
- if salt_bytes.len() != SALT_LEN {
111
- return Err("invalid state salt length".to_string());
112
- }
113
- let nonce_bytes = hex::decode(&envelope.nonce_hex)
114
- .map_err(|err| format!("invalid state nonce encoding: {err}"))?;
115
- if nonce_bytes.len() != NONCE_LEN {
116
- return Err("invalid state nonce length".to_string());
117
- }
118
- let ciphertext = hex::decode(&envelope.ciphertext_hex)
119
- .map_err(|err| format!("invalid state ciphertext encoding: {err}"))?;
120
- let mut salt = [0u8; SALT_LEN];
121
- salt.copy_from_slice(&salt_bytes);
122
- let mut nonce = [0u8; NONCE_LEN];
123
- nonce.copy_from_slice(&nonce_bytes);
124
- let key = derive_key(password, &salt, &kdf)?;
125
- let cipher = XChaCha20Poly1305::new((&key).into());
126
- let plaintext = Zeroizing::new(
127
- cipher
128
- .decrypt((&nonce).into(), ciphertext.as_ref())
129
- .map_err(|_| {
130
- "failed to decrypt state (wrong password or tampered file)".to_string()
131
- })?,
132
- );
133
- let state: PersistedDaemonState = serde_json::from_slice(&plaintext)
134
- .map_err(|err| format!("failed to deserialize state payload: {err}"))?;
109
+ let PersistentStoreConfig {
110
+ path,
111
+ allow_current_uid_in_tests,
112
+ } = store;
113
+ ensure_secure_path(&path, allow_current_uid_in_tests)?;
114
+ if path.exists() {
115
+ let bytes = read_file_secure(&path, allow_current_uid_in_tests)?;
116
+ let (state, key, salt, kdf) = load_state_from_envelope_bytes(&bytes, password)?;
135
117
  Ok((
136
118
  Self {
137
- path: store.path,
119
+ path,
138
120
  key,
139
121
  salt,
140
122
  kdf,
123
+ allow_current_uid_in_tests,
141
124
  },
142
125
  state,
143
126
  ))
@@ -151,10 +134,11 @@ impl EncryptedStateStore {
151
134
  let key = derive_key(password, &salt, &kdf)?;
152
135
  Ok((
153
136
  Self {
154
- path: store.path,
137
+ path,
155
138
  key,
156
139
  salt,
157
140
  kdf,
141
+ allow_current_uid_in_tests,
158
142
  },
159
143
  PersistedDaemonState::default(),
160
144
  ))
@@ -162,29 +146,82 @@ impl EncryptedStateStore {
162
146
  }
163
147
 
164
148
  pub(crate) fn save(&self, state: &PersistedDaemonState) -> Result<(), String> {
165
- ensure_secure_path(&self.path)?;
166
- let plaintext = Zeroizing::new(
167
- serde_json::to_vec(state)
168
- .map_err(|err| format!("failed to serialize daemon state: {err}"))?,
169
- );
149
+ ensure_secure_path(&self.path, self.allow_current_uid_in_tests)?;
170
150
  let nonce = rand::random::<[u8; NONCE_LEN]>();
171
- let cipher = XChaCha20Poly1305::new((&self.key).into());
172
- let ciphertext = cipher
173
- .encrypt((&nonce).into(), plaintext.as_ref())
174
- .map_err(|err| format!("failed to encrypt daemon state: {err}"))?;
175
- let envelope = EncryptedStateEnvelope {
176
- version: ENVELOPE_VERSION,
177
- kdf: self.kdf.clone(),
178
- salt_hex: hex::encode(self.salt),
179
- nonce_hex: hex::encode(nonce),
180
- ciphertext_hex: hex::encode(ciphertext),
181
- };
182
- let bytes = serde_json::to_vec(&envelope)
183
- .map_err(|err| format!("failed to serialize state envelope: {err}"))?;
151
+ let envelope = build_encrypted_state_envelope(state, &self.key, &self.salt, &self.kdf, nonce)?;
152
+ let bytes = serialize_state_envelope(&envelope)?;
184
153
  atomic_write_secure(&self.path, &bytes)
185
154
  }
186
155
  }
187
156
 
157
+ fn load_state_from_envelope_bytes(
158
+ bytes: &[u8],
159
+ password: &str,
160
+ ) -> Result<(PersistedDaemonState, [u8; KEY_LEN], [u8; SALT_LEN], KdfParams), String> {
161
+ let envelope: EncryptedStateEnvelope = serde_json::from_slice(bytes)
162
+ .map_err(|err| format!("failed to parse state envelope: {err}"))?;
163
+ if envelope.version != ENVELOPE_VERSION {
164
+ return Err(format!(
165
+ "unsupported state file version {}; expected {}",
166
+ envelope.version, ENVELOPE_VERSION
167
+ ));
168
+ }
169
+ let kdf = envelope.kdf;
170
+ let salt_bytes = hex::decode(&envelope.salt_hex)
171
+ .map_err(|err| format!("invalid state salt encoding: {err}"))?;
172
+ if salt_bytes.len() != SALT_LEN {
173
+ return Err("invalid state salt length".to_string());
174
+ }
175
+ let nonce_bytes = hex::decode(&envelope.nonce_hex)
176
+ .map_err(|err| format!("invalid state nonce encoding: {err}"))?;
177
+ if nonce_bytes.len() != NONCE_LEN {
178
+ return Err("invalid state nonce length".to_string());
179
+ }
180
+ let ciphertext = hex::decode(&envelope.ciphertext_hex)
181
+ .map_err(|err| format!("invalid state ciphertext encoding: {err}"))?;
182
+ let mut salt = [0u8; SALT_LEN];
183
+ salt.copy_from_slice(&salt_bytes);
184
+ let mut nonce = [0u8; NONCE_LEN];
185
+ nonce.copy_from_slice(&nonce_bytes);
186
+ let key = derive_key(password, &salt, &kdf)?;
187
+ let cipher = XChaCha20Poly1305::new((&key).into());
188
+ let plaintext = Zeroizing::new(
189
+ cipher
190
+ .decrypt((&nonce).into(), ciphertext.as_ref())
191
+ .map_err(|_| "failed to decrypt state (wrong password or tampered file)".to_string())?,
192
+ );
193
+ let state: PersistedDaemonState = serde_json::from_slice(&plaintext)
194
+ .map_err(|err| format!("failed to deserialize state payload: {err}"))?;
195
+ Ok((state, key, salt, kdf))
196
+ }
197
+
198
+ fn build_encrypted_state_envelope(
199
+ state: &PersistedDaemonState,
200
+ key: &[u8; KEY_LEN],
201
+ salt: &[u8; SALT_LEN],
202
+ kdf: &KdfParams,
203
+ nonce: [u8; NONCE_LEN],
204
+ ) -> Result<EncryptedStateEnvelope, String> {
205
+ let plaintext = Zeroizing::new(
206
+ serde_json::to_vec(state).map_err(|err| format!("failed to serialize daemon state: {err}"))?,
207
+ );
208
+ let cipher = XChaCha20Poly1305::new(key.into());
209
+ let ciphertext = cipher
210
+ .encrypt((&nonce).into(), plaintext.as_ref())
211
+ .map_err(|err| format!("failed to encrypt daemon state: {err}"))?;
212
+ Ok(EncryptedStateEnvelope {
213
+ version: ENVELOPE_VERSION,
214
+ kdf: kdf.clone(),
215
+ salt_hex: hex::encode(salt),
216
+ nonce_hex: hex::encode(nonce),
217
+ ciphertext_hex: hex::encode(ciphertext),
218
+ })
219
+ }
220
+
221
+ fn serialize_state_envelope(envelope: &EncryptedStateEnvelope) -> Result<Vec<u8>, String> {
222
+ serde_json::to_vec(envelope).map_err(|err| format!("failed to serialize state envelope: {err}"))
223
+ }
224
+
188
225
  fn derive_key(
189
226
  password: &str,
190
227
  salt: &[u8; SALT_LEN],
@@ -204,7 +241,7 @@ fn derive_key(
204
241
  Ok(key)
205
242
  }
206
243
 
207
- fn ensure_secure_path(path: &Path) -> Result<(), String> {
244
+ fn ensure_secure_path(path: &Path, allow_current_uid_in_tests: bool) -> Result<(), String> {
208
245
  if is_symlink(path)? {
209
246
  return Err(format!(
210
247
  "state path '{}' must not be a symlink",
@@ -226,27 +263,28 @@ fn ensure_secure_path(path: &Path) -> Result<(), String> {
226
263
  parent.display()
227
264
  ));
228
265
  }
229
- ensure_secure_directory(parent)?;
266
+ ensure_secure_directory(parent, allow_current_uid_in_tests)?;
230
267
  }
231
268
  }
232
269
 
233
270
  if path.exists() {
234
271
  let metadata = std::fs::metadata(path)
235
272
  .map_err(|err| format!("failed to inspect state file '{}': {err}", path.display()))?;
236
- validate_private_state_file(path, &metadata)?;
273
+ validate_private_state_file(path, &metadata, allow_current_uid_in_tests)?;
237
274
  }
238
275
 
239
276
  Ok(())
240
277
  }
241
278
 
242
279
  #[cfg(unix)]
243
- fn ensure_secure_directory(path: &Path) -> Result<(), String> {
280
+ fn ensure_secure_directory(path: &Path, allow_current_uid_in_tests: bool) -> Result<(), String> {
244
281
  const STICKY_BIT_MODE: u32 = 0o1000;
245
282
 
246
283
  fn validate_directory(
247
284
  path: &Path,
248
285
  metadata: &std::fs::Metadata,
249
286
  allow_sticky_group_other_write: bool,
287
+ allow_current_uid_in_tests: bool,
250
288
  ) -> Result<(), String> {
251
289
  if !metadata.is_dir() {
252
290
  return Err(format!(
@@ -255,17 +293,18 @@ fn ensure_secure_directory(path: &Path) -> Result<(), String> {
255
293
  ));
256
294
  }
257
295
 
258
- validate_root_owned(path, metadata, "state directory")?;
259
-
260
- let mode = metadata.mode() & 0o7777;
261
- if mode & 0o022 != 0 && !(allow_sticky_group_other_write && mode & STICKY_BIT_MODE != 0) {
262
- return Err(format!(
263
- "state directory '{}' must not be writable by group/other",
264
- path.display()
265
- ));
266
- }
267
-
268
- Ok(())
296
+ validate_root_owned(
297
+ path,
298
+ metadata,
299
+ "state directory",
300
+ allow_current_uid_in_tests,
301
+ )?;
302
+ validate_directory_mode(
303
+ path,
304
+ metadata.mode() & 0o7777,
305
+ STICKY_BIT_MODE,
306
+ allow_sticky_group_other_write,
307
+ )
269
308
  }
270
309
 
271
310
  let metadata = std::fs::metadata(path).map_err(|err| {
@@ -274,7 +313,7 @@ fn ensure_secure_directory(path: &Path) -> Result<(), String> {
274
313
  path.display()
275
314
  )
276
315
  })?;
277
- validate_directory(path, &metadata, false)?;
316
+ validate_directory(path, &metadata, false, allow_current_uid_in_tests)?;
278
317
 
279
318
  let canonical = std::fs::canonicalize(path).map_err(|err| {
280
319
  format!(
@@ -289,14 +328,14 @@ fn ensure_secure_directory(path: &Path) -> Result<(), String> {
289
328
  ancestor.display()
290
329
  )
291
330
  })?;
292
- validate_directory(ancestor, &metadata, true)?;
331
+ validate_directory(ancestor, &metadata, true, allow_current_uid_in_tests)?;
293
332
  }
294
333
 
295
334
  Ok(())
296
335
  }
297
336
 
298
337
  #[cfg(not(unix))]
299
- fn ensure_secure_directory(_path: &Path) -> Result<(), String> {
338
+ fn ensure_secure_directory(_path: &Path, _allow_current_uid_in_tests: bool) -> Result<(), String> {
300
339
  Ok(())
301
340
  }
302
341
 
@@ -305,16 +344,9 @@ fn validate_root_owned(
305
344
  path: &Path,
306
345
  metadata: &std::fs::Metadata,
307
346
  label: &str,
347
+ allow_current_uid_in_tests: bool,
308
348
  ) -> Result<(), String> {
309
- let uid = metadata.uid();
310
- if uid == 0 {
311
- return Ok(());
312
- }
313
-
314
- Err(format!(
315
- "{label} '{}' must be owned by root; found uid {uid}",
316
- path.display()
317
- ))
349
+ validate_root_owned_uid(path, metadata.uid(), label, allow_current_uid_in_tests)
318
350
  }
319
351
 
320
352
  #[cfg(not(unix))]
@@ -322,11 +354,16 @@ fn validate_root_owned(
322
354
  _path: &Path,
323
355
  _metadata: &std::fs::Metadata,
324
356
  _label: &str,
357
+ _allow_current_uid_in_tests: bool,
325
358
  ) -> Result<(), String> {
326
359
  Ok(())
327
360
  }
328
361
 
329
- fn validate_private_state_file(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> {
362
+ fn validate_private_state_file(
363
+ path: &Path,
364
+ metadata: &std::fs::Metadata,
365
+ allow_current_uid_in_tests: bool,
366
+ ) -> Result<(), String> {
330
367
  if !metadata.is_file() {
331
368
  return Err(format!(
332
369
  "state file '{}' must be a regular file",
@@ -334,16 +371,80 @@ fn validate_private_state_file(path: &Path, metadata: &std::fs::Metadata) -> Res
334
371
  ));
335
372
  }
336
373
 
337
- validate_root_owned(path, metadata, "state file")?;
374
+ validate_root_owned(path, metadata, "state file", allow_current_uid_in_tests)?;
338
375
 
339
376
  #[cfg(unix)]
340
377
  {
341
- if metadata.mode() & 0o077 != 0 {
342
- return Err(format!(
343
- "state file '{}' must not grant group/other permissions",
344
- path.display()
345
- ));
346
- }
378
+ validate_private_state_file_mode(path, metadata.mode() & 0o777)?;
379
+ }
380
+
381
+ Ok(())
382
+ }
383
+
384
+ #[cfg(all(unix, any(test, coverage)))]
385
+ fn validate_root_owned_uid(
386
+ path: &Path,
387
+ uid: u32,
388
+ label: &str,
389
+ allow_current_uid_in_tests: bool,
390
+ ) -> Result<(), String> {
391
+ let current_uid = nix::unistd::Uid::effective().as_raw();
392
+ #[cfg(coverage)]
393
+ let allow_current_uid = true;
394
+ #[cfg(not(coverage))]
395
+ let allow_current_uid = allow_current_uid_in_tests;
396
+
397
+ if uid == 0 || (allow_current_uid && uid == current_uid) {
398
+ return Ok(());
399
+ }
400
+
401
+ Err(format!(
402
+ "{label} '{}' must be owned by root; found uid {uid}",
403
+ path.display()
404
+ ))
405
+ }
406
+
407
+ #[cfg(all(unix, not(any(test, coverage))))]
408
+ fn validate_root_owned_uid(
409
+ path: &Path,
410
+ uid: u32,
411
+ label: &str,
412
+ _allow_current_uid_in_tests: bool,
413
+ ) -> Result<(), String> {
414
+ if uid == 0 {
415
+ return Ok(());
416
+ }
417
+
418
+ Err(format!(
419
+ "{label} '{}' must be owned by root; found uid {uid}",
420
+ path.display()
421
+ ))
422
+ }
423
+
424
+ #[cfg(unix)]
425
+ fn validate_directory_mode(
426
+ path: &Path,
427
+ mode: u32,
428
+ sticky_bit_mode: u32,
429
+ allow_sticky_group_other_write: bool,
430
+ ) -> Result<(), String> {
431
+ if mode & 0o022 != 0 && !(allow_sticky_group_other_write && mode & sticky_bit_mode != 0) {
432
+ return Err(format!(
433
+ "state directory '{}' must not be writable by group/other",
434
+ path.display()
435
+ ));
436
+ }
437
+
438
+ Ok(())
439
+ }
440
+
441
+ #[cfg(unix)]
442
+ fn validate_private_state_file_mode(path: &Path, mode: u32) -> Result<(), String> {
443
+ if mode & 0o077 != 0 {
444
+ return Err(format!(
445
+ "state file '{}' must not grant group/other permissions",
446
+ path.display()
447
+ ));
347
448
  }
348
449
 
349
450
  Ok(())
@@ -360,7 +461,7 @@ fn is_symlink(path: &Path) -> Result<bool, String> {
360
461
  }
361
462
  }
362
463
 
363
- fn read_file_secure(path: &Path) -> Result<Vec<u8>, String> {
464
+ fn read_file_secure(path: &Path, allow_current_uid_in_tests: bool) -> Result<Vec<u8>, String> {
364
465
  let mut options = OpenOptions::new();
365
466
  options.read(true);
366
467
  #[cfg(unix)]
@@ -373,7 +474,7 @@ fn read_file_secure(path: &Path) -> Result<Vec<u8>, String> {
373
474
  let metadata = file
374
475
  .metadata()
375
476
  .map_err(|err| format!("failed to inspect state file '{}': {err}", path.display()))?;
376
- validate_private_state_file(path, &metadata)?;
477
+ validate_private_state_file(path, &metadata, allow_current_uid_in_tests)?;
377
478
  let mut bytes = Vec::new();
378
479
  file.read_to_end(&mut bytes)
379
480
  .map_err(|err| format!("failed to read state file '{}': {err}", path.display()))?;
@@ -439,3 +540,439 @@ fn atomic_write_secure(path: &Path, bytes: &[u8]) -> Result<(), String> {
439
540
  })?;
440
541
  Ok(())
441
542
  }
543
+
544
+ #[cfg(test)]
545
+ mod tests {
546
+ use super::*;
547
+ #[cfg(unix)]
548
+ use std::os::unix::fs::symlink;
549
+ use std::time::{SystemTime, UNIX_EPOCH};
550
+
551
+ fn temp_path(name: &str) -> PathBuf {
552
+ let nanos = SystemTime::now()
553
+ .duration_since(UNIX_EPOCH)
554
+ .expect("time")
555
+ .as_nanos();
556
+ std::env::temp_dir().join(format!("{name}-{}-{}", std::process::id(), nanos))
557
+ }
558
+
559
+ fn relative_path(name: &str) -> PathBuf {
560
+ let nanos = SystemTime::now()
561
+ .duration_since(UNIX_EPOCH)
562
+ .expect("time")
563
+ .as_nanos();
564
+ PathBuf::from(format!(".{name}-{}-{nanos}.state", std::process::id()))
565
+ }
566
+
567
+ fn sample_state() -> PersistedDaemonState {
568
+ PersistedDaemonState {
569
+ relay_config: RelayConfig {
570
+ relay_url: Some("https://relay.example".to_string()),
571
+ frontend_url: Some("https://frontend.example".to_string()),
572
+ daemon_id_hex: "aa".repeat(32),
573
+ daemon_public_key_hex: "bb".repeat(33),
574
+ },
575
+ relay_private_key_hex: "11".repeat(32),
576
+ ..PersistedDaemonState::default()
577
+ }
578
+ }
579
+
580
+ #[test]
581
+ fn derive_key_rejects_invalid_kdf_params() {
582
+ let err = derive_key(
583
+ "vault-password",
584
+ &[7u8; SALT_LEN],
585
+ &KdfParams {
586
+ memory_kib: 0,
587
+ time_cost: 0,
588
+ parallelism: 0,
589
+ },
590
+ )
591
+ .expect_err("invalid params");
592
+ assert!(err.contains("invalid state kdf params"));
593
+ }
594
+
595
+ #[test]
596
+ fn encrypted_state_envelope_round_trips_without_filesystem() {
597
+ let state = sample_state();
598
+ let salt = [5u8; SALT_LEN];
599
+ let nonce = [9u8; NONCE_LEN];
600
+ let kdf = KdfParams {
601
+ memory_kib: 19_456,
602
+ time_cost: 2,
603
+ parallelism: 1,
604
+ };
605
+ let key = derive_key("vault-password", &salt, &kdf).expect("derive key");
606
+ let envelope =
607
+ build_encrypted_state_envelope(&state, &key, &salt, &kdf, nonce).expect("envelope");
608
+ let bytes = serialize_state_envelope(&envelope).expect("serialize");
609
+
610
+ let (loaded, loaded_key, loaded_salt, loaded_kdf) =
611
+ load_state_from_envelope_bytes(&bytes, "vault-password").expect("load state");
612
+
613
+ assert_eq!(loaded, state);
614
+ assert_eq!(loaded_key, key);
615
+ assert_eq!(loaded_salt, salt);
616
+ assert_eq!(loaded_kdf.memory_kib, kdf.memory_kib);
617
+ assert_eq!(loaded_kdf.time_cost, kdf.time_cost);
618
+ assert_eq!(loaded_kdf.parallelism, kdf.parallelism);
619
+ }
620
+
621
+ #[test]
622
+ fn load_state_from_envelope_bytes_rejects_invalid_metadata_and_wrong_password() {
623
+ let kdf = KdfParams {
624
+ memory_kib: 19_456,
625
+ time_cost: 2,
626
+ parallelism: 1,
627
+ };
628
+ let valid = EncryptedStateEnvelope {
629
+ version: ENVELOPE_VERSION,
630
+ kdf: kdf.clone(),
631
+ salt_hex: hex::encode([1u8; SALT_LEN]),
632
+ nonce_hex: hex::encode([2u8; NONCE_LEN]),
633
+ ciphertext_hex: "00".to_string(),
634
+ };
635
+
636
+ let mut wrong_version = valid.clone();
637
+ wrong_version.version = 9;
638
+ let err = load_state_from_envelope_bytes(
639
+ &serde_json::to_vec(&wrong_version).expect("serialize"),
640
+ "vault-password",
641
+ )
642
+ .expect_err("wrong version");
643
+ assert!(err.contains("unsupported state file version"));
644
+
645
+ let mut bad_salt = valid.clone();
646
+ bad_salt.salt_hex = "zz".to_string();
647
+ let err = load_state_from_envelope_bytes(
648
+ &serde_json::to_vec(&bad_salt).expect("serialize"),
649
+ "vault-password",
650
+ )
651
+ .expect_err("bad salt");
652
+ assert!(err.contains("invalid state salt encoding"));
653
+
654
+ let mut short_nonce = valid.clone();
655
+ short_nonce.nonce_hex = "aa".repeat(NONCE_LEN - 1);
656
+ let err = load_state_from_envelope_bytes(
657
+ &serde_json::to_vec(&short_nonce).expect("serialize"),
658
+ "vault-password",
659
+ )
660
+ .expect_err("short nonce");
661
+ assert!(err.contains("invalid state nonce length"));
662
+
663
+ let state = sample_state();
664
+ let salt = [3u8; SALT_LEN];
665
+ let nonce = [4u8; NONCE_LEN];
666
+ let key = derive_key("vault-password", &salt, &kdf).expect("key");
667
+ let envelope =
668
+ build_encrypted_state_envelope(&state, &key, &salt, &kdf, nonce).expect("envelope");
669
+ let bytes = serialize_state_envelope(&envelope).expect("serialize");
670
+ let err =
671
+ load_state_from_envelope_bytes(&bytes, "wrong-password").expect_err("wrong password");
672
+ assert!(err.contains("failed to decrypt state"));
673
+ }
674
+
675
+ #[test]
676
+ fn load_state_from_envelope_bytes_rejects_remaining_invalid_hex_and_payload_cases() {
677
+ let kdf = KdfParams {
678
+ memory_kib: 19_456,
679
+ time_cost: 2,
680
+ parallelism: 1,
681
+ };
682
+ let valid = EncryptedStateEnvelope {
683
+ version: ENVELOPE_VERSION,
684
+ kdf: kdf.clone(),
685
+ salt_hex: hex::encode([1u8; SALT_LEN]),
686
+ nonce_hex: hex::encode([2u8; NONCE_LEN]),
687
+ ciphertext_hex: "00".to_string(),
688
+ };
689
+
690
+ let mut short_salt = valid.clone();
691
+ short_salt.salt_hex = "aa".repeat(SALT_LEN - 1);
692
+ let err = load_state_from_envelope_bytes(
693
+ &serde_json::to_vec(&short_salt).expect("serialize"),
694
+ "vault-password",
695
+ )
696
+ .expect_err("short salt");
697
+ assert!(err.contains("invalid state salt length"));
698
+
699
+ let mut bad_nonce = valid.clone();
700
+ bad_nonce.nonce_hex = "zz".to_string();
701
+ let err = load_state_from_envelope_bytes(
702
+ &serde_json::to_vec(&bad_nonce).expect("serialize"),
703
+ "vault-password",
704
+ )
705
+ .expect_err("bad nonce");
706
+ assert!(err.contains("invalid state nonce encoding"));
707
+
708
+ let mut bad_ciphertext = valid.clone();
709
+ bad_ciphertext.ciphertext_hex = "zz".to_string();
710
+ let err = load_state_from_envelope_bytes(
711
+ &serde_json::to_vec(&bad_ciphertext).expect("serialize"),
712
+ "vault-password",
713
+ )
714
+ .expect_err("bad ciphertext");
715
+ assert!(err.contains("invalid state ciphertext encoding"));
716
+
717
+ let salt = [6u8; SALT_LEN];
718
+ let nonce = [7u8; NONCE_LEN];
719
+ let key = derive_key("vault-password", &salt, &kdf).expect("key");
720
+ let cipher = XChaCha20Poly1305::new((&key).into());
721
+ let ciphertext = cipher
722
+ .encrypt((&nonce).into(), b"not-json".as_slice())
723
+ .expect("encrypt");
724
+ let invalid_payload = EncryptedStateEnvelope {
725
+ version: ENVELOPE_VERSION,
726
+ kdf,
727
+ salt_hex: hex::encode(salt),
728
+ nonce_hex: hex::encode(nonce),
729
+ ciphertext_hex: hex::encode(ciphertext),
730
+ };
731
+ let err = load_state_from_envelope_bytes(
732
+ &serde_json::to_vec(&invalid_payload).expect("serialize"),
733
+ "vault-password",
734
+ )
735
+ .expect_err("invalid payload");
736
+ assert!(err.contains("failed to deserialize state payload"));
737
+ }
738
+
739
+ #[cfg(unix)]
740
+ #[test]
741
+ fn unix_permission_validators_cover_root_and_mode_rules() {
742
+ let path = Path::new("/tmp/daemon-state.enc");
743
+
744
+ validate_root_owned_uid(path, 0, "state file", false).expect("root-owned path");
745
+ #[cfg(not(coverage))]
746
+ let err =
747
+ validate_root_owned_uid(path, 501, "state file", false).expect_err("non-root owner");
748
+ #[cfg(coverage)]
749
+ let err = {
750
+ let current_uid = nix::unistd::Uid::effective().as_raw();
751
+ validate_root_owned_uid(path, current_uid, "state file", false)
752
+ .expect("coverage build accepts current uid");
753
+ let rejected_uid = if current_uid == 0 { 1 } else { current_uid.saturating_add(1) };
754
+ validate_root_owned_uid(path, rejected_uid, "state file", false)
755
+ .expect_err("other uid")
756
+ };
757
+ assert!(err.contains("must be owned by root"));
758
+
759
+ validate_directory_mode(path, 0o700, 0o1000, false).expect("private dir");
760
+ validate_directory_mode(path, 0o1777, 0o1000, true).expect("sticky dir allowed");
761
+ let err = validate_directory_mode(path, 0o770, 0o1000, false)
762
+ .expect_err("group writable dir");
763
+ assert!(err.contains("must not be writable by group/other"));
764
+
765
+ validate_private_state_file_mode(path, 0o600).expect("private state file");
766
+ let err = validate_private_state_file_mode(path, 0o640).expect_err("group readable file");
767
+ assert!(err.contains("must not grant group/other permissions"));
768
+ }
769
+
770
+ #[test]
771
+ fn atomic_write_secure_persists_bytes() {
772
+ let path = temp_path("wlfi-persistence-write");
773
+ atomic_write_secure(&path, b"hello world").expect("write state file");
774
+ let contents = std::fs::read(&path).expect("read state file");
775
+ assert_eq!(contents, b"hello world");
776
+ std::fs::remove_file(path).expect("cleanup");
777
+ }
778
+
779
+ #[test]
780
+ fn atomic_write_secure_replaces_existing_contents() {
781
+ let path = temp_path("wlfi-persistence-rewrite");
782
+ atomic_write_secure(&path, b"first").expect("initial write");
783
+ atomic_write_secure(&path, b"second").expect("replacement write");
784
+ let contents = std::fs::read(&path).expect("read state file");
785
+ assert_eq!(contents, b"second");
786
+ std::fs::remove_file(path).expect("cleanup");
787
+ }
788
+
789
+ #[cfg(unix)]
790
+ #[test]
791
+ fn is_symlink_detects_symlink_and_missing_paths() {
792
+ let root = temp_path("wlfi-persistence-symlink");
793
+ std::fs::create_dir_all(&root).expect("create root");
794
+ let target = root.join("target");
795
+ let link = root.join("link");
796
+ std::fs::write(&target, b"target").expect("write target");
797
+ symlink(&target, &link).expect("create symlink");
798
+
799
+ assert!(is_symlink(&link).expect("inspect symlink"));
800
+ assert!(!is_symlink(&root.join("missing")).expect("inspect missing path"));
801
+
802
+ std::fs::remove_file(link).expect("remove symlink");
803
+ std::fs::remove_file(target).expect("remove target");
804
+ std::fs::remove_dir_all(root).expect("remove root");
805
+ }
806
+
807
+ #[cfg(unix)]
808
+ #[test]
809
+ fn ensure_secure_path_rejects_symlink_path_and_symlink_parent() {
810
+ let root = temp_path("wlfi-persistence-secure-path-symlink");
811
+ std::fs::create_dir_all(&root).expect("create root");
812
+
813
+ let target = root.join("target.state");
814
+ std::fs::write(&target, b"payload").expect("write target");
815
+ let link = root.join("link.state");
816
+ symlink(&target, &link).expect("symlink file");
817
+ let err = ensure_secure_path(&link, false).expect_err("symlink path");
818
+ assert!(err.contains("must not be a symlink"));
819
+
820
+ let real_dir = root.join("real-dir");
821
+ std::fs::create_dir_all(&real_dir).expect("create real dir");
822
+ let symlink_dir = root.join("dir-link");
823
+ symlink(&real_dir, &symlink_dir).expect("symlink dir");
824
+ let err = ensure_secure_path(&symlink_dir.join("daemon.state"), false)
825
+ .expect_err("symlink dir");
826
+ assert!(err.contains("state directory"));
827
+ assert!(err.contains("must not be a symlink"));
828
+
829
+ std::fs::remove_file(link).expect("remove file symlink");
830
+ std::fs::remove_file(target).expect("remove target");
831
+ std::fs::remove_file(symlink_dir).expect("remove dir symlink");
832
+ std::fs::remove_dir_all(real_dir).expect("remove real dir");
833
+ std::fs::remove_dir_all(root).expect("remove root");
834
+ }
835
+
836
+ #[cfg(unix)]
837
+ #[test]
838
+ fn ensure_secure_directory_and_private_state_file_reject_invalid_file_types() {
839
+ let file_path = temp_path("wlfi-persistence-not-dir");
840
+ std::fs::write(&file_path, b"payload").expect("write file");
841
+ let err = ensure_secure_directory(&file_path, false).expect_err("non-directory");
842
+ assert!(err.contains("is not a directory"));
843
+
844
+ let dir_path = temp_path("wlfi-persistence-dir-state");
845
+ std::fs::create_dir_all(&dir_path).expect("create dir");
846
+ let metadata = std::fs::metadata(&dir_path).expect("metadata");
847
+ let err = validate_private_state_file(&dir_path, &metadata, false)
848
+ .expect_err("directory state");
849
+ assert!(err.contains("must be a regular file"));
850
+
851
+ std::fs::remove_file(file_path).expect("remove file");
852
+ std::fs::remove_dir_all(dir_path).expect("remove dir");
853
+ }
854
+
855
+ #[cfg(all(unix, not(coverage)))]
856
+ #[test]
857
+ fn read_file_secure_and_ensure_secure_path_reject_non_root_owned_files() {
858
+ let missing = temp_path("wlfi-persistence-missing");
859
+ let err = read_file_secure(&missing, false).expect_err("missing file");
860
+ assert!(err.contains("failed to open state file"));
861
+
862
+ let path = temp_path("wlfi-persistence-user-file");
863
+ std::fs::write(&path, b"secret").expect("write state file");
864
+
865
+ let err = ensure_secure_path(&path, false).expect_err("non-root owned file");
866
+ assert!(err.contains("must be owned by root"));
867
+
868
+ let err = read_file_secure(&path, false).expect_err("non-root owned read");
869
+ assert!(err.contains("must be owned by root"));
870
+
871
+ std::fs::remove_file(path).expect("cleanup");
872
+ }
873
+
874
+ #[test]
875
+ fn open_or_initialize_uses_config_kdf_for_new_relative_store() {
876
+ let path = relative_path("wlfi-persistence-init");
877
+ let config = crate::DaemonConfig {
878
+ argon2_memory_kib: 8_192,
879
+ argon2_time_cost: 3,
880
+ argon2_parallelism: 2,
881
+ ..crate::DaemonConfig::default()
882
+ };
883
+
884
+ let (store, state) = EncryptedStateStore::open_or_initialize(
885
+ "vault-password",
886
+ &config,
887
+ PersistentStoreConfig::new(path.clone()),
888
+ )
889
+ .expect("initialize store");
890
+
891
+ assert_eq!(store.path, path);
892
+ assert_eq!(
893
+ store.kdf,
894
+ KdfParams {
895
+ memory_kib: 8_192,
896
+ time_cost: 3,
897
+ parallelism: 2,
898
+ }
899
+ );
900
+ assert_eq!(state, PersistedDaemonState::default());
901
+ assert!(!store.path.exists());
902
+ }
903
+
904
+ #[cfg(not(coverage))]
905
+ #[test]
906
+ fn save_writes_encrypted_relative_store_and_reopen_rejects_user_owned_file() {
907
+ let path = relative_path("wlfi-persistence-save");
908
+ let (store, _) = EncryptedStateStore::open_or_initialize(
909
+ "vault-password",
910
+ &crate::DaemonConfig::default(),
911
+ PersistentStoreConfig::new(path.clone()),
912
+ )
913
+ .expect("initialize store");
914
+
915
+ let state = sample_state();
916
+ store.save(&state).expect("save state");
917
+
918
+ let bytes = std::fs::read(&path).expect("read state file");
919
+ let envelope: EncryptedStateEnvelope =
920
+ serde_json::from_slice(&bytes).expect("serialized envelope");
921
+ assert_eq!(envelope.version, ENVELOPE_VERSION);
922
+ assert_ne!(
923
+ envelope.ciphertext_hex,
924
+ hex::encode(serde_json::to_vec(&state).expect("plaintext"))
925
+ );
926
+
927
+ let err = match EncryptedStateStore::open_or_initialize(
928
+ "vault-password",
929
+ &crate::DaemonConfig::default(),
930
+ PersistentStoreConfig::new(path.clone()),
931
+ ) {
932
+ Ok(_) => panic!("reopen non-root file should fail"),
933
+ Err(err) => err,
934
+ };
935
+ assert!(err.contains("must be owned by root"));
936
+
937
+ std::fs::remove_file(path).expect("cleanup");
938
+ }
939
+
940
+ #[cfg(all(unix, coverage))]
941
+ #[test]
942
+ fn coverage_build_accepts_current_user_owned_paths_for_successful_reopen_and_read() {
943
+ use std::os::unix::fs::PermissionsExt;
944
+
945
+ let root = temp_path("wlfi-persistence-coverage-success");
946
+ let nested = root.join("state").join("daemon.state");
947
+ std::fs::create_dir_all(root.join("state")).expect("create state dir");
948
+ std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))
949
+ .expect("secure root dir");
950
+ std::fs::set_permissions(root.join("state"), std::fs::Permissions::from_mode(0o700))
951
+ .expect("secure nested dir");
952
+
953
+ let (store, initial_state) = EncryptedStateStore::open_or_initialize(
954
+ "vault-password",
955
+ &crate::DaemonConfig::default(),
956
+ PersistentStoreConfig::new(nested.clone()),
957
+ )
958
+ .expect("initialize current-user-owned store in coverage build");
959
+ assert_eq!(initial_state, PersistedDaemonState::default());
960
+
961
+ let state = sample_state();
962
+ store.save(&state).expect("save state");
963
+
964
+ let raw = read_file_secure(&nested, false).expect("read secure state file");
965
+ assert!(!raw.is_empty());
966
+
967
+ let (reopened, loaded_state) = EncryptedStateStore::open_or_initialize(
968
+ "vault-password",
969
+ &crate::DaemonConfig::default(),
970
+ PersistentStoreConfig::new(nested.clone()),
971
+ )
972
+ .expect("reopen saved state");
973
+ assert_eq!(reopened.path, nested);
974
+ assert_eq!(loaded_state, state);
975
+
976
+ std::fs::remove_dir_all(root).expect("cleanup");
977
+ }
978
+ }