CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
vault.rs Hinzugefügt: Datum nicht erfasst 17,4 KB · 447 Zeilen
Code Datei herunterladen
1//! Encrypted local vault — byte-compatible port of `security/vaultStore.js`.
2//!
3//! Format (unchanged, so the 0.1.0 master password keeps working):
4//! * `vault/core-v1.meta.json`: scrypt parameters (N=32768, r=8, p=1, 32 B key) and a 16 B salt.
5//! * `vault/core-v1.data.json`: AES-256-GCM, 12 B IV, 16 B tag, AAD `CHJ_CORE_VAULT_V1`,
6//! plaintext = compact JSON `{schemaVersion:1, profiles, hostKeys, secrets}`.
7//! The key exists only in Core memory and is zeroized on lock.
9use crate::core::error::{CoreError, CoreResult};
10use crate::core::storage::atomic_write_json;
11use aes_gcm::aead::{Aead, KeyInit, Payload};
12use aes_gcm::{Aes256Gcm, Nonce};
13use base64::engine::general_purpose::STANDARD as B64;
14use base64::Engine;
15use parking_lot::Mutex;
16use rand::RngCore;
17use serde_json::{json, Value};
18use sha2::{Digest, Sha256};
19use std::fs;
20use std::path::{Path, PathBuf};
21use zeroize::{Zeroize, Zeroizing};
23pub const KEY_LENGTH: usize = 32;
24const SALT_LENGTH: usize = 16;
25const IV_LENGTH: usize = 12;
26const TAG_LENGTH: usize = 16;
27const AAD: &[u8] = b"CHJ_CORE_VAULT_V1";
28const SCRYPT_N: u64 = 1 << 15;
29const SCRYPT_R: u32 = 8;
30const SCRYPT_P: u32 = 1;
31pub const MIN_PASSWORD_LENGTH: usize = 4;
32pub const MAX_PASSWORD_LENGTH: usize = 64;
34pub type VaultKey = Zeroizing<[u8; KEY_LENGTH]>;
36pub fn initial_vault() -> Value {
37 json!({ "schemaVersion": 1, "profiles": [], "hostKeys": {}, "secrets": {} })
40pub fn validate_password(password: &str) -> CoreResult<String> {
41 if password.contains('\0') {
42 return Err(CoreError::plain("Password contains an invalid character."));
43 }
44 let length = password.chars().count();
45 if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) {
46 return Err(CoreError::plain(format!(
47 "Vault password must contain {MIN_PASSWORD_LENGTH} to {MAX_PASSWORD_LENGTH} characters."
48 )));
49 }
50 Ok(password.to_string())
53pub fn validate_vault_data(mut value: Value) -> CoreResult<Value> {
54 let object = value.as_object_mut().ok_or_else(|| CoreError::plain("Vault data is invalid."))?;
55 if object.get("schemaVersion").and_then(Value::as_f64) != Some(1.0) {
56 return Err(CoreError::plain("Unsupported vault data schema."));
57 }
58 if !object.get("profiles").map(Value::is_array).unwrap_or(false) {
59 return Err(CoreError::plain("Vault profiles are invalid."));
60 }
61 if !object.get("hostKeys").map(Value::is_object).unwrap_or(false) {
62 return Err(CoreError::plain("Vault host keys are invalid."));
63 }
64 if !object.get("secrets").map(Value::is_object).unwrap_or(false) {
65 object.insert("secrets".into(), json!({}));
66 }
67 Ok(value)
70fn derive_key(password: &str, salt: &[u8], n: u64, r: u32, p: u32) -> CoreResult<VaultKey> {
71 if n < 2 || !n.is_power_of_two() || n > (1 << 20) || r == 0 || r > 32 || p == 0 || p > 16 {
72 return Err(CoreError::plain("Unsupported vault KDF parameters."));
73 }
74 let log_n = n.trailing_zeros() as u8;
75 let params = scrypt::Params::new(log_n, r, p, KEY_LENGTH).map_err(|_| CoreError::plain("Unsupported vault KDF parameters."))?;
76 let mut key: VaultKey = Zeroizing::new([0u8; KEY_LENGTH]);
77 scrypt::scrypt(password.as_bytes(), salt, &params, key.as_mut()).map_err(|_| CoreError::plain("Key derivation failed."))?;
78 Ok(key)
81#[derive(Default)]
82struct Session {
83 key: Option<VaultKey>,
84 data: Option<Value>,
87pub struct VaultStore {
88 root: PathBuf,
89 meta_path: PathBuf,
90 data_path: PathBuf,
91 session: Mutex<Session>,
92 generation: Mutex<u64>,
93 /// Serializes read-modify-write updates: two concurrent SSH connects must not lose a trusted host key.
94 update_lock: Mutex<()>,
97#[derive(serde::Serialize, Clone, Debug)]
98pub struct VaultStatus {
99 pub initialized: bool,
100 #[serde(rename = "needsSetup")]
101 pub needs_setup: bool,
102 pub damaged: bool,
103 pub unlocked: bool,
106fn locked_error() -> CoreError {
107 CoreError::new("VAULT_LOCKED", "Vault is locked.")
110impl VaultStore {
111 pub fn new(storage_root: &Path) -> Self {
112 let root = storage_root.join("vault");
113 Self {
114 update_lock: Mutex::new(()),
115 meta_path: root.join("core-v1.meta.json"),
116 data_path: root.join("core-v1.data.json"),
117 root,
118 session: Mutex::new(Session::default()),
119 generation: Mutex::new(0),
120 }
121 }
123 pub fn generation(&self) -> u64 {
124 *self.generation.lock()
125 }
127 pub fn status(&self) -> VaultStatus {
128 let meta = self.meta_path.exists();
129 let data = self.data_path.exists();
130 let session = self.session.lock();
131 VaultStatus {
132 initialized: meta && data,
133 needs_setup: !meta && !data,
134 damaged: meta != data,
135 unlocked: session.key.is_some() && session.data.is_some(),
136 }
137 }
139 pub fn is_unlocked(&self) -> bool {
140 let session = self.session.lock();
141 session.key.is_some() && session.data.is_some()
142 }
144 fn read_meta(&self) -> CoreResult<Value> {
145 Ok(serde_json::from_str(&fs::read_to_string(&self.meta_path)?)?)
146 }
148 /// Stable identity of this vault instance (same formula as 0.1.0, used by biometric enrollment).
149 pub fn instance_id(&self) -> Option<String> {
150 if !self.status().initialized {
151 return None;
152 }
153 let meta = self.read_meta().ok()?;
154 let identity = json!([meta.get("formatVersion"), meta.get("createdAt"), meta.get("salt")]);
155 let text = serde_json::to_string(&identity).ok()?;
156 Some(hex::encode(Sha256::digest(text.as_bytes())))
157 }
159 pub async fn create(&self, password: &str) -> CoreResult<VaultStatus> {
160 let generation = self.generation();
161 let status = self.status();
162 if status.initialized {
163 return Err(CoreError::plain("Vault is already initialized."));
164 }
165 if status.damaged {
166 return Err(CoreError::plain("Vault files are incomplete. Restore or remove them before setup."));
167 }
168 let normalized = validate_password(password)?;
169 let mut salt = [0u8; SALT_LENGTH];
170 rand::rngs::OsRng.fill_bytes(&mut salt);
171 let key = tokio::task::spawn_blocking(move || derive_key(&normalized, &salt, SCRYPT_N, SCRYPT_R, SCRYPT_P))
172 .await
173 .map_err(|_| CoreError::plain("Key derivation failed."))??;
174 if generation != self.generation() || !self.status().needs_setup {
175 return Err(CoreError::plain("Vault operation cancelled."));
176 }
177 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
178 let data = initial_vault();
179 let result = (|| -> CoreResult<()> {
180 atomic_write_json(
181 &self.meta_path,
182 &json!({
183 "formatVersion": 1,
184 "dataSchemaVersion": 1,
185 "createdAt": now,
186 "updatedAt": now,
187 "cipher": "aes-256-gcm",
188 "kdf": { "name": "scrypt", "N": SCRYPT_N, "r": SCRYPT_R, "p": SCRYPT_P, "keyLength": KEY_LENGTH },
189 "salt": B64.encode(salt)
190 }),
191 )?;
192 self.write_encrypted(&data, &key)
193 })();
194 if let Err(error) = result {
195 let _ = fs::remove_file(&self.meta_path);
196 let _ = fs::remove_file(&self.data_path);
197 return Err(error);
198 }
199 self.set_session(key, data);
200 Ok(self.status())
201 }
203 fn kdf_from_meta(meta: &Value) -> CoreResult<(Vec<u8>, u64, u32, u32)> {
204 if meta.get("formatVersion").and_then(Value::as_f64) != Some(1.0)
205 || meta.get("cipher").and_then(Value::as_str) != Some("aes-256-gcm")
206 || meta.pointer("/kdf/name").and_then(Value::as_str) != Some("scrypt")
207 {
208 return Err(CoreError::plain("Unsupported vault format."));
209 }
210 let salt = B64.decode(meta.get("salt").and_then(Value::as_str).unwrap_or("")).map_err(|_| CoreError::plain("Vault salt is invalid."))?;
211 if salt.len() != SALT_LENGTH {
212 return Err(CoreError::plain("Vault salt is invalid."));
213 }
214 let num = |key: &str, fallback: u64| meta.pointer(&format!("/kdf/{key}")).and_then(Value::as_u64).filter(|v| *v > 0).unwrap_or(fallback);
215 Ok((salt, num("N", SCRYPT_N), num("r", SCRYPT_R as u64) as u32, num("p", SCRYPT_P as u64) as u32))
216 }
218 pub async fn unlock(&self, password: &str) -> CoreResult<VaultStatus> {
219 let generation = self.generation();
220 let instance = self.instance_id();
221 if !self.status().initialized {
222 return Err(CoreError::plain("Vault setup is required or its files are incomplete."));
223 }
224 let normalized = validate_password(password)?;
225 let failure = || CoreError::new("VAULT_UNLOCK_FAILED", "Vault password is incorrect or the vault is damaged.");
226 let meta = self.read_meta().map_err(|_| failure())?;
227 let (salt, n, r, p) = Self::kdf_from_meta(&meta).map_err(|_| failure())?;
228 let key = tokio::task::spawn_blocking(move || derive_key(&normalized, &salt, n, r, p))
229 .await
230 .map_err(|_| failure())?
231 .map_err(|_| failure())?;
232 if generation != self.generation() || instance != self.instance_id() {
233 return Err(failure());
234 }
235 let data = self.read_encrypted(&key).map_err(|_| failure())?;
236 self.set_session(key, data);
237 Ok(self.status())
238 }
240 /// Copy of the unlock key for a Core-internal operation (biometric enrollment). Never exposed over IPC.
241 pub fn unlock_key_copy(&self) -> CoreResult<VaultKey> {
242 let session = self.session.lock();
243 match (&session.key, &session.data) {
244 (Some(key), Some(_)) => Ok(Zeroizing::new(**key)),
245 _ => Err(locked_error()),
246 }
247 }
249 pub async fn verify_password(&self, password: &str) -> CoreResult<bool> {
250 self.assert_unlocked()?;
251 let generation = self.generation();
252 let required = || CoreError::new("BIOMETRIC_MASTER_PASSWORD_REQUIRED", "Master password required.");
253 let normalized = validate_password(password)?;
254 let meta = self.read_meta()?;
255 let (salt, n, r, p) = Self::kdf_from_meta(&meta)?;
256 let key = tokio::task::spawn_blocking(move || derive_key(&normalized, &salt, n, r, p))
257 .await
258 .map_err(|_| required())??;
259 self.assert_unlocked()?;
260 if generation != self.generation() {
261 return Err(required());
262 }
263 let current = self.unlock_key_copy()?;
264 if !bool::from(subtle::ConstantTimeEq::ct_eq(key.as_ref(), current.as_ref())) {
265 return Err(required());
266 }
267 self.read_encrypted(&key)?;
268 Ok(true)
269 }
271 pub fn unlock_with_key(&self, key: &[u8]) -> CoreResult<VaultStatus> {
272 if !self.status().initialized || key.len() != KEY_LENGTH {
273 return Err(CoreError::new("BIOMETRIC_INVALID_KEY", "Invalid protected unlocking key."));
274 }
275 let mut copy: VaultKey = Zeroizing::new([0u8; KEY_LENGTH]);
276 copy.copy_from_slice(key);
277 match self.read_encrypted(&copy) {
278 Ok(data) => {
279 self.set_session(copy, data);
280 Ok(self.status())
281 }
282 Err(_) => Err(CoreError::new(
283 "BIOMETRIC_ENROLLMENT_INVALIDATED",
284 "Biometric enrollment is no longer valid. Use the master password.",
285 )),
286 }
287 }
289 pub fn lock(&self) -> VaultStatus {
290 *self.generation.lock() += 1;
291 {
292 let mut session = self.session.lock();
293 session.key = None; // Zeroizing wipes the key on drop.
294 session.data = None;
295 }
296 self.status()
297 }
299 pub fn reset(&self, confirmation: &str) -> CoreResult<VaultStatus> {
300 if confirmation != "SMAZAT" {
301 return Err(CoreError::new("VAULT_RESET_CONFIRMATION_REQUIRED", "Vault reset confirmation is invalid."));
302 }
303 self.lock();
304 if self.root.exists() {
305 fs::remove_dir_all(&self.root)?;
306 }
307 let status = self.status();
308 if !status.needs_setup {
309 return Err(CoreError::new("VAULT_RESET_FAILED", "Vault data could not be removed completely."));
310 }
311 Ok(status)
312 }
314 pub fn assert_unlocked(&self) -> CoreResult<()> {
315 if self.is_unlocked() {
316 Ok(())
317 } else {
318 Err(locked_error())
319 }
320 }
322 pub fn get_data(&self) -> CoreResult<Value> {
323 let session = self.session.lock();
324 match (&session.key, &session.data) {
325 (Some(_), Some(data)) => Ok(data.clone()),
326 _ => Err(locked_error()),
327 }
328 }
330 pub fn replace_data(&self, next: Value) -> CoreResult<Value> {
331 let normalized = validate_vault_data(next)?;
332 let mut session = self.session.lock();
333 let key = match (&session.key, &session.data) {
334 (Some(key), Some(_)) => Zeroizing::new(**key),
335 _ => return Err(locked_error()),
336 };
337 self.write_encrypted(&normalized, &key)?;
338 session.data = Some(normalized.clone());
339 Ok(normalized)
340 }
342 /// Mutator → encrypt → commit, like `VaultStore.update` in 0.1.0.
343 pub fn update<R>(&self, mutator: impl FnOnce(&mut Value) -> CoreResult<R>) -> CoreResult<R> {
344 let _update = self.update_lock.lock();
345 let mut draft = self.get_data()?;
346 let result = mutator(&mut draft)?;
347 self.replace_data(draft)?;
348 Ok(result)
349 }
351 fn write_encrypted(&self, data: &Value, key: &[u8; KEY_LENGTH]) -> CoreResult<()> {
352 let data = validate_vault_data(data.clone())?;
353 let mut plaintext = Zeroizing::new(serde_json::to_vec(&data)?);
354 let mut iv = [0u8; IV_LENGTH];
355 rand::rngs::OsRng.fill_bytes(&mut iv);
356 let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| CoreError::plain("Invalid vault key."))?;
357 let mut sealed = cipher
358 .encrypt(Nonce::from_slice(&iv), Payload { msg: &plaintext, aad: AAD })
359 .map_err(|_| CoreError::plain("Vault encryption failed."))?;
360 plaintext.zeroize();
361 let tag = sealed.split_off(sealed.len() - TAG_LENGTH);
362 let result = atomic_write_json(
363 &self.data_path,
364 &json!({
365 "formatVersion": 1,
366 "updatedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
367 "iv": B64.encode(iv),
368 "authTag": B64.encode(&tag),
369 "ciphertext": B64.encode(&sealed)
370 }),
371 );
372 sealed.zeroize();
373 result
374 }
376 fn read_encrypted(&self, key: &[u8; KEY_LENGTH]) -> CoreResult<Value> {
377 let blob: Value = serde_json::from_str(&fs::read_to_string(&self.data_path)?)?;
378 if blob.get("formatVersion").and_then(Value::as_f64) != Some(1.0) {
379 return Err(CoreError::plain("Unsupported encrypted vault data."));
380 }
381 let field = |name: &str| B64.decode(blob.get(name).and_then(Value::as_str).unwrap_or("")).unwrap_or_default();
382 let iv = field("iv");
383 let tag = field("authTag");
384 let mut ciphertext = field("ciphertext");
385 if iv.len() != IV_LENGTH || tag.len() != TAG_LENGTH || ciphertext.is_empty() {
386 return Err(CoreError::plain("Encrypted vault data is invalid."));
387 }
388 ciphertext.extend_from_slice(&tag);
389 let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| CoreError::plain("Invalid vault key."))?;
390 let plaintext = Zeroizing::new(
391 cipher
392 .decrypt(Nonce::from_slice(&iv), Payload { msg: &ciphertext, aad: AAD })
393 .map_err(|_| CoreError::plain("Vault authentication failed."))?,
394 );
395 ciphertext.zeroize();
396 validate_vault_data(serde_json::from_slice(&plaintext)?)
397 }
399 fn set_session(&self, key: VaultKey, data: Value) {
400 self.lock();
401 let mut session = self.session.lock();
402 session.key = Some(key);
403 session.data = Some(data);
404 }
407#[cfg(test)]
408mod tests {
409 use super::*;
411 #[tokio::test]
412 async fn create_lock_unlock_roundtrip() {
413 let dir = tempfile::tempdir().unwrap();
414 let vault = VaultStore::new(dir.path());
415 assert!(vault.status().needs_setup);
416 vault.create("heslo123").await.unwrap();
417 vault.update(|data| {
418 data["profiles"].as_array_mut().unwrap().push(json!({"id": "p1"}));
419 Ok(())
420 })
421 .unwrap();
422 vault.lock();
423 assert!(vault.get_data().is_err());
424 assert_eq!(vault.unlock("wrong-pass").await.unwrap_err().code, "VAULT_UNLOCK_FAILED");
425 vault.unlock("heslo123").await.unwrap();
426 assert_eq!(vault.get_data().unwrap()["profiles"][0]["id"], "p1");
427 assert!(vault.verify_password("heslo123").await.unwrap());
428 assert!(vault.verify_password("jine-heslo").await.is_err());
429 }
431 #[tokio::test]
432 async fn password_rules_count_code_points() {
433 assert!(validate_password("ččč").is_err());
434 assert!(validate_password("čččč").is_ok());
435 assert!(validate_password(&"a".repeat(65)).is_err());
436 assert!(validate_password("ab\0c").is_err());
437 }
439 #[tokio::test]
440 async fn reset_requires_confirmation() {
441 let dir = tempfile::tempdir().unwrap();
442 let vault = VaultStore::new(dir.path());
443 vault.create("heslo123").await.unwrap();
444 assert!(vault.reset("DELETE").is_err());
445 assert!(vault.reset("SMAZAT").unwrap().needs_setup);
446 }
SHA-256148b3276bd31b5eb751559bd3723042b421960d9c397bbfd7b1ed76e95e583ec

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien