Skip to content

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

JobAlgorithmParameters
Encrypt notes and imagesAES-256-GCM256-bit vault key, 96-bit random nonce per encryption, 128-bit tag
Encrypt (wrap) the vault keyAES-256-GCM256-bit password-derived key, 96-bit random nonce, 128-bit tag
Turn the password into a keyPBKDF2-HMAC-SHA256100,000 iterations, 16-byte random salt, 32-byte output
RandomnessOperating system CSPRNGVault 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 key
password 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:

BytesContent
1Frame version, 0x02
4Length of the path in bytes, unsigned big-endian
nThe path relative to the notes folder, UTF-8, for example Projects/plan.md
restThe 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:

  1. Reads GET / to learn how the server signs in.
  2. Sends the sync password to POST /api/auth/password/login and receives a session token.
  3. Picks the vault: the oldest collection on the account, or a new one if there is none.
  4. Downloads the wrapped vault key, runs PBKDF2 locally with the stored salt and iteration count, and decrypts the vault key.
  5. 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