How FUTO Notes end-to-end encryption works
FUTO Notes encrypts every synced note and image on your device with AES-256-GCM under a random 256-bit vault key, and the sync server stores only the ciphertext. The vault key is itself encrypted under a key derived from your sync password with PBKDF2-HMAC-SHA256, and that wrapped copy on the server is how a new device gets the key.
This page describes what the code does, including the parts that are not encrypted and the limits of the design. The implementation is in crates/futo-notes-core/src/e2ee/ and crates/futo-notes-sync/ in the app repo. The same Rust code runs on desktop, iOS, and Android.
Primitives and parameters
| Job | Algorithm | Parameters |
|---|---|---|
| Encrypt notes and images | AES-256-GCM | 256-bit vault key, 96-bit random nonce per encryption, 128-bit tag |
| Encrypt (wrap) the vault key | AES-256-GCM | 256-bit password-derived key, 96-bit random nonce, 128-bit tag |
| Turn the password into a key | PBKDF2-HMAC-SHA256 | 100,000 iterations, 16-byte random salt, 32-byte output |
| Randomness | Operating system CSPRNG | Vault key, salt, and every nonce |
The ciphers come from the RustCrypto aes-gcm and pbkdf2 crates. There is no custom cryptography.
A fresh nonce is drawn for every encryption, so saving the same note twice produces two unrelated ciphertexts. Random 96-bit nonces under one key are safe well past the number of saves a person makes; the usual guideline is 2^32 encryptions per key.
Key hierarchy
sync password ──PBKDF2-HMAC-SHA256 (salt, 100,000 iterations)──▶ password keypassword key ──AES-256-GCM decrypt──▶ vault key (32 random bytes)vault key ──AES-256-GCM──▶ every note and image blob- The vault key is generated once per vault, by the first device that connects to an empty server. It never changes for the life of that vault.
- The password key exists only in memory while the app derives the vault key. It is never stored or sent anywhere.
- The vault key is kept only in memory. The app derives it again from the password each time it connects, so it is not written to disk on any platform.
The server stores the wrapped vault key as JSON on the vault’s collection row:
{ "key_salt": "<32 hex characters: the 16-byte salt>", "key_kdf": { "kdf": "pbkdf2-sha256", "iterations": 100000, "hash": "SHA-256" }, "encrypted_vault_key": "<120 hex characters: 12-byte nonce, 32-byte encrypted key, 16-byte tag>", "key_updated_at": "2026-09-01T12:00:00.123Z"}A wrong password produces a different password key, the GCM tag check fails, and the app refuses to go further. It cannot decrypt anything with a wrong key, so it never writes garbage into your notes.
What’s inside each encrypted blob
Before encryption, the app packs the note’s path and its content into one frame. Every byte of this frame is encrypted:
| Bytes | Content |
|---|---|
| 1 | Frame version, 0x02 |
| 4 | Length of the path in bytes, unsigned big-endian |
| n | The path relative to the notes folder, UTF-8, for example Projects/plan.md |
| rest | The file: a note’s markdown as UTF-8, or an image’s bytes as base64 text |
Because the path is inside the frame, the filename and folder are encrypted along with the text. The blob that is uploaded is:
[12-byte nonce][AES-256-GCM ciphertext of the frame][16-byte tag]Images use exactly the same frame and cipher as notes. The synced file types are .md notes and images with the extensions jpg, jpeg, png, gif, webp, svg, bmp, ico, avif, and heic; other files in the notes folder are not uploaded. See what syncs.
The encryption uses no associated data. A ciphertext is not bound to the object ID or version it is stored under, so the app can tell that a blob was made with the vault key but not that it is the latest version of that note. What the server can see covers what that allows.
On download, the app checks the GCM tag before it uses any of the plaintext. A blob that was tampered with, truncated, or encrypted under another key fails that check, the note is not written, and the failure is recorded in the sync result.
Setting up a new device
When you enter a server URL and the sync password on a device, the app:
- Reads
GET /to learn how the server signs in. - Sends the sync password to
POST /api/auth/password/loginand receives a session token. - Picks the vault: the oldest collection on the account, or a new one if there is none.
- Downloads the wrapped vault key, runs PBKDF2 locally with the stored salt and iteration count, and decrypts the vault key.
- Pulls the list of objects, downloads each blob, decrypts it, and writes the file to the path inside the frame.
If the vault has no key yet and holds no objects, this device is the first one. It generates a vault key and a salt, wraps the key under your password, and uploads the wrapped copy. If two devices do this at the same moment, the server keeps the first key and hands it to the second device, which adopts it, so the vault never ends up with two keys. If a vault holds objects but has no key, the app refuses to create a new key, because doing so would make the existing objects unreadable.
The sync password is also the login password
The password you type signs the app in to the server, and it is also the input to PBKDF2. It is not transformed or derived separately for login: steps 2 and 4 of setting up a new device use the same string.
This means the server receives your password in the login request body. That happens when you first connect, whenever a session expires (sessions last seven days), and each time the iOS or Android app starts. Whoever runs the server, or anyone who can read that request, has everything needed to unwrap the vault key.
The end-to-end encryption protects your notes from anyone who gets a copy of the server’s data directory or a backup of it, as long as they don’t also get the password, which the installer writes into the server’s .env file. It does not protect them from the running server. The self-hosted design assumes the person running the server is you. What the server can see goes through each case, and the sync password covers where the app keeps it.
What is not encrypted
For every object, the server stores some plaintext it needs to coordinate sync:
- The object’s ID, version number, and position in the vault’s change order.
- Whether it is deleted.
- Its creation and last-change times.
- The exact size of its blob, which is the size of the path plus the content plus 33 bytes. Nothing is padded.
The wrapped vault key’s salt and KDF parameters are stored in the clear, as they must be.
Your notes on your own devices are not encrypted by FUTO Notes either. They are plain markdown files in the notes folder, protected by your operating system and its disk encryption. See where your notes are stored.
Limits of the design
- Sizes leak. Blob sizes follow note sizes to the byte, so the server knows roughly how long each note is, and a very large object is probably an image.
- No rollback detection. The app does not check that versions only move forward, and blobs are not bound to their version, so a server could serve an older blob without the app noticing.
- PBKDF2 at 100,000 iterations. That is below the 600,000 iterations OWASP currently recommends for PBKDF2-HMAC-SHA256. Anyone holding the wrapped key can guess passwords offline, so a long random password matters.
- No key rotation. The vault key never changes, and the app has no way to re-wrap it under a new password. Changing the password means starting a new vault; see changing the sync password.
- No forward secrecy. Anyone who has the password and an old copy of the server’s data can read that copy.
Next steps
- What the server can see: the threat model, case by case.
- Sync password: where each app keeps it, and how to change it.
- Download FUTO Notes for desktop, iOS, and Android.