Encryption - SankethBK/diaryvault GitHub Wiki
DiaryVault supports opt-in, per-note, end-to-end encryption. Notes you mark as encrypted are protected with a passphrase only you know β nobody else, including us and your cloud provider, can read them. This page describes the architecture in depth.
Source:
lib/features/encryption/
- Design goals
- Threat model
- Key hierarchy
- The keychain
- Cryptographic primitives
- Storage layout
- Encrypted note payload format
- Sync hash design
- Session lifecycle
- Multi-device semantics
- Passphrase change and recovery rotation
- User-facing flows
- What encrypted notes are excluded from
- Limitations and caveats
- Opt-in per note. Encryption is not all-or-nothing. A note is either plain or encrypted, chosen in the editor with a lock toggle.
- Cloud-provider-blind. The cloud store (Dropbox / Google Drive / Nextcloud) only ever sees ciphertext for encrypted notes.
- Recoverable. Forgetting the passphrase must not mean instant data loss β a recovery code provides a second key slot.
- Sync-friendly. Encrypted notes must sync across devices like normal notes, including conflict resolution, without any server-side changes.
- Zero plaintext at rest. Title, body, and plain-text preview of an encrypted note are never written to disk or cloud in plaintext.
Protected against:
- Anyone with read access to your cloud app folder (cloud provider, someone who steals your Dropbox credentials) β they see only base64 ciphertext for encrypted note content.
- Anyone with read access to the local SQLite database β same.
NOT protected against / out of scope:
- An attacker who knows your passphrase. The passphrase is the root of all key material.
- A device compromise while the session is unlocked. Master keys live in process memory while the encrypted notes view is open.
- Asset files. Images/videos/audio attached to an encrypted note are uploaded to the cloud unencrypted (see Limitations). Only their paths inside the note body are encrypted.
- Metadata. Note creation/modification timestamps, the number of encrypted notes, ciphertext lengths, and tags remain visible.
- Guessing attacks on weak passphrases. Argon2id makes them expensive, not impossible. Choose a long passphrase.
The scheme is classic envelope encryption with three layers:
passphrase ββArgon2idβββΊ KEK-pass βββ
ββββΊ unwrap βββΊ MASTER KEY (random 256-bit)
recovery code ββArgon2idβββΊ KEK-rec ββ β
β unwrap
βΌ
per-note DEK (random 256-bit)
β
βΌ
AES-256-GCM encrypts {title, body, plain_text}
Why a master key at all (instead of deriving note keys directly from the passphrase)?
- Cheap passphrase changes. Changing the passphrase re-wraps one master key instead of re-wrapping every note's DEK. (In practice we still re-stamp every note row so sync propagates β but the DEKs themselves don't change, and the master key is never re-generated.)
- A single recovery code. The recovery code wraps the same master key, so one code recovers all notes, forever, regardless of when they were created.
- Passphrase verification with zero notes. The locally cached wrapped master key lets the app distinguish "wrong passphrase" from "no notes yet".
The salt must be shared between devices, which is why it travels with the notes (see The keychain) β two devices that set up encryption independently generate different random salts and master keys, even with the same passphrase. The multi-device machinery below reconciles that.
The keychain is everything needed to unlock the master key, while containing no secrets itself:
class EncryptionKeychain {
final KdfParams kdfParams; // Argon2id params + 16-byte random salt
final WrappedKey wrappedMkPass; // MK encrypted with KEK-pass
final WrappedKey wrappedMkRecovery; // MK encrypted with KEK-rec
}A keychain is stored in two places:
-
Locally (shared preferences, key
encryption_keychain_cache) so unlock doesn't need a database hit. -
Stamped on every encrypted note row (
enc_salt,enc_wrapped_mk_pass,enc_wrapped_mk_recoverycolumns). Because the keychain rides along with the notes, it travels with cloud sync for free β there is no separate metadata file to keep consistent. A fresh device hydrates its keychain from any synced encrypted note row.
Because the GCM authentication tag fails on the wrong KEK, unwrapping doubles as passphrase verification β there is no separate password hash to leak.
All primitives live in lib/features/encryption/core/crypto_service.dart (stateless, no I/O).
| Purpose | Algorithm | Parameters |
|---|---|---|
| Passphrase β KEK | Argon2id | 64 MiB memory, 3 iterations, parallelism 1, 32-byte output, 16-byte random salt per keychain |
| Key wrapping (MK, DEKs) | AES-256-GCM | 256-bit keys, 12-byte nonce, 16-byte tag, stored as base64(nonce β ciphertext β tag)
|
| Note content | AES-256-GCM | same encoding as above |
| Sync hash | HMAC-SHA256 | keyed with the master key (see below) |
| Recovery code | random | 5 groups Γ 5 chars from a 31-char unambiguous alphabet (no 0/o, 1/l/i) β 123 bits of entropy; input is normalized (case/space/dash tolerant) before Argon2id |
encryption_version (currently 1) is stamped on every note row. KDF cost parameters are versioned constants, so a future version bump can strengthen them without a migration β only the salt is stored per note.
Notes table (SQLite schema v4), encryption columns:
| Column | Contents |
|---|---|
is_encrypted |
1 for encrypted notes; all normal-note queries filter is_encrypted = 0
|
encryption_version |
scheme version (currently 1) |
enc_salt |
base64 KDF salt of the keychain that protects this note |
enc_wrapped_mk_pass |
master key wrapped with the passphrase KEK |
enc_wrapped_mk_recovery |
master key wrapped with the recovery KEK |
wrapped_dek |
this note's DEK wrapped with the master key |
title, plain_text
|
NULL for encrypted notes β nothing sensitive at rest |
body |
the ciphertext payload (see below) |
The same columns appear in the cloud copy of the note (<noteId>.json), so the cloud artifact is self-contained: any device that knows the passphrase can derive everything it needs from a synced note row.
The three sensitive fields are packed into one JSON document and encrypted under the note's DEK with a single random IV:
plaintext: {"title": ..., "body": <quill delta JSON string>, "plain_text": ...}
ciphertext: base64( nonce β AES-256-GCM_DEK(plaintext) β tag ) β stored in `body`
The body embeds asset references (image/video paths) β those are inside the ciphertext and therefore hidden. Note that the editor's dirty-check and asset trimming always operate on the plaintext body before encryption; the shared write paths never see ciphertext.
Normal notes sync by comparing a SHA-1 hash of title + created_at + body + tags. For encrypted notes this has two problems:
- Ciphertext hashing is useless β random IVs mean re-encrypting identical content yields different ciphertext.
- Plaintext hashing leaks β an unsalted hash of the plaintext lets the cloud provider run confirmation attacks ("is this note about X?").
So encrypted notes use a keyed, deterministic hash:
hash = HMAC-SHA256(key = master key,
title + created_at + body(with asset paths reduced to file names) + tags
+ "|enc|" + enc_salt + enc_wrapped_mk_pass + enc_wrapped_mk_recovery + wrapped_dek)
- Deterministic over the plaintext, so change detection survives re-encryption.
- Keyed, so the cloud provider can't precompute or confirm anything.
- Includes the keychain material, so a passphrase change or recovery-code rotation changes the hash of every encrypted note and therefore syncs to other devices as if it were a normal note edit β no special sync protocol needed.
The editor's close-dialog dirty check recomputes exactly this composition (via IEncryptedNotesRepository.computeEditorHash), using the keychain stamped on the note's row β which matters when the note was created on another device.
EncryptionSessionService owns the session:
- The master key exists only in memory (
SecretKey), never persisted. - States:
EncryptionDisabledβ (setup) βEncryptionLockedβEncryptionUnlocked. - The encrypted notes view is session-scoped: entering it requires an unlock; leaving it calls
lock(), which drops all in-memory keys and clears the repository's decrypted-note cache. - On app start,
initialize()loads the cached keychain, or β on a fresh device after sync β hydrates it from an encrypted note row.
This is where the design earns its keep. Keychains are identified by their enc_wrapped_mk_pass blob.
Two devices that each run setup generate different keychains (different random salt + master key), even with the same passphrase. After sync, both keychains' notes exist on both devices. unlock() therefore tries every distinct keychain found locally (the cached one plus all keychains stamped on note rows) and caches each master key the passphrase opens:
- Decryption always uses the note's own keychain β device A's notes decrypt on device B transparently.
- The primary keychain (used to encrypt new notes) stays the device's own.
A note stays bound to the keychain it was created with. When you edit a synced note, the stored row is the source of truth: its DEK is unwrapped with its own keychain's master key, content is re-encrypted, and its keychain columns are left untouched. The passphrase it answers to never changes just because you edited it on another device β this avoids key material ping-ponging between devices on every save.
They remain locked: the encrypted notes list shows a "π Locked note" placeholder ("Protected by a different passphrase"). Tapping it prompts for that note's passphrase and calls unlockNote(noteId, passphrase), which rebuilds the keychain from the note row and opens it via unlockAdditionalKeychain β an in-memory-only unlock that never replaces the primary keychain. After that, the note decrypts and can be edited like any other.
changePassphrase converges every note the session can currently decrypt onto the new keychain (foreign notes get their DEK re-wrapped under the primary master key). Hashes change, so sync propagates the re-stamped rows. On the next unlock elsewhere, if the device's cached keychain no longer opens but a synced one does, the synced keychain is adopted as the new primary (and persisted). Net effect: one passphrase change + one sync β all devices follow.
Both operations re-wrap key material and rely on hash-driven sync propagation:
-
changePassphrase(old, new): verifiesold, generates fresh KDF params (new salt), re-wraps the same master key with the new KEK, then re-stamps every openable encrypted note (recomputing hashes). The master key itself never changes, so DEKs don't need re-encryption β except foreign-keychain notes, which converge (above). -
regenerateRecoveryCode(passphrase): generates a new code, re-wraps the master key's recovery slot, propagates the same way. The old code dies everywhere after sync.
If you forget the passphrase and lose the recovery code, the notes are unrecoverable β by design. The setup flow makes you acknowledge this explicitly.
| Surface | Behavior |
|---|---|
| Settings β Encryption | Enable toggle, unlock, change passphrase, regenerate recovery code |
| Setup dialog | Explainer β passphrase form (min 8 chars, confirmation, "I understand there is no recovery without passphrase + code" checkbox) β recovery code shown exactly once with a copy button and an "I have written it down" checkbox |
| Home page | Lock mini-FAB appears when encryption is enabled; opens the encrypted notes view (unlock prompt if locked) |
| Encrypted notes view | Lists decrypted previews while unlocked; locks the session on exit. Locked (different-passphrase) notes appear as placeholders with a per-note unlock prompt |
| Editor | Lock toggle (visible when encryption is enabled) marks the note encrypted; the save path routes to EncryptedNotesRepository which encrypts before persisting |
- The home list and search (all normal-note queries filter
is_encrypted = 0). -
Export (JSON / text / PDF all go through
fetchNotes, which applies the same filter).
-
Asset files are not encrypted. Images/videos/audio attached to an encrypted note are uploaded to the cloud as plain files. Only the note's text content (and the asset paths inside it) are protected. If a photo itself is sensitive, treat it as visible to your cloud provider. (This is the top candidate for a future
encryption_version2.) - Tags are stored and synced in plaintext, including on encrypted notes.
- Metadata leakage: timestamps, note count, ciphertext sizes, and the mere existence of encrypted notes are visible.
- Cross-platform asset paths: asset paths embedded in an encrypted note's ciphertext can't be rewritten during sync (the sync layer can't read ciphertext). AndroidβAndroid works because the app-private directory path is identical; AndroidβiOS asset references in encrypted notes may not resolve.
- Session is per-launch and per-view: there is no "keep unlocked for X minutes" option β leaving the encrypted notes view locks everything.
- The passphrase is the single root of trust. There is no escrow, no server-side reset. Passphrase + recovery code both lost = data gone.