المصدر المنشور
تنزيل الشيفرة المصدرية بصيغة ZIP
CH-J Tools
تصفّح الأدلة والملفات الخاصة بإصدار محدد من التطبيق.
CH-J / Server Manager
1704 ملفات
0.1.2-beta
تاريخ الإضافة: · تاريخ البناء:
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
generation: u64,84
// Authenticated host trust is retained in RAM across lock, never written outside the vault.85
host_keys: serde_json::Map<String, Value>,86
key: Option<VaultKey>,87
data: Option<Value>,88
}90
pub struct VaultStore {91
root: PathBuf,92
meta_path: PathBuf,93
data_path: PathBuf,94
session: Mutex<Session>,95
/// Serializes read-modify-write updates: two concurrent SSH connects must not lose a trusted host key.96
update_lock: Mutex<()>,97
}99
#[derive(serde::Serialize, Clone, Debug)]100
pub struct VaultStatus {101
pub initialized: bool,102
#[serde(rename = "needsSetup")]103
pub needs_setup: bool,104
pub damaged: bool,105
pub unlocked: bool,106
}108
fn locked_error() -> CoreError {109
CoreError::new("VAULT_LOCKED", "Vault is locked.")110
}112
impl VaultStore {113
pub fn new(storage_root: &Path) -> Self {114
let root = storage_root.join("vault");115
Self {116
update_lock: Mutex::new(()),117
meta_path: root.join("core-v1.meta.json"),118
data_path: root.join("core-v1.data.json"),119
root,120
session: Mutex::new(Session::default()),121
}122
}124
pub fn generation(&self) -> u64 {125
self.session.lock().generation126
}128
pub fn status(&self) -> VaultStatus {129
let meta = self.meta_path.exists();130
let data = self.data_path.exists();131
let session = self.session.lock();132
VaultStatus {133
initialized: meta && data,134
needs_setup: !meta && !data,135
damaged: meta != data,136
unlocked: session.key.is_some() && session.data.is_some(),137
}138
}140
pub fn is_unlocked(&self) -> bool {141
let session = self.session.lock();142
session.key.is_some() && session.data.is_some()143
}145
fn read_meta(&self) -> CoreResult<Value> {146
Ok(serde_json::from_str(&fs::read_to_string(&self.meta_path)?)?)147
}149
/// Stable identity of this vault instance (same formula as 0.1.0, used by biometric enrollment).150
pub fn instance_id(&self) -> Option<String> {151
if !self.status().initialized {152
return None;153
}154
let meta = self.read_meta().ok()?;155
Self::meta_identity(&meta)156
}158
fn meta_identity(meta: &Value) -> Option<String> {159
let identity = json!([meta.get("formatVersion"), meta.get("createdAt"), meta.get("salt")]);160
let text = serde_json::to_string(&identity).ok()?;161
Some(hex::encode(Sha256::digest(text.as_bytes())))162
}164
pub async fn create(&self, password: &str) -> CoreResult<VaultStatus> {165
let generation = self.generation();166
let status = self.status();167
if status.initialized {168
return Err(CoreError::plain("Vault is already initialized."));169
}170
if status.damaged {171
return Err(CoreError::plain("Vault files are incomplete. Restore or remove them before setup."));172
}173
let normalized = validate_password(password)?;174
let mut salt = [0u8; SALT_LENGTH];175
rand::rngs::OsRng.fill_bytes(&mut salt);176
let key = tokio::task::spawn_blocking(move || derive_key(&normalized, &salt, SCRYPT_N, SCRYPT_R, SCRYPT_P))177
.await178
.map_err(|_| CoreError::plain("Key derivation failed."))??;179
let mut session = self.session.lock();180
if generation != session.generation || self.meta_path.exists() || self.data_path.exists() {181
return Err(CoreError::plain("Vault operation cancelled."));182
}183
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);184
let data = initial_vault();185
let result = (|| -> CoreResult<()> {186
atomic_write_json(187
&self.meta_path,188
&json!({189
"formatVersion": 1,190
"dataSchemaVersion": 1,191
"createdAt": now,192
"updatedAt": now,193
"cipher": "aes-256-gcm",194
"kdf": { "name": "scrypt", "N": SCRYPT_N, "r": SCRYPT_R, "p": SCRYPT_P, "keyLength": KEY_LENGTH },195
"salt": B64.encode(salt)196
}),197
)?;198
self.write_encrypted(&data, &key)199
})();200
if let Err(error) = result {201
let _ = fs::remove_file(&self.meta_path);202
let _ = fs::remove_file(&self.data_path);203
return Err(error);204
}205
Self::set_session(&mut session, key, data);206
drop(session);207
Ok(self.status())208
}210
fn kdf_from_meta(meta: &Value) -> CoreResult<(Vec<u8>, u64, u32, u32)> {211
if meta.get("formatVersion").and_then(Value::as_f64) != Some(1.0)212
|| meta.get("cipher").and_then(Value::as_str) != Some("aes-256-gcm")213
|| meta.pointer("/kdf/name").and_then(Value::as_str) != Some("scrypt")214
{215
return Err(CoreError::plain("Unsupported vault format."));216
}217
let salt = B64.decode(meta.get("salt").and_then(Value::as_str).unwrap_or("")).map_err(|_| CoreError::plain("Vault salt is invalid."))?;218
if salt.len() != SALT_LENGTH {219
return Err(CoreError::plain("Vault salt is invalid."));220
}221
let num = |key: &str, fallback: u64| meta.pointer(&format!("/kdf/{key}")).and_then(Value::as_u64).filter(|v| *v > 0).unwrap_or(fallback);222
Ok((salt, num("N", SCRYPT_N), num("r", SCRYPT_R as u64) as u32, num("p", SCRYPT_P as u64) as u32))223
}225
pub async fn unlock(&self, password: &str) -> CoreResult<VaultStatus> {226
let generation = self.generation();227
let instance = self.instance_id();228
if !self.status().initialized {229
return Err(CoreError::plain("Vault setup is required or its files are incomplete."));230
}231
let normalized = validate_password(password)?;232
let failure = || CoreError::new("VAULT_UNLOCK_FAILED", "Vault password is incorrect or the vault is damaged.");233
let meta = self.read_meta().map_err(|_| failure())?;234
let (salt, n, r, p) = Self::kdf_from_meta(&meta).map_err(|_| failure())?;235
let key = tokio::task::spawn_blocking(move || derive_key(&normalized, &salt, n, r, p))236
.await237
.map_err(|_| failure())?238
.map_err(|_| failure())?;239
self.unlock_key_at_generation(key, generation, instance).map_err(|_| failure())240
}242
/// Copy of the unlock key for a Core-internal operation (biometric enrollment). Never exposed over IPC.243
pub fn unlock_key_copy(&self) -> CoreResult<VaultKey> {244
let session = self.session.lock();245
match (&session.key, &session.data) {246
(Some(key), Some(_)) => Ok(Zeroizing::new(**key)),247
_ => Err(locked_error()),248
}249
}251
pub async fn verify_password(&self, password: &str) -> CoreResult<bool> {252
self.assert_unlocked()?;253
let generation = self.generation();254
let required = || CoreError::new("BIOMETRIC_MASTER_PASSWORD_REQUIRED", "Master password required.");255
let normalized = validate_password(password)?;256
let meta = self.read_meta()?;257
let (salt, n, r, p) = Self::kdf_from_meta(&meta)?;258
let key = tokio::task::spawn_blocking(move || derive_key(&normalized, &salt, n, r, p))259
.await260
.map_err(|_| required())??;261
let session = self.session.lock();262
let current = session.key.as_ref().filter(|_| session.data.is_some()).ok_or_else(required)?;263
if generation != session.generation || !bool::from(subtle::ConstantTimeEq::ct_eq(&key[..], ¤t[..])) {264
return Err(required());265
}266
self.read_encrypted(&key)?;267
Ok(true)268
}270
pub fn unlock_with_key(&self, key: &[u8]) -> CoreResult<VaultStatus> {271
self.unlock_with_key_at_generation(key, self.generation())272
}274
pub fn unlock_with_key_at_generation(&self, key: &[u8], generation: u64) -> CoreResult<VaultStatus> {275
if !self.status().initialized || key.len() != KEY_LENGTH {276
return Err(CoreError::new("BIOMETRIC_INVALID_KEY", "Invalid protected unlocking key."));277
}278
let instance = self.instance_id();279
let mut copy: VaultKey = Zeroizing::new([0u8; KEY_LENGTH]);280
copy.copy_from_slice(key);281
self.unlock_key_at_generation(copy, generation, instance)282
}284
fn unlock_key_at_generation(&self, key: VaultKey, generation: u64, instance: Option<String>) -> CoreResult<VaultStatus> {285
// Check and decrypt/commit under the same mutex used by lock/reset. A completed286
// lock cannot be undone by a KDF or biometric retrieval that started earlier.287
let mut session = self.session.lock();288
let current_instance = self.read_meta().ok().and_then(|meta| Self::meta_identity(&meta));289
if generation != session.generation || instance.is_none() || instance != current_instance {290
return Err(locked_error());291
}292
let data = self.read_encrypted(&key).map_err(|_| CoreError::new(293
"BIOMETRIC_ENROLLMENT_INVALIDATED", "Biometric enrollment is no longer valid. Use the master password.",294
))?;295
Self::set_session(&mut session, key, data);296
drop(session);297
Ok(self.status())298
}300
fn clear_session(session: &mut Session) {301
session.generation += 1;302
session.key = None;303
session.data = None;304
}306
pub fn lock(&self) -> VaultStatus {307
Self::clear_session(&mut self.session.lock());308
self.status()309
}311
pub fn reset(&self, confirmation: &str) -> CoreResult<VaultStatus> {312
if confirmation != "SMAZAT" {313
return Err(CoreError::new("VAULT_RESET_CONFIRMATION_REQUIRED", "Vault reset confirmation is invalid."));314
}315
let mut session = self.session.lock();316
Self::clear_session(&mut session);317
session.host_keys.clear();318
if self.root.exists() {319
fs::remove_dir_all(&self.root)?;320
}321
drop(session);322
let status = self.status();323
if !status.needs_setup {324
return Err(CoreError::new("VAULT_RESET_FAILED", "Vault data could not be removed completely."));325
}326
Ok(status)327
}329
pub fn assert_unlocked(&self) -> CoreResult<()> {330
if self.is_unlocked() {331
Ok(())332
} else {333
Err(locked_error())334
}335
}337
pub fn get_data(&self) -> CoreResult<Value> {338
let session = self.session.lock();339
match (&session.key, &session.data) {340
(Some(_), Some(data)) => Ok(data.clone()),341
_ => Err(locked_error()),342
}343
}345
pub fn replace_data(&self, next: Value) -> CoreResult<Value> {346
self.replace_data_at_generation(next, self.generation())347
}349
fn replace_data_at_generation(&self, next: Value, generation: u64) -> CoreResult<Value> {350
let normalized = validate_vault_data(next)?;351
let mut session = self.session.lock();352
if session.generation != generation { return Err(locked_error()); }353
let key = match (&session.key, &session.data) {354
(Some(key), Some(_)) => Zeroizing::new(**key),355
_ => return Err(locked_error()),356
};357
self.write_encrypted(&normalized, &key)?;358
session.host_keys = normalized["hostKeys"].as_object().cloned().unwrap_or_default();359
session.data = Some(normalized.clone());360
Ok(normalized)361
}363
/// Mutator ← encrypt ← commit bound to a single unlocking epoch.364
pub fn update<R>(&self, mutator: impl FnOnce(&mut Value) -> CoreResult<R>) -> CoreResult<R> {365
self.update_at_generation(self.unlocked_generation()?, mutator)366
}368
pub fn unlocked_generation(&self) -> CoreResult<u64> {369
let session = self.session.lock();370
if session.key.is_none() || session.data.is_none() { return Err(locked_error()); }371
Ok(session.generation)372
}374
pub fn assert_generation(&self, generation: u64) -> CoreResult<()> {375
self.with_generation(generation, || Ok(()))376
}378
/// Runs a small synchronous final effect atomically with respect to lock/reset.379
/// The callback must not reenter VaultStore.380
pub fn with_generation<R>(&self, generation: u64, operation: impl FnOnce() -> CoreResult<R>) -> CoreResult<R> {381
let session = self.session.lock();382
if session.generation != generation || session.key.is_none() || session.data.is_none() { return Err(locked_error()); }383
operation()384
}386
/// A final local effect bound to an immutable snapshot. Must not reenter VaultStore.387
pub fn with_data_at_generation<R>(&self,generation:u64,operation:impl FnOnce(&Value)->CoreResult<R>)->CoreResult<R>{388
let session=self.session.lock();389
if session.generation!=generation || session.key.is_none(){return Err(locked_error())}390
operation(session.data.as_ref().ok_or_else(locked_error)?)391
}393
pub fn get_data_at_generation(&self, generation: u64) -> CoreResult<Value> {394
let session = self.session.lock();395
if session.generation != generation || session.key.is_none() { return Err(locked_error()); }396
session.data.clone().ok_or_else(locked_error)397
}399
pub fn update_at_generation<R>(&self, generation: u64, mutator: impl FnOnce(&mut Value) -> CoreResult<R>) -> CoreResult<R> {400
let _update = self.update_lock.lock();401
let mut draft = self.get_data_at_generation(generation)?;402
let result = mutator(&mut draft)?;403
self.replace_data_at_generation(draft, generation)?;404
Ok(result)405
}407
fn write_encrypted(&self, data: &Value, key: &[u8; KEY_LENGTH]) -> CoreResult<()> {408
let data = validate_vault_data(data.clone())?;409
let mut plaintext = Zeroizing::new(serde_json::to_vec(&data)?);410
let mut iv = [0u8; IV_LENGTH];411
rand::rngs::OsRng.fill_bytes(&mut iv);412
let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| CoreError::plain("Invalid vault key."))?;413
let mut sealed = cipher414
.encrypt(Nonce::from_slice(&iv), Payload { msg: &plaintext, aad: AAD })415
.map_err(|_| CoreError::plain("Vault encryption failed."))?;416
plaintext.zeroize();417
let tag = sealed.split_off(sealed.len() - TAG_LENGTH);418
let result = atomic_write_json(419
&self.data_path,420
&json!({421
"formatVersion": 1,422
"updatedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),423
"iv": B64.encode(iv),424
"authTag": B64.encode(&tag),425
"ciphertext": B64.encode(&sealed)426
}),427
);428
sealed.zeroize();429
result430
}432
fn read_encrypted(&self, key: &[u8; KEY_LENGTH]) -> CoreResult<Value> {433
let blob: Value = serde_json::from_str(&fs::read_to_string(&self.data_path)?)?;434
if blob.get("formatVersion").and_then(Value::as_f64) != Some(1.0) {435
return Err(CoreError::plain("Unsupported encrypted vault data."));436
}437
let field = |name: &str| B64.decode(blob.get(name).and_then(Value::as_str).unwrap_or("")).unwrap_or_default();438
let iv = field("iv");439
let tag = field("authTag");440
let mut ciphertext = field("ciphertext");441
if iv.len() != IV_LENGTH || tag.len() != TAG_LENGTH || ciphertext.is_empty() {442
return Err(CoreError::plain("Encrypted vault data is invalid."));443
}444
ciphertext.extend_from_slice(&tag);445
let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| CoreError::plain("Invalid vault key."))?;446
let plaintext = Zeroizing::new(447
cipher448
.decrypt(Nonce::from_slice(&iv), Payload { msg: &ciphertext, aad: AAD })449
.map_err(|_| CoreError::plain("Vault authentication failed."))?,450
);451
ciphertext.zeroize();452
validate_vault_data(serde_json::from_slice(&plaintext)?)453
}455
fn set_session(session: &mut Session, key: VaultKey, data: Value) {456
session.generation += 1;457
session.host_keys = data["hostKeys"].as_object().cloned().unwrap_or_default();458
session.key = Some(key);459
session.data = Some(data);460
}462
pub fn trusted_host_key(&self, id: &str) -> Option<String> {463
self.session.lock().host_keys.get(id).and_then(Value::as_str).map(str::to_string)464
}466
/// Outer None is for a deliberate Core-internal write; Some binds a user's467
/// confirmation to exactly the previously observed key, including unknown.468
pub fn trust_host_key(&self, id: &str, fingerprint: &str, expected: Option<Option<&str>>) -> CoreResult<bool> {469
let _update = self.update_lock.lock();470
let mut session = self.session.lock();471
if let Some(expected) = expected {472
if session.host_keys.get(id).and_then(Value::as_str) != expected {473
return Err(CoreError::new("HOST_KEY_REPLACEMENT_STALE", "The trusted host key changed while confirmation was pending."));474
}475
}476
let persistent = session.key.is_some() && session.data.is_some();477
if persistent {478
let mut data = session.data.as_ref().unwrap().clone();479
data["hostKeys"][id] = json!(fingerprint);480
self.write_encrypted(&data, session.key.as_ref().unwrap())?;481
session.data = Some(data);482
}483
session.host_keys.insert(id.to_string(), json!(fingerprint));484
Ok(persistent)485
}486
}488
#[cfg(test)]489
mod tests {490
use super::*;492
#[tokio::test]493
async fn create_lock_unlock_roundtrip() {494
let dir = tempfile::tempdir().unwrap();495
let vault = VaultStore::new(dir.path());496
assert!(vault.status().needs_setup);497
vault.create("heslo123").await.unwrap();498
vault.update(|data| {499
data["profiles"].as_array_mut().unwrap().push(json!({"id": "p1"}));500
Ok(())501
})502
.unwrap();503
vault.lock();504
assert!(vault.get_data().is_err());505
assert_eq!(vault.unlock("wrong-pass").await.unwrap_err().code, "VAULT_UNLOCK_FAILED");506
vault.unlock("heslo123").await.unwrap();507
assert_eq!(vault.get_data().unwrap()["profiles"][0]["id"], "p1");508
assert!(vault.verify_password("heslo123").await.unwrap());509
assert!(vault.verify_password("jine-heslo").await.is_err());510
}512
#[tokio::test]513
async fn password_rules_count_code_points() {514
assert!(validate_password("ččč").is_err());515
assert!(validate_password("čččč").is_ok());516
assert!(validate_password(&"a".repeat(65)).is_err());517
assert!(validate_password("ab\0c").is_err());518
}520
#[tokio::test]521
async fn late_unlock_and_biometric_commit_cannot_undo_lock() {522
let root = tempfile::tempdir().unwrap(); let vault = VaultStore::new(root.path());523
vault.create("fixture-password").await.unwrap();524
let generation = vault.generation(); let key = vault.unlock_key_copy().unwrap(); let instance = vault.instance_id();525
vault.lock();526
assert!(vault.unlock_key_at_generation(key.clone(), generation, instance).is_err());527
assert!(vault.unlock_with_key_at_generation(key.as_ref(), generation).is_err());528
assert!(!vault.is_unlocked());529
}531
#[tokio::test]532
async fn stale_mutator_cannot_overwrite_a_new_unlock_or_recreated_vault() {533
let root = tempfile::tempdir().unwrap(); let vault = VaultStore::new(root.path());534
vault.create("fixture-password").await.unwrap();535
let key = vault.unlock_key_copy().unwrap(); let stale_generation = vault.generation();536
let stale_data = vault.get_data().unwrap();537
let result = vault.update(|data| {538
data["secrets"]["stale"] = json!(true);539
vault.lock(); vault.unlock_with_key(key.as_ref())?;540
Ok(())541
});542
assert_eq!(result.unwrap_err().code, "VAULT_LOCKED");543
assert!(vault.get_data().unwrap()["secrets"]["stale"].is_null());544
vault.reset("SMAZAT").unwrap(); vault.create("new-fixture-password").await.unwrap();545
assert!(vault.replace_data_at_generation(stale_data, stale_generation).is_err());546
assert!(vault.update_at_generation(stale_generation, |_| Ok(())).is_err());547
assert!(vault.with_generation::<()>(stale_generation, || panic!("stale final effect ran")).is_err());548
}550
#[tokio::test]551
async fn reset_requires_confirmation() {552
let dir = tempfile::tempdir().unwrap();553
let vault = VaultStore::new(dir.path());554
vault.create("heslo123").await.unwrap();555
assert!(vault.reset("DELETE").is_err());556
assert!(vault.reset("SMAZAT").unwrap().needs_setup);557
}558
}SHA-256
09bb7e4e67c97c6404873fdb23e54e7fb4eb87507dc89abe3758ce26f26f9abbCH-J Proprietary Software License 1.14 · تُتاح الشيفرة المصدرية بموجب CH-J Proprietary Software License 1.14. وإتاحتها لا تغيّر شروط الترخيص ولا تمنح حقوقًا إضافية.
SHA-256 للأرشيف
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f