Skip to content

Vault and Crypto

Se.Cherkasov edited this page Aug 23, 2026 · 1 revision

Vault and crypto

English · Русский

The cryptography has not been independently audited. It composes established primitives (libsodium, BouncyCastle, Nimbus SRP-6a) rather than inventing any, but the composition has not been reviewed by a third party. See SECURITY.md.

Key hierarchy

flowchart TD
  PW[master password] -->|Argon2id m=64MiB t=3 p=4, salt| MK[masterKey · 256-bit]
  MK -->|wrap / unwrap| DK[dataKey · 256-bit random]
  MK -->|HKDF branch| AK[authKey → SRP-6a verifier]
  DK -->|XChaCha20-Poly1305 + AAD| REC[vault records]
  AK --> SRV[(sync server:<br/>verifier + wrapped dataKey + ciphertext)]
Loading
  • masterKey exists only in memory, is never serialized, and is wiped (zeroize()) as soon as it is no longer needed — it is Argon2id output and a heap dump or swap would expose it.
  • dataKey is random, encrypts every record, and is stored only wrapped under the masterKey. Changing the master password rewraps it; records are not re-encrypted.
  • authKey is derived deterministically from the masterKey for sync authentication. The server never receives it — only the SRP-6a verifier.

KDF_MEMORY_MIB is published as a constant because the Vault panel states how the secrets on screen are protected, and the number the UI shows must not drift from the one in use.

Record format

data class VaultRecord(
    val id: String, val type: RecordType, val version: Long,
    val updatedAt: Long, val deviceId: String, val deleted: Boolean,
    val blob: ByteArray,   // XChaCha20-Poly1305(dataKey, payload)
)

Metadata is plaintext; blob is sealed with AAD bound to all of that metadata (recordAad), so a record cannot be swapped into another slot and its plaintext metadata cannot be tampered with without failing the tag check. Blobs sealed before 0.1.3 bound only id‖type; they are upgraded in place on the first unlock and are not trusted on read or merge until then.

The same structure is the unit of E2E sync: version is a Lamport counter for last-writer-wins, and deletion is a tombstone rather than physical erasure.

RecordType covers hosts, groups, credentials, known hosts, trusted CAs, snippets, runbooks and their run logs, tunnels, settings, terminal history, teams and team identities, and trash snapshots. IDENTITY is legacy — never written, kept so old tombstones still deserialize.

The file

VaultFileBody
├── meta: { formatVersion, salt, wrappedDataKey }   ← plaintext
└── records: [ VaultRecord … ]                      ← blobs encrypted

FileVault (okio, desktop and Android) caches records in memory and rewrites the file atomically (temp file + atomicMove). Mutators write the new snapshot to disk first and commit it to fields only on success, so a failed write leaves the cache, meta and dataKey unchanged. An unreadable file on unlock yields UnlockResult.Corrupted rather than a partial state.

Error convention in VaultCrypto: unwrapDataKey and open return null only on an AEAD tag failure (wrong password or corrupted ciphertext) — an expected outcome. Structurally invalid input (wrong key length, a blob with no room for nonce and tag) is a programming error and throws.

Transactions

vault.transaction { … } runs a read-modify-write under the vault's own reentrant lock. Anything composite — reordering hosts, merging a remote batch — must go through it, or a change computed from a stale snapshot overwrites a just-merged update. That is a real defect this rule exists for (VaultHostStore vs the background merge).

Merging remote records

Vault.mergeRemote returns MergeResult(applied, rejected). Rejected are LWW winners whose blob failed authentication against their claimed metadata — a tampered or replayed record from a compromised server. They are not stored, the local record survives, and the rejection is surfaced rather than silently dropped.

Files on disk

Secret-bearing files go through vault/atomicWriteUtf8 — atomic, 0600, never a plain writeText (which once broke TOFU known_hosts and left world-readable vault files). Exporting a key to a user-chosen location uses PrivateConfig.writePrivateFile, which hardens the file without re-permissioning the directory the user picked.

Secret comparison uses vault/constantTimeEquals, and intermediate key copies are zeroed.

Biometrics

VaultBiometrics + BiometricKeyStore (Android: AndroidBiometricKeyStore) hold a biometric-bound wrapper of the unlock material. The binding strength is reported honestly to the UI — a device that can only offer a weaker binding says so rather than implying more. Enrolling again is required after a sync connect, because connecting changes the vault key.

Trash and the security log

TrashStore keeps a snapshot of a deleted record as its own record type for the 30-day window — deliberately not a fatter tombstone, so clients that do not know the type keep it verbatim and never show it. SecurityLog records security moments (password change, biometric enrollment, secret copies, host key changes) inside the vault.

Threat boundaries

Protected: theft of data at rest, a curious or compromised sync server, eavesdropping on sync credentials. Not protected: a compromised device, weak master passwords (there is no recovery), malicious remote hosts, and secrets you deliberately send to a cloud AI.

Clone this wiki locally