4
Vault dan Model Kriptografi
VaultManager adalah satu-satunya tempat key turunan disimpan di backend. Struct ini hanya punya satu field, derived_key: RwLock<Option<[u8; 32]>>, sehingga key ada di memori proses dan tidak pernah ditulis ke tabel mana pun selain salt dan verifier (src-tauri/src/vault/mod.rs:12-211). AppState memegangnya sebagai Arc<VaultManager> dan membuat satu instance saat aplikasi dijalankan (src-tauri/src/lib.rs:24-302).
Primitif kriptografi
| Lapisan | Implementasi | Nilai di kode |
|---|---|---|
| Key derivation | Argon2::default() lewat hash_password_into | output [u8; 32] |
| Salt | generate_salt() dengan OsRng.fill_bytes | 16 byte |
| Cipher | Aes256Gcm (aes-gcm) | key 32 byte |
| Nonce | OsRng.fill_bytes per operasi | 12 byte |
| Verifier | VERIFY_STRING = b"TERMIMUS_VAULT_OK" | 18 byte plaintext |
| Memory hygiene | zeroize pada lock | key.zeroize() |
derive_key memanggil Argon2::default().hash_password_into dengan password dan salt, lalu mengembalikan array 32 byte; salt 16 byte dibuat oleh generate_salt memakai OsRng (src-tauri/src/vault/mod.rs:87-1023). README menamai algoritma ini Argon2id dengan salt acak 16 byte (README.md:2174).
encrypt_with_key membuat Aes256Gcm dari key 32 byte, mengisi nonce_bytes 12 byte dari OsRng untuk setiap panggilan, lalu mengembalikan pasangan (ciphertext, nonce); decrypt_with_key menolak nonce yang panjangnya bukan 12 dengan pesan Invalid nonce length (src-tauri/src/vault/mod.rs:124-1555). Karena nonce dibuat per operasi, setiap item mendapat nonce acak sendiri. README menegaskan cipher terautentikasi dengan nonce 12 byte unik per item dari OsRng (README.md:2186).
Siklus hidup key
sequenceDiagram
autonumber
participant UI as useVaultStore
participant CMD as commands (tauri)
participant VM as VaultManager
participant DB as vault_meta
Note over UI,DB: Setup
UI->>CMD: vault_setup(password)
CMD->>VM: generate_salt()
CMD->>VM: derive_key(password, salt)
CMD->>DB: set_vault_meta("salt")
CMD->>VM: set_key(key)
CMD->>VM: encrypt(VERIFY_STRING)
CMD->>DB: set_vault_meta("verifier_ciphertext", "verifier_nonce")
VM-->>CMD: (ciphertext, nonce)
Note over UI,DB: Unlock
UI->>CMD: vault_unlock(password)
CMD->>DB: get_vault_meta("salt", "verifier_ciphertext", "verifier_nonce")
CMD->>VM: derive_key(password, salt)
CMD->>VM: set_key(key)
CMD->>VM: decrypt(verifier_ct, verifier_nonce)
alt payload == VERIFY_STRING
VM-->>UI: Ok(true)
else salah password
CMD->>VM: lock() (zeroize)
VM-->>UI: Err("Incorrect master password")
end
Note over UI,DB: Simpan secret
UI->>CMD: host_save(secret)
CMD->>VM: is_unlocked()
CMD->>VM: encrypt(secret.as_bytes())
VM-->>CMD: (ciphertext, nonce)
CMD->>DB: save_credential(ciphertext, nonce)
Note over UI,DB: Auto-lock
UI->>UI: useAutoLock timer / blur
UI->>CMD: vault_lock()
CMD->>VM: lock()
VM->>VM: key.zeroize()
VM->>VM: derived_key = None%% fig: Siklus hidup key vault dari setup sampai auto-lock %% desc: Key diturunkan dan diverifikasi lewat payload VERIFY_STRING, dipakai untuk enkripsi per item, lalu di-zeroize saat lock.
Setup
vault_setup menolak password lebih pendek dari 4 karakter dengan pesan Password must be at least 4 characters long, membuat salt baru, menurunkan key, menyimpan salt ke vault_meta, memasang key di memori lewat set_key, lalu mengenkripsi VERIFY_STRING dan menyimpan verifier_ciphertext serta verifier_nonce (src-tauri/src/commands/mod.rs:45-707). Salt dan pasangan verifier itu ditulis ke tabel vault_meta; skema tabelnya dibahas di Skema Database Lokal.
Unlock
vault_unlock membaca salt, verifier_ciphertext, dan verifier_nonce dari vault_meta; bila salah satunya hilang ia mengembalikan Vault has not been initialized yet, Corrupted vault state: verifier missing, atau Corrupted vault state: nonce missing (src-tauri/src/commands/mod.rs:73-908). Key hasil derive_key dipasang lebih dulu, lalu decrypt dipanggil pada verifier: hasil yang sama dengan VERIFY_STRING berarti sukses, selain itu vault.lock() dijalankan dan command mengembalikan Incorrect master password (src-tauri/src/commands/mod.rs:92-1029).
Lock dan zeroize
lock mengambil write lock, memanggil key.zeroize() pada byte key, lalu mengisi derived_key dengan None (src-tauri/src/vault/mod.rs:27-3410). Setelah itu is_unlocked() bernilai false dan setiap encrypt/decrypt gagal dengan Vault is locked. Unlock vault first. (src-tauri/src/vault/mod.rs:157-17511). export_key_hex juga menolak saat terkunci dengan Vault is locked (src-tauri/src/vault/mod.rs:110-12212).
Penyimpanan secret
Saat menyimpan host dengan password baru, host_save memeriksa state.vault.is_unlocked() dan menolak dengan Vault must be unlocked to save credentials, lalu mengenkripsi secret dan (bila ada) passphrase secara terpisah sehingga masing-masing memperoleh ciphertext dan nonce sendiri sebelum save_credential dipanggil (src-tauri/src/commands/mod.rs:371-41713). Jalur baca ada di resolve_host_auth, yang memanggil state.vault.decrypt untuk password maupun PEM private key saat koneksi SSH dibuka (src-tauri/src/commands/mod.rs:500-54914).
Command yang menampilkan secret ke UI memakai penjagaan yang sama: keychain_get_private_key menolak dengan Vault must be unlocked to view key, host_get_password dengan Vault must be unlocked to view password, dan credential_get_secret dengan Vault must be unlocked to view credentials (src-tauri/src/commands/mod.rs:891-95815).
Auto-lock dan layar kunci
stateDiagram-v2
[*] --> Terkunci
Terkunci --> Terbuka: vault_unlock / vault_biometric_unlock
Terbuka --> Terkunci: vault_lock (manual)
Terbuka --> AppLocked: lockApp (padlock)
Terbuka --> Terkunci: autoLock (timer, blur, beforeunload)
AppLocked --> Terbuka: unlock(password) / unlockWithBiometric
Terkunci --> Terbuka: vault_keyring_unlock (senyap)
AppLocked --> AppLocked: keyring unlock sengaja dilewati
style Terbuka fill:#2d7d46,color:#fff
style Terkunci fill:#f59e0b,color:#000
style AppLocked fill:#dc2626,color:#fff%% fig: Tiga keadaan vault di UI %% desc: Lock manual dan auto-lock memakai jalur yang sama, sedangkan AppLocked memaksa autentikasi sadar tanpa keyring.
Store UI membedakan tiga aksi lock. lock memanggil api.lockVault() lalu menutup prompt; lockApp melakukan hal sama tetapi menyalakan isAppLocked sehingga UI tertutup AppLockScreen; autoLock membuka kembali prompt dan menandainya isUnlockPromptDismissable (src/stores/useVaultStore.ts:149-17516). Komentar di store menyatakan lockApp sengaja tidak menyentuh sesi SSH/SFTP/tunnel maupun buffer terminal, hanya key vault yang di-zeroize (src/stores/useVaultStore.ts:156-16317). AppLockScreen mengembalikan null bila !isAppLocked || isUnlocked, tidak punya tombol dismiss, dan menyediakan jalur password serta biometrik (src/components/vault/AppLockScreen.tsx:44-7518).
refresh mencoba unlock senyap lewat OS keyring hanya bila vault terinisialisasi, terkunci, dan isAppLocked bernilai false; saat layar kunci aplikasi aktif, jalur itu dilewati agar kembali ke aplikasi selalu butuh autentikasi sadar (src/stores/useVaultStore.ts:51-7519).
Kebijakan auto-lock
AutoLockPolicy berisi lima nilai: never, on_close, 15min, 1hour, dan on_focus_loss, dengan default on_close (src/stores/useSettingsStore.ts:4-3320). useAutoLock memetakan 15min ke 15 * 60 * 1000 ms dan 1hour ke 60 * 60 * 1000 ms, lalu mereset timer pada mousemove, mousedown, keydown, touchstart, dan wheel (src/hooks/useAutoLock.ts:5-4421). Untuk on_focus_loss ia memanggil autoLock() pada event blur jendela (src/hooks/useAutoLock.ts:46-5622), dan untuk beforeunload ia memanggil useVaultStore.getState().autoLock() kecuali kebijakannya never (src/hooks/useAutoLock.ts:58-7223).
⚠️ Kebijakan
nevertetap tidak melindungi key saat proses berakhir: key hanya di-zeroize bilalockdipanggil, danlockdijalankan oleh eventbeforeunloadserta olehvault_lock. Menutup proses secara paksa melewati keduanya.
Keyring dan biometrik
Key turunan bisa didelegasikan ke OS keyring sebagai hex 64 karakter dengan service termimus dan user vault-derived-key; vault_keyring_save menolak bila vault terkunci memakai pesan Vault is locked — unlock it first before enabling OS Keyring. (src-tauri/src/commands/mod.rs:110-13124). unlock_from_keyring_internal menolak entri yang panjangnya bukan 64 dengan Keyring entry is corrupted (unexpected length)., lalu memverifikasi key hasil decode terhadap verifier; bila verifier tidak cocok, key di-zeroize, entri keyring dihapus, dan fungsi mengembalikan Ok(false) (src-tauri/src/commands/mod.rs:133-17725). README menyebut delegasi ini untuk KWallet, GNOME Secret Service, macOS Keychain, dan Windows Credential Manager (README.md:22126).
Biometrik dibungkus modul biometric: is_supported() dan authenticate(reason) diekspor dari macos pada macOS dan dari unsupported pada platform lain (src-tauri/src/biometric/mod.rs:1-1527). Implementasi macOS memakai LAContext dengan LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1, dan authenticate mengembalikan Err("Touch ID is not available or not configured on this device") bila policy tidak bisa dievaluasi (src-tauri/src/biometric/macos.rs:12-6328). vault_biometric_unlock menuntut is_supported(), menuntut key tersimpan di keyring (No saved vault key found in OS Keyring. Please unlock with master password first to enable biometric unlock.), menjalankan authenticate("Unlock Termimus Vault") di spawn_blocking, lalu memakai unlock_from_keyring_internal (src-tauri/src/commands/mod.rs:201-23029). Artinya biometrik tidak menurunkan key baru; ia hanya memberi gerbang pada key yang sudah ada di keyring.
Rotasi master password
graph LR
OLD["old_password + salt"] --> DK1[derive_key] --> V{"decrypt verifier"}
V -->|"bukan VERIFY_STRING"| FAIL["Current password is incorrect."]
V -->|cocok| LOOP["for cred in list_credentials"]
LOOP --> D["decrypt_with_key(old_key)"]
D -->|"Err"| SKIP["eprintln warning, lewati"]
D -->|"Ok"| E["encrypt_with_key(new_key)"]
E --> TX["rekey_vault: satu transaksi SQLite"]
SKIP --> TX
NEW["new_password + new_salt"] --> DK2[derive_key] --> E
DK2 --> NV["encrypt_with_key(VERIFY_STRING)"] --> TX
TX --> SET["set_key(new_key)"]
SET --> CLR["vault_keyring_clear()"]
style FAIL fill:#dc2626,color:#fff
style SKIP fill:#f59e0b,color:#000
style TX fill:#336791,color:#fff
style SET fill:#2d7d46,color:#fff
style CLR fill:#8b5cf6,color:#fff%% fig: Alur vault_change_password %% desc: Password lama diverifikasi ke verifier, kredensial dienkripsi ulang dengan key baru, lalu semuanya di-commit dalam satu transaksi.
vault_change_password menolak password baru di bawah 4 karakter dengan New password must be at least 4 characters., memverifikasi password lama terhadap verifier (Current password is incorrect.), membuat salt baru, lalu mengenkripsi ulang setiap credential beserta passphrase-nya dengan key baru (src-tauri/src/commands/mod.rs:247-31730). Credential yang gagal didekripsi tidak menggagalkan proses: ia dilewati dan dicatat lewat eprintln! dengan prefiks [Vault] Warning: skipping undecryptable credential during re-keying (src-tauri/src/commands/mod.rs:313-31631).
Hasil enkripsi ulang, salt baru, dan verifier baru di-commit oleh rekey_vault dalam satu transaksi SQLite sehingga database tidak pernah setengah ter-key (src-tauri/src/db/mod.rs:378-42032). Setelah commit, key di memori diganti dengan key baru dan entri keyring lama dihapus supaya pengguna mengaktifkannya ulang (src-tauri/src/commands/mod.rs:334-34033).
⚠️
vault_resetbersifat destruktif: ia memanggilstate.vault.lock(), menghapus entri keyring, lalureset_vault()yang menjalankanDELETE FROM credentials; DELETE FROM vault_meta; UPDATE hosts SET credential_id = NULL;sehingga semua password dan private key hilang permanen (src-tauri/src/commands/mod.rs:343-35434, src-tauri/src/db/mod.rs:932-94135).
Enkripsi dengan passphrase terpisah
Backup memakai jalur yang tidak menyentuh key vault: encrypt_with_passphrase membuat salt sendiri, menurunkan key dari passphrase, dan mengembalikan tiga nilai (ciphertext, salt, nonce), sedangkan decrypt_with_passphrase menurunkan ulang key dari salt itu dan memetakan kegagalan AEAD menjadi Decryption failed — wrong passphrase or corrupted backup (src-tauri/src/vault/mod.rs:36-8536). Karena kuncinya berbeda, mengubah master password tidak membuat backup lama tidak terbaca. Alur backup dan sync dijelaskan di Backup, Sync Relay, dan API Server.
Batas kepercayaan
| Yang disimpan di disk | Yang hanya di memori |
|---|---|
salt 16 byte | derived_key [u8; 32] |
verifier_ciphertext + verifier_nonce | password master (hanya selama command berjalan) |
ciphertext + nonce per credential | plaintext secret saat decrypt dipanggil |
| hex key di OS keyring (bila diaktifkan) | — |
Salt dan verifier disimpan sebagai blob di vault_meta lewat set_vault_meta, yang memakai INSERT ... ON CONFLICT(key) DO UPDATE (src-tauri/src/db/mod.rs:911-93037). Karena yang tersimpan hanya salt, ciphertext, dan nonce, penyerang yang membaca berkas database tidak dapat menurunkan key tanpa password master. Verifikasi host key SSH memakai mekanisme terpisah dan dibahas di Verifikasi Host Key TOFU.