Houre

How your notes stay private

Houre is end-to-end encrypted: your notes are locked on your device and only you hold the key. Here's what that means, first in plain language and then in full technical detail.

In plain terms

Encrypted end to end

Before a note leaves your device, Houre locks it with a key made from your password, a key that never reaches our servers. What we store is scrambled data that we, our hosting provider, and anyone who got hold of the database simply cannot read.

Sharing stays sealed

When you invite someone to a project, your device wraps that project's key in an envelope only they can open, using the public key they published. You can compare a short fingerprint together first, to be sure you're inviting the right person. After that you both can read the project's notes on your own devices, while our servers still only ever handle sealed data.

What we can and can't see

To deliver your notes to the right devices, our servers hold some structural details. They never hold anything you write.

Stays private

  • Note content
  • Tags and the connections between notes
  • Project names and colors
  • Your display name and avatar
  • Attachment names, types, and contents
  • Invitation details, including the inviter's name and the shared key

Visible to the server

  • Your email address
  • When each note was created and its encrypted size
  • Which account owns each note
  • Which project each note belongs to
  • Who belongs to which project
  • Who invited whom to which project
  • How many times each project's key has been replaced

Under the hood

For readers who want the specifics, here's what actually happens.

Keys

Your password and a per-account salt are stretched with Argon2id into a master key. HKDF splits that into two independent secrets: an authentication secret used only to sign in, and an envelope key. Your content isn't protected by the master key directly. Instead a random data key, wrapped by the envelope key, does the work.

password + salt
   │  Argon2id (64 MB, 3 iterations)
   ▼
master_key
   ├─ HKDF → auth_secret    (sign-in only)
   └─ HKDF → envelope_key   (wraps data_key)

data_key       random 32 bytes, wrapped by envelope_key
keypair_seed   random 32 bytes, wrapped by data_key

The server never sees your password, master key, data key, or key seed.

Creating an account

Registration happens almost entirely on your device. The client generates your random data key, your key seed, and a random Argon2id salt, runs your password and that salt through Argon2id to produce the master key, derives the sign-in secret and the envelope key from it, and wraps the data key and key seed. Only then does it upload your email, the sign-in secret, the salt, your public-key bundle (more about this below), and the two wrapped blobs. The server bcrypt-hashes the sign-in secret, stores the rest untouched, and emails a verification code to finish signup. Your password, master key, envelope key, and data key never leave the device.

// generated and derived on your device
data_key      = random(32)
keypair_seed  = random(32)          // also derives your public_key bundle
salt          = random(16)

master_key    = Argon2id(password, salt)
auth_secret   = HKDF(master_key, "nota-auth")        // login only
envelope_key  = HKDF(master_key, "nota-envelope")    // wraps data_key

// wrap the two long-term secrets
encrypted_data_key     = encrypt(envelope_key, data_key, aad = "nota-envelope")
encrypted_keypair_seed = encrypt(data_key, keypair_seed, aad = "nota-keypair-seed")

// upload; password and master_key never leave the device
upload = {
    email
    auth_secret
    salt
    public_key
    encrypted_data_key
    encrypted_keypair_seed
}

Signing in

Signing in reverses that. Rather than creating keys, your device fetches your salt, re-derives the same master key from your password, proves it with the sign-in secret, and unwraps the secrets the server returns. The password again never leaves the device:

// fetch your salt, then re-derive the same keys from your password
salt         = GET /auth/salt?email={email}
master_key   = Argon2id(password, salt)
auth_secret  = HKDF(master_key, "nota-auth")
envelope_key = HKDF(master_key, "nota-envelope")

// send only auth_secret; the server checks bcrypt(auth_secret) and replies
POST /auth/login { email, auth_secret }
     -> { session_token, encrypted_data_key, encrypted_keypair_seed }

// unwrap your secrets on device
data_key     = decrypt(envelope_key, encrypted_data_key, aad = "nota-envelope")
keypair_seed = decrypt(data_key, encrypted_keypair_seed, aad = "nota-keypair-seed")

Encrypting a note

Every note is sealed with XChaCha20-Poly1305 under a 24-byte random nonce that's generated fresh every time the note is saved, so an edited note is re-encrypted with a new nonce and never reuses an old one. Each ciphertext is also bound to its note ID as authenticated data, so the server can't swap encrypted blobs between notes. Personal notes use your data key; notes in a shared project use that project's key. A two-byte epoch in each blob's header records which generation of that key sealed it (see Key rotation below).

