SigNoteSigNote
Documentation

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
ShareHow it's madeWhere it lives
deviceSharePBKDF2(passphrase, salt, 600k iterations)sessionStorage (tab-scoped)
serverShareRandom 32 bytes generated at setupStored in Postgres

Neither share alone can reconstruct the MEK:

  • Server stolen → attacker has serverShare but not deviceShare (which requires your passphrase). No MEK, no decryption.
  • sessionStorage read → attacker has deviceShare but not serverShare. 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:

  1. A random 32-byte salt is generated client-side and stored with your encryption profile
  2. 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 serverShare is 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 keyHKDF info stringUsed for
secretBodyKeysecret-body:v1Encrypting and decrypting all Secret bodies
verifyKeykey-verify:v1Encrypting the keyCheck passphrase verification payload
sealWrapKey(id)seal-wrap:v1:<noteId>Wrapping the per-note NEK for each Seal
fileEncKeyfile-enc:v1Encrypting Secret attachments
otpVaultKeyotp-vault:v1Encrypting 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:

  1. Generate a random 32-byte NEK
  2. Encrypt the body with the NEK using AES-GCM 256-bit (note ID as AAD)
  3. Derive sealWrapKey from MEK + note ID via HKDF
  4. Wrap the NEK with sealWrapKey using AES-GCM 256-bit (note ID as AAD)
  5. Store encryptedBody and wrappedNoteKey in Postgres

Decryption:

  1. Derive sealWrapKey from MEK + note ID (same as step 3 above)
  2. Unwrap the NEK using sealWrapKey
  3. 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:

PrimitiveUsed for
PBKDF2 (SHA-256, 600k iterations)Passphrase → deviceShare
HKDF (SHA-256)MEK → working keys
AES-GCM 256-bitAll encryption and decryption
XORCombining 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.