Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
872 souborů
core-20261030T220507Z-61e4558d889f
Přidáno: · Sestaveno:
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.9
use crate::core::error::{CoreError, CoreResult};10
use crate::core::storage::atomic_write_json;11
use aes_gcm::aead::{Aead, KeyInit, Payload};12
use aes_gcm::{Aes256Gcm, Nonce};13
use base64::engine::general_purpose::STANDARD as B64;14
use base64::Engine;15
use parking_lot::Mutex;16
use rand::RngCore;17
use serde_json::{json, Value};18
use sha2::{Digest, Sha256};19
use std::fs;20
use std::path::{Path, PathBuf};21
use zeroize::{Zeroize, Zeroizing};23
pub const KEY_LENGTH: usize = 32;24
const SALT_LENGTH: usize = 16;25
const IV_LENGTH: usize = 12;26
const TAG_LENGTH: usize = 16;27
const AAD: &[u8] = b"CHJ_CORE_VAULT_V1";28
const SCRYPT_N: u64 = 1 << 15;29
const SCRYPT_R: u32 = 8;30
const SCRYPT_P: u32 = 1;31
pub const MIN_PASSWORD_LENGTH: usize = 4;32
pub const MAX_PASSWORD_LENGTH: usize = 64;34
pub type VaultKey = Zeroizing<[u8; KEY_LENGTH]>;36
pub fn initial_vault() -> Value {37
json!({ "schemaVersion": 1, "profiles": [], "hostKeys": {}, "secrets": {} })38
}40
pub 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())51
}53
pub 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)68
}70
fn 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, ¶ms, key.as_mut()).map_err(|_| CoreError::plain("Key derivation failed."))?;78
Ok(key)79
}81
#[derive(Default)]82
struct Session {83
key: Option<VaultKey>,84
data: Option<Value>,85
}87
pub 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<()>,95
}97
#[derive(serde::Serialize, Clone, Debug)]98
pub struct VaultStatus {99
pub initialized: bool,100
#[serde(rename = "needsSetup")]101
pub needs_setup: bool,102
pub damaged: bool,103
pub unlocked: bool,104
}106
fn locked_error() -> CoreError {107
CoreError::new("VAULT_LOCKED", "Vault is locked.")108
}110
impl 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
.await173
.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
.await230
.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
.await258
.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(©) {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 = cipher358
.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
result374
}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
cipher392
.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
}405
}407
#[cfg(test)]408
mod 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
}447
}SHA-256
148b3276bd31b5eb751559bd3723042b421960d9c397bbfd7b1ed76e95e583ecCH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df