@wlfi-agent/cli 1.4.17 → 1.4.19
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.
- package/Cargo.lock +5 -0
- package/README.md +61 -28
- package/crates/vault-cli-admin/src/io_utils.rs +149 -1
- package/crates/vault-cli-admin/src/main.rs +639 -16
- package/crates/vault-cli-admin/src/shared_config.rs +18 -18
- package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
- package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
- package/crates/vault-cli-admin/src/tui.rs +1205 -120
- package/crates/vault-cli-agent/Cargo.toml +1 -0
- package/crates/vault-cli-agent/src/io_utils.rs +163 -2
- package/crates/vault-cli-agent/src/main.rs +648 -32
- package/crates/vault-cli-daemon/Cargo.toml +4 -0
- package/crates/vault-cli-daemon/src/main.rs +617 -67
- package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
- package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
- package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
- package/crates/vault-daemon/src/persistence.rs +637 -100
- package/crates/vault-daemon/src/tests.rs +1013 -3
- package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
- package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
- package/crates/vault-domain/src/nonce.rs +4 -0
- package/crates/vault-domain/src/tests.rs +616 -0
- package/crates/vault-policy/src/engine.rs +55 -32
- package/crates/vault-policy/src/tests.rs +195 -0
- package/crates/vault-sdk-agent/src/lib.rs +415 -22
- package/crates/vault-signer/Cargo.toml +3 -0
- package/crates/vault-signer/src/lib.rs +266 -40
- package/crates/vault-transport-unix/src/lib.rs +653 -5
- package/crates/vault-transport-xpc/src/tests.rs +531 -3
- package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
- package/dist/cli.cjs +756 -194
- package/dist/cli.cjs.map +1 -1
- package/package.json +5 -2
- package/packages/cache/.turbo/turbo-build.log +20 -20
- package/packages/cache/coverage/clover.xml +529 -394
- package/packages/cache/coverage/coverage-final.json +2 -2
- package/packages/cache/coverage/index.html +21 -21
- package/packages/cache/coverage/src/client/index.html +1 -1
- package/packages/cache/coverage/src/client/index.ts.html +1 -1
- package/packages/cache/coverage/src/errors/index.html +1 -1
- package/packages/cache/coverage/src/errors/index.ts.html +12 -12
- package/packages/cache/coverage/src/index.html +1 -1
- package/packages/cache/coverage/src/index.ts.html +1 -1
- package/packages/cache/coverage/src/service/index.html +21 -21
- package/packages/cache/coverage/src/service/index.ts.html +769 -313
- package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
- package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
- package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
- package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
- package/packages/cache/dist/index.cjs +2 -2
- package/packages/cache/dist/index.js +1 -1
- package/packages/cache/dist/service/index.cjs +2 -2
- package/packages/cache/dist/service/index.js +1 -1
- package/packages/cache/node_modules/.bin/tsc +2 -2
- package/packages/cache/node_modules/.bin/tsserver +2 -2
- package/packages/cache/node_modules/.bin/tsup +2 -2
- package/packages/cache/node_modules/.bin/tsup-node +2 -2
- package/packages/cache/node_modules/.bin/vitest +4 -4
- package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/packages/cache/src/service/index.test.ts +165 -19
- package/packages/cache/src/service/index.ts +38 -1
- package/packages/config/.turbo/turbo-build.log +4 -4
- package/packages/config/dist/index.cjs +0 -17
- package/packages/config/dist/index.cjs.map +1 -1
- package/packages/config/src/index.ts +0 -17
- package/packages/rpc/.turbo/turbo-build.log +11 -11
- package/packages/rpc/dist/index.cjs +0 -17
- package/packages/rpc/dist/index.cjs.map +1 -1
- package/packages/rpc/src/index.js +1 -0
- package/packages/ui/node_modules/.bin/tsc +2 -2
- package/packages/ui/node_modules/.bin/tsserver +2 -2
- package/packages/ui/node_modules/.bin/tsup +2 -2
- package/packages/ui/node_modules/.bin/tsup-node +2 -2
- package/scripts/install-cli-launcher.mjs +37 -0
- package/scripts/install-rust-binaries.mjs +47 -0
- package/scripts/run-tests-isolated.mjs +210 -0
- package/src/cli.ts +310 -50
- package/src/lib/admin-reset.ts +101 -33
- package/src/lib/admin-setup.ts +285 -55
- package/src/lib/agent-auth-migrate.ts +5 -1
- package/src/lib/asset-broadcast.ts +15 -4
- package/src/lib/config-amounts.ts +6 -4
- package/src/lib/hidden-tty-prompt.js +1 -0
- package/src/lib/hidden-tty-prompt.ts +105 -0
- package/src/lib/keychain.ts +1 -0
- package/src/lib/local-admin-access.ts +4 -29
- package/src/lib/rust.ts +129 -33
- package/src/lib/signed-tx.ts +1 -0
- package/src/lib/sudo.ts +15 -5
- package/src/lib/wallet-profile.ts +3 -0
- package/src/lib/wallet-setup.ts +52 -0
- package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
- 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 {
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
|
172
|
-
let
|
|
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(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
}
|