In full, saving a note means encrypting its update and uploading it alongside a few plaintext fields. Only the content is sealed; the rest is what the server needs to store and route it:

// yjs_update is the note's text, formatting, and tags (a Yjs CRDT update)

// the newest generation of the right key (see Key rotation below)
note_key, epoch = newest(data_key)       (personal note)
               or newest(project_key)    (shared-project note)

// encrypt the update under the note key, bound to the note id
encrypted_content = encrypt(note_key, yjs_update, aad = "nota-note:" || note_id, epoch)
                  = 0x02 || epoch || nonce || XChaCha20-Poly1305(note_key, nonce, yjs_update, aad)

// upload; the server stores encrypted_content opaquely
upload = {
    note_id
    owner_user_id
    project_id          (null for a personal note)
    encrypted_content
}

Post-quantum identity

Each account derives two keypairs from its key seed: an X-Wing hybrid keypair (X25519 combined with ML-KEM-768) for receiving sealed data, and an ML-DSA-65 keypair for signing. The hybrid, post-quantum design defends against harvest-now-decrypt-later, where an invitation captured today could be opened once quantum computers arrive. A published public key is trusted only after its fingerprint is confirmed out of band.

The published bundle is just a version byte followed by the two public keys. The fingerprint is a SHAKE128 hash of that whole bundle, truncated to its first 16 bytes and shown as eight groups of four hexadecimal digits, short enough for two people to read aloud and compare.

public_key_bundle =
      0x02               (version byte)
   || xwing_public_key   (1216 bytes)
   || mldsa_public_key   (1952 bytes)

// first 16 bytes, shown as 8 groups of 4 hex digits
fingerprint = SHAKE128(public_key_bundle)[:16]

Sharing a project

Each project has its own random 32-byte key. To invite you, the inviter's device fetches your published key bundle, seals the project key and a short preview with your X-Wing public key, and signs both with their ML-DSA-65 key. Your device checks the signature and fingerprint, opens the sealed key with your secret key, re-wraps it under your own data key for storage, and uploads that. The server only relays sealed blobs it can't open.

An invitation is built entirely on the inviter's device: the preview and the project key are each sealed to the invitee, and both are signed so the invitee can trust they came from the right person. Opening the sealed preview reveals only the project's name and the inviter's display name:

// look up the invitee's published key bundle by email
{ user_id, public_key } = GET /users?email={invitee_email}

// verify public_key's fingerprint with the invitee out of band, then take its X-Wing half
invitee_xwing_public = xwing_key(public_key)

// the preview the invitee sees before accepting
preview = { project_name, inviter_display_name }

// xwing_encrypt seals a message to a recipient's X-Wing public key:
//   (kem_ct, secret) = XWing.encapsulate(invitee_xwing_public)
//   0x02 || kem_ct || nonce || XChaCha20-Poly1305(secret, nonce, msg, aad = context)
encrypted_project_key = xwing_encrypt(project_key, "nota-invitation-project-key")
encrypted_preview     = xwing_encrypt(preview,     "nota-invitation-preview")

// sign both blobs so the invitee knows they came from you
signature = ML-DSA-65.sign(inviter_mldsa_secret, project_id || encrypted_project_key || encrypted_preview)

// upload; the server relays these and can't open the sealed blobs
POST /projects/{id}/invitations {
    user_id                (the invitee, from the lookup above)
    encrypted_project_key
    encrypted_preview
    signature
}

Key rotation

Rotating a key never re-encrypts what's already stored. Instead, every key (each project key, and your personal data key) is an ordered list of generations. A rotation appends a new random generation, and its position in the list is its epoch. New content is always encrypted under the newest generation and carries that epoch in its header, so a reader picks the right generation directly. Older generations are kept, which is what keeps older notes readable.

// a key is a list of generations, and each generation's index is its epoch
project_key = [p0, p1, …, pN]   (stored as 0x01 || p0 || p1 || … || pN)

// writers use the newest generation and record its epoch in the blob
encrypted_content = 0x02 || N || nonce || XChaCha20-Poly1305(pN, nonce, content, aad)

// readers take the epoch from the blob and select that generation
key = project_key[epoch]      (not held yet: the blob waits until the new key arrives)

