Easy Docket is end-to-end encrypted with a key that only your devices hold. Every sync destination — the bundled server, an S3 bucket, a Git host, and any CORS proxy in between — receives ciphertext it cannot decrypt.
Stated precisely: an attacker with complete control of a sync destination, and a full copy of everything stored there, learns nothing about your finances.
Honesty about the edges matters more than a longer list of claims.
| Exposure | Why |
|---|---|
| Metadata | A destination sees object names (vaults/<random-uuid>/ops/<timestamp>-<counter>-<device>.edk), sizes and upload times. That reveals when you use the app and roughly how much you record, though not what. |
| Number of devices | Device ids appear in object names. They are random, and a reinstall gets a new one. |
| A compromised device | The threat model does not extend to an attacker with code execution on an unlocked device. Nothing client-side can. |
| A stolen S3 or Git credential | Cannot read your data, but can delete it. Scope keys to one bucket or repository. |
| Backup responsibility | Lose the passphrase and the data is unrecoverable. That is the cost of the guarantee, not a bug. |
| Key | Derivation | Lifetime |
|---|---|---|
| Master key (MK) | 256 bits from the platform CSPRNG, once per vault | Forever; the only key that reads ledger data |
| Key-encryption key (KEK) | PBKDF2-HMAC-SHA256, 650,000 iterations, 16-byte random salt | Derived on demand, never stored |
The MK is wrapped under the KEK and the wrapped form is persisted. Changing the passphrase re-wraps the MK, so it is instant no matter how much history exists, and data already synced stays readable.
Why PBKDF2 and not Argon2id. Argon2id is the better KDF, and if this were server-side code it would be the choice. Client-side the calculus differs: PBKDF2 is native to WebCrypto on every target — browser, Android WebView and the Node test runner — with no WASM payload to ship, no fallback path to test and no risk of a polyfill running the derivation in ordinary JavaScript where timing is unpredictable. 650,000 iterations exceeds the OWASP 2023 floor for SHA-256. The iteration count is recorded in each wrapped-key record, so it can be raised later without invalidating existing vaults.
AES-256-GCM. A fresh 96-bit random IV per message — never reused with a key, which is the one thing GCM cannot survive. The envelope header is passed as additional authenticated data, so it cannot be tampered with even though it is readable.
Key wrapping uses a separate AAD domain separator (easy-docket/wrap/v1), so a
wrapped key can never be replayed as a payload or vice versa.
Every byte handed to a destination is one of these:
0 magic 4 "EDCK"
4 version 1 1
5 algorithm 1 1 = AES-256-GCM
6 headerLen 2 big-endian
8 iv 12 random per message
20 header … UTF-8 JSON, authenticated but NOT encrypted
… payload … ciphertext ‖ 16-byte GCM tag
Self-describing on purpose: a destination may be a Git repository cloned years later on a different device, so the ciphertext must carry what is needed to authenticate it.
The header is the only plaintext, and it is restricted to four fields — vault
id, device id, HLC stamp, payload type. All four are random or mechanical.
Nothing financial may ever go in it, and
sync.service.spec.ts asserts exactly that key set.
This is where platforms genuinely differ, and the app says so rather than implying a uniform guarantee.
The master key is held by capacitor-secure-storage-plugin, which stores it in
an EncryptedSharedPreferences file whose encryption key lives in the
hardware-backed Android Keystore. The key material does not leave the keystore.
The vault therefore survives an app restart without a passphrase prompt.
The master key is never persisted in a browser. It is held as a CryptoKey
handle for the lifetime of the tab, and the user unlocks with their passphrase
each session.
This is a deliberate refusal rather than a missing feature. localStorage,
sessionStorage and IndexedDB are all readable by any script running on the
origin, so a key placed in any of them is exposed to a future XSS — and a
"session passphrase" scheme that stores the derived key alongside its own salt
merely moves the problem one indirection along. There is no browser primitive
that holds a secret against script on its own origin, so the app asks for the
passphrase instead of pretending.
What is persisted on the web is the wrapped key, which is inert without the
passphrase, and CryptoKey handles are opaque references into the browser's
crypto implementation rather than byte arrays sitting in the JavaScript heap.
VaultService.keyIsDurable exposes the difference, and both the unlock screen
and the security settings page explain it in the user's own terms.
On Android the key survives a restart, which is the right default for everyday use and the wrong one for a phone found unlocked — at that point the ledger is simply open. Settings → Security offers a fingerprint, face or device-credential check in front of the stored key.
It is worth being precise about what that is and is not. It is not a second layer of encryption: the key is already held in the hardware keystore, and this is a presence check on whoever is holding the phone. A refused check leaves the vault locked, and the passphrase still opens it — a convenience gate that could lock someone out of their own ledger would be a worse bargain than the one it improves on.
Enabling it verifies once immediately, so a check that does not work cannot be
switched on. The plugin declares USE_BIOMETRIC and USE_FINGERPRINT, which a
Play Store listing has to account for.
lock() drops the in-memory key and evicts it from the keystore, so a stolen
device cannot resume. destroy() additionally erases the wrapped key and vault
identity.
A sync target holds a GitHub token or an S3 secret key. Those are encrypted
under the master key before they touch disk, exactly like ledger data
(SyncSettingsService). The natural consequence — sync cannot run while the
vault is locked — is correct anyway: a locked vault has no key to encrypt
outgoing data with either.
redactTarget() exists so that no code path can log a target in the clear.
The Go server is built so the zero-knowledge claim is checkable rather than
merely asserted. There is no code path in internal/storage that could decrypt
anything, because the package deals only in names and bytes.
- Bearer tokens, stored as SHA-256 hashes and compared in constant time. Tokens are high-entropy values issued by the operator, not user-chosen passwords, so a password KDF would add cost without adding security.
- Account isolation enforced at the path level, with a second check that the resolved path is inside the account root even though name validation already rejects traversal.
- Strict name validation —
vaults/<vault>/ops/<stamp>.edkand nothing else. That single regex removes path traversal, absolute paths, control characters, and clients using the vault as general-purpose storage. - Immutable objects. Re-uploading identical bytes succeeds (a retry after a crash must be safe); different bytes under an existing name return 409.
- Atomic writes — write to a temp file,
fsync, then rename. A power cut cannot leave a correctly named file with truncated contents, which would be worse than no file at all. - Quotas per account, and a per-object size cap.
- CORS off by default. Browser origins must be named explicitly in
DOCKET_ALLOWED_ORIGINS. A cross-origin page could never read the ciphertext, but it should not get to spend the user's token either. - No TLS of its own. Run it behind a reverse proxy that terminates TLS.
Beyond the unit and integration suites, the pipeline was run end to end: the built PWA in a real browser, creating a vault, recording a transaction for "Dr Mehta Clinic" against an account named "Joint Current Account", and syncing to the Go server over HTTP.
The file that reached the server's disk:
00000000: 4544 434b 0101 006d c2b8 d43b b506 5ba4 EDCK...m...;..[.
00000010: 3db9 234e 7b22 7622 3a22 6362 6561 3638 =.#N{"v":"cbea68
… {"v":"<vault-uuid>","d":"RbWKuTdf","h":"…","t":"ops"} …
00000080: 7df4 c866 209d eb34 3508 901c 4473 ffea }..f ..45...Ds..
The only readable bytes are the magic, the version, the algorithm and the four
header fields. Searching the object for Dr Mehta Clinic, Joint Current Account, consultation, the amounts, and even the entity names transactions
and accounts returns nothing.
Open a security advisory on the repository rather than a public issue.