Encryption 🔐
This page is the full technical deep-dive into SigNote's encryption model. If you just want to understand what the tiers do, start with Secrets and Seals. If you want to know how, read on.
Design goals
- The server stores only ciphertext — it can never read note bodies
- The passphrase is never stored, transmitted, or logged anywhere
- The Master Encryption Key (MEK) only exists in memory during an active unlocked session
- A recovery file can restore access if you lose the passphrase; without either the passphrase or that file, recovery is impossible
- All cryptography uses the browser's built-in Web Crypto API — no third-party crypto libraries
The two-share key architecture
The Master Encryption Key is never stored directly. Instead it's split into two shares:
MEK = deviceShare XOR serverShare
| Share | How it's made | Where it lives |
|---|---|---|
deviceShare | PBKDF2(passphrase, salt, 600k iterations) | sessionStorage (tab-scoped) |
serverShare | Random 32 bytes generated at setup | Stored in Postgres |
Neither share alone can reconstruct the MEK:
- Server stolen → attacker has
serverSharebut notdeviceShare(which requires your passphrase). No MEK, no decryption. sessionStorageread → attacker hasdeviceSharebut notserverShare. No MEK, no decryption.
Both shares are needed simultaneously. The MEK is reconstructed only in memory, as a non-extractable CryptoKey object, and only for the duration of an unlocked session.
→ See the Threat model for the full discussion.
Passphrase derivation (PBKDF2)
During setup:
- A random 32-byte
saltis generated client-side and stored with your encryption profile - Your passphrase is run through PBKDF2 to produce
deviceShare:- Hash: SHA-256
- Iterations: 600,000
- Output length: 32 bytes
When you unlock from a new tab or after closing sessionStorage, the same derivation runs again from your passphrase + the stored salt, reproducing the same deviceShare.
The high iteration count makes brute-forcing the passphrase computationally expensive.
⚠️ Your passphrase is the only thing protecting your data if your
serverShareis ever stolen. Choose a strong, unique passphrase of at least 8 characters.
Passphrase verification (keyCheck)
How does the app know if you entered the correct passphrase without storing it?
During setup, a keyCheck payload is created: a known constant is encrypted with a key derived from the MEK. When you unlock, the app attempts to decrypt keyCheck. If decryption succeeds, the passphrase was correct. If it fails, you're told and nothing is stored.
The verifyKey used for this is derived from the MEK via HKDF with info string signote-v1/verify.
Key hierarchy (HKDF)
Once the MEK is reconstructed, it's imported as an HKDF base key. All working keys are derived from it using HKDF (SHA-256) with distinct info strings — this is called domain separation and ensures keys can't be confused with each other.
| Derived key | HKDF info string | Used for |
|---|---|---|
secretBodyKey | secret-body:v1 | Encrypting and decrypting all Secret bodies |
verifyKey | key-verify:v1 | Encrypting the keyCheck passphrase verification payload |
sealWrapKey(id) | seal-wrap:v1:<noteId> | Wrapping the per-note NEK for each Seal |
fileEncKey | file-enc:v1 | Encrypting Secret attachments |
otpVaultKey | otp-vault:v1 | Encrypting Authenticator credentials |
Because sealWrapKey includes the note ID in its info string, every Seal gets a unique wrapping key derived from the same MEK.
The Authenticator key has its own lifecycle. On a trusted device it is stored as a non-extractable CryptoKey in a separate IndexedDB database, allowing codes to work offline without keeping the MEK or Secret bodies unlocked.
Secrets encryption
Each Secret body is encrypted with secretBodyKey:
- Algorithm: AES-GCM 256-bit
- IV: 12 random bytes, freshly generated per encryption operation
- Stored:
{ iv, ciphertext }in Postgres (the body field is never plaintext)
All Secrets share the same session key, so after a single unlock they can all be decrypted at once in the background.
Seals encryption — per-note keys
Each Seal uses a unique Note Encryption Key (NEK):
Encryption:
- Generate a random 32-byte NEK
- Encrypt the body with the NEK using AES-GCM 256-bit (note ID as AAD)
- Derive
sealWrapKeyfrom MEK + note ID via HKDF - Wrap the NEK with
sealWrapKeyusing AES-GCM 256-bit (note ID as AAD) - Store
encryptedBodyandwrappedNoteKeyin Postgres
Decryption:
- Derive
sealWrapKeyfrom MEK + note ID (same as step 3 above) - Unwrap the NEK using
sealWrapKey - Decrypt the body using the NEK
The note ID is used as Additional Authenticated Data (AAD) on both operations. This cryptographically binds the ciphertext to its note — if the note ID doesn't match during decryption, AES-GCM authentication fails.
Attachments are encrypted with the same NEK, with seal-file:v1:<noteId> as AAD — distinct from the body's, so a file can't be passed off as a body or moved to another Seal. A new Seal's NEK is generated and wrapped when its editor opens, so files attached before the first save are already under the key the Seal is stored with. Seal attachments from before this scheme are under fileEncKey until the next key change moves them.
Cryptographic primitives
All operations use the browser's native Web Crypto API:
| Primitive | Used for |
|---|---|
| PBKDF2 (SHA-256, 600k iterations) | Passphrase → deviceShare |
| HKDF (SHA-256) | MEK → working keys |
| AES-GCM 256-bit | All encryption and decryption |
| XOR | Combining two 32-byte shares into the MEK |
Keys are created as non-extractable CryptoKey objects wherever possible. This prevents JavaScript from reading the raw key bytes, even from the same page.
sessionStorage and tab lifecycle
deviceShare is kept in sessionStorage after the first unlock. This allows the MEK to be silently reconstructed on page reload — you don't need to re-enter your passphrase every time you refresh.
sessionStorage is tab-scoped: it's automatically cleared when you close the tab. The MEK cannot be reconstructed from a new tab without re-entering the passphrase.
Explicit lock also clears sessionStorage immediately, along with the in-memory MEK and CryptoKey objects.
Replacing the MEK
The MEK is stable, but not permanent. A passphrase change keeps the same MEK and re-splits it: a new salt produces a new deviceShare, and a new serverShare is chosen so that deviceShare XOR serverShare still lands on the same key. Nothing stored has to be rewritten, because nothing was encrypted under a different key.
Changing your encryption keys does the other thing: it mints a genuinely new MEK, and every ciphertext in the account is decrypted in the browser and re-encrypted under it — Secret bodies, Seal bodies, the per-note Seal NEKs (each one freshly generated, then wrapped under the new key), every retained version, every Authenticator payload, and every encrypted attachment (a Seal's under its new NEK).
Two consequences follow from that, and they are the reason it is a separate, deliberate operation rather than a setting:
- The account carries an encryption generation counter alongside the profile id. The profile id deliberately does not change — the account was not reset, it was re-keyed — so the generation is what tells a device it is holding keys for a vault that has moved on. A trusted Authenticator device wipes its offline cache on a generation mismatch and has to enroll again.
- Every recovery file made under the old key is dead, and so is the old passphrase.
While the operation runs, the account is fenced: the server refuses every write from every session until it activates or is cancelled. Activation itself is one atomic transaction, so there is no moment at which half your vault is on each key.
What the server never sees
- Your passphrase (never transmitted)
- The MEK (never leaves the browser)
deviceShare(never sent server-side)- Note Encryption Keys (NEKs) in unwrapped form
- Any plaintext note body for Secrets or Seals
→ See Secrets and Seals for per-tier details.
→ See Changing your keys for what a full re-key asks of you.