A project is rotated whenever someone who could open its key is no longer entitled to it: a member leaves or is removed, a pending invitation is cancelled or declined (the invitation already carried the key), or a member reports that their password may have been compromised. The server witnesses each of these and records that the project owes a new key, but it can't create one, so the owner's app does it on its next sync. First it checks every recipient's current public key against the fingerprint the owner verified. If anyone is unverified or their key has changed, the rotation waits and asks the owner to verify them, so a server can't slip in a key of its own. Then it seals the entire updated list to each remaining member and pending invitee and signs each copy. The server applies the rotation in one transaction, and only if the epoch hasn't moved and the recipients exactly match the current membership, so someone removed mid-rotation can't receive the new key and someone who just joined can't be left out.

// on the owner's device, once the server has recorded that project P owes a new key
p_new     = random(32)
container = [p0, …, pN, p_new]

for each remaining member and pending invitee:
    require their current public_key matches the fingerprint the owner verified
    // sealed to their X-Wing public key and bound to this project, epoch, and recipient
    grant     = xwing_encrypt(container, "nota-project-key-grant:v1:{P}:{N+1}:{recipient}")
    signature = ML-DSA-65.sign(owner_mldsa_secret, version || P || N+1 || recipient || grant)

// one transaction, rejected if the epoch moved or the recipients don't match the membership
POST /projects/{P}/rekey {
    expected_base_epoch = N
    owner_key_blob         (the owner's own copy of container, wrapped under their data_key)
    grants                 (one grant and signature per recipient)
}

Members pick up the new key on their next sync. Their app accepts it only if it's signed by an owner key they have verified, names this project and this member, and is at least as new as the key they already hold, so an older copy can't be replayed to roll them back. It then replaces its list with the one received, re-wraps it under the member's own data key, and uploads that copy. If the owner's key has changed since they last verified it, the app asks them to compare fingerprints first. Until then they keep reading and writing with the generations they have, and newer notes wait until the key arrives rather than being discarded.

Existing notes move to the newest key as they're edited: every save re-encrypts the whole note, and the server discards the older copy. Notes nobody touches stay on their original epoch, which is why old generations are never thrown away and why a new member receives the full list, giving them the project's history just as before.

Password changes

Because the data key is random and merely wrapped by your password, a routine password change re-encrypts only that wrapper. Your notes, attachments, identity keypairs, and every project key are untouched, so the change is instant no matter how much you've stored. Your other devices are signed out and unwrap the key with the new password when you sign back in.

If you also choose "My password may have been compromised", the change replaces your keys as well. Your device adds a new data key generation and creates a new key seed, which means new X-Wing and ML-DSA-65 keypairs and a new fingerprint. In one transaction the server swaps in the new credentials and public key, signs out every other session, drops pending invitations to and from you (they're tied to your old identity), and records a rotation for every project you belong to or were invited to, because your old data key could unwrap each of their keys. Those projects then rotate as described above, and the people you share with are asked to verify your new fingerprint.

// "My password may have been compromised", all on your device
salt'          = random(16)
master_key'    = Argon2id(new_password, salt')
auth_secret'   = HKDF(master_key', "nota-auth")
envelope_key'  = HKDF(master_key', "nota-envelope")
d_new          = random(32)
data_key'      = [d0, …, dN, d_new]      (old generations kept, so old notes stay readable)
keypair_seed'  = random(32)              (a new identity; the old seed is discarded)

PATCH /auth/password {
    current_auth_secret                 (from your current password)
    new_auth_secret             = auth_secret'
    new_argon2_salt             = salt'
    new_encrypted_data_key      = encrypt(envelope_key', data_key', aad = "nota-envelope")
    new_encrypted_keypair_seed  = encrypt(d_new, keypair_seed', aad = "nota-keypair-seed")
    new_public_key              = bundle(keypair_seed')
    nonce                               (so a retried request applies only once)
}

Personal notes written afterward use the new generation, which only the new password can unwrap. Someone holding your old password and an old copy of the server's data could still open what existed before the change, but nothing written after it.

What rotation can't undo

Rotation protects notes written after it, not what someone already had. A member who leaves keeps the key generations they held, so notes they could read before stay readable to them. Their sync access ends the moment they leave, but the new key only exists once the owner's app completes the rotation on its next sync, after verifying every remaining member. Notes written before that still use the old key, and the person who left could read them if they got hold of the encrypted data another way, such as a breach of the server's stored data. The same applies after a compromised password: each project is protected once its owner has rotated it. And since the server decides when to deliver rotation requests, a malicious server could delay one, though it can never read the keys themselves.