Full Technical Reference

How Meritonis Encryption Works

A complete walkthrough of the multi-layer encryption system — from your passphrase to the last ciphertext byte stored in the database. Designed to be understandable by everyone, with enough depth for security engineers.

AES-GCM-256ECDH P-256PBKDF2-SHA-256WebCrypto API

Architecture Overview

Meritonis uses a four-layer key hierarchy. Each layer protects the one below it. Losing your passphrase means losing access — by design there is no recovery path, no key escrow, and no server-side copy of your keys.

Your Passphrase
Memorised — never transmitted
INPUT
PBKDF2 · SHA-256 · 200,000 iterations · salt = SHA-256(userId)
AES-GCM-256 Wrapping Key
Derived in-browser · never stored
LAYER 1
AES-GCM decrypt · PKCS8 blob from database
ECDH P-256 Private Key
Stored encrypted in DB · decrypted in browser
LAYER 2
ECDH key agreement + AES-GCM · wrapped org key from database
Organisation Key (32 bytes)
Decrypted in browser · one key per organisation
LAYER 3
AES-GCM-256 · unique 12-byte IV per field
Encrypted Fields
Passwords, credentials, account data — stored as ciphertext
DATA
The golden rule: The server only ever receives ciphertext. At no point does any plaintext, passphrase, or private key leave your browser.

Step 1 — Passphrase Key Derivation (PBKDF2)

When you enter your passphrase, the browser runs PBKDF2 (Password-Based Key Derivation Function 2) to produce a 256-bit AES key. This key is called the wrapping key because it wraps (encrypts) your private key.

Inputpassphrase (string) + salt (16 bytes)
Salt sourceSHA-256(userId) → first 16 bytes — deterministic and user-unique
AlgorithmPBKDF2 with SHA-256 as PRF
Iterations200,000 — raises the cost of each brute-force guess ~200,000×
Output length256 bits (32 bytes)
Key usageAES-GCM encrypt / decrypt
// Pseudocode
const salt    = SHA-256(userId).slice(0, 16)        // 16 bytes
const rawKey  = PBKDF2(passphrase, salt, 200_000, 32, "SHA-256")
const wrapKey = importKey(rawKey, "AES-GCM")
Why 200,000 iterations? Every additional iteration adds latency to every brute-force attempt. At 200,000 iterations on modern hardware, an attacker can test only a few thousand passphrases per second per GPU — compared to billions of plain SHA-256 hashes. A strong passphrase with a high iteration count is effectively uncrackable.

Why is the salt user-specific?

The salt is derived deterministically from your user ID: SHA-256(userId).slice(0, 16). This means:

  • Two users with the same passphrase produce different wrapping keys.
  • You can re-derive your wrapping key any time from passphrase + userId — no stored state needed.
  • Pre-built rainbow tables cannot be reused across users.

Step 2 — ECDH P-256 Keypair

Each user gets an ECDH P-256 keypair generated entirely in the browser on first login. ECDH (Elliptic Curve Diffie-Hellman) enables two parties to agree on a shared secret using only each other's public keys — no secret is ever transmitted.

Public Key
ECDH P-256 · 65 bytes (raw uncompressed)
Stored in the database. Shared openly. Used by others to encrypt secrets that only you can read.
Private Key
ECDH P-256 · PKCS8 · AES-GCM encrypted
Never leaves the browser in plaintext. Stored as an AES-GCM ciphertext wrapped with your passphrase-derived key.
// Private key storage format (base64 encoded)
//   [12-byte IV] [encrypted PKCS8 private key]
const encryptedPrivKey = AES-GCM.encrypt(wrappingKey, PKCS8(privateKey), randomIV)
stored = base64(IV || encryptedPrivKey)
Wrong passphrase detection: AES-GCM is an authenticated encryption scheme. It appends a 16-byte authentication tag to every ciphertext. If you enter the wrong passphrase, a different wrapping key is derived and AES-GCM decryption fails with a tag mismatch — no server roundtrip is needed to validate your passphrase.

Step 3 — Organisation Key Distribution (ECDH Key Exchange)

Organisations share data (accounts, proxies, IMAP credentials) through a single random 32-byte organisation key. Rather than transmitting this key in the clear, it is wrapped individually for each member via an ephemeral ECDH key exchange.

Encrypting the org key for a user (e.g. when inviting)
Sender (Browser)
Ephemeral ECDH P-256 Keypair
Generated fresh for this operation. Discarded after use.
ephPrivKey, ephPubKey = generateKeypair()
ECDH(ephPrivKey, recipPubKey)
sharedSecret (32 bytes)
AES-GCM encrypt(orgKey)
Database
Stored per member
ephPubKey + IV + encryptedOrgKey
base64(ephPub[65] || IV[12] || ct[48])
Decrypting the org key (user's browser)
// Recipient decrypts using their private key + the stored ephemeral public key
const sharedSecret = ECDH(privateKey, ephPubKey)
const orgKey        = AES-GCM.decrypt(sharedSecret, IV, ciphertext)
Why ephemeral keys? A fresh ECDH keypair is generated for every org-key wrapping operation. Even if an attacker compromises a wrapping keypair after the fact, they cannot decrypt past org keys — the ephemeral private key was already discarded. This property is called forward secrecy.

Adding a new team member

When a new user joins an organisation, an existing member (who already has the org key) re-wraps it for the newcomer using the newcomer's public key and a fresh ephemeral keypair. The org key itself never changes; only a new wrapped copy is added to the database.

Step 4 — Field Encryption (AES-GCM-256)

With the organisation key recovered, the browser can encrypt and decrypt individual data fields. Every sensitive value — password, email credential, proxy URL, IMAP password — is encrypted independently with AES-GCM-256 using a fresh random IV.

Ciphertext format (base64 encoded)
bytes 0–11
IV
12 bytes · random
bytes 12…
Ciphertext
variable length
last 16 bytes
Auth Tag
GCM authentication
AlgorithmAES-GCM · 256-bit key (org key)
IV size96 bits (12 bytes) · random per operation
Auth tag128 bits · included in ciphertext
Overhead12 bytes IV + 16 bytes tag = 28 bytes per field
// Encrypt a plaintext field
const iv         = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, orgKey, plaintext)
const stored     = base64(iv || new Uint8Array(ciphertext))   // IV prepended

// Decrypt
const iv_        = stored.slice(0, 12)
const plaintext_ = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv_ }, orgKey, stored.slice(12))
Why a unique IV per field? AES-GCM is a stream cipher internally. If the same key + IV combination is ever reused, an attacker who sees two ciphertexts can XOR them together and partially recover the plaintext. By generating a fresh random 12-byte IV for every encryption call, this attack is made computationally infeasible (collision probability ≈ 2⁻⁹⁶ per pair).

Security Properties

The combined design provides the following provable security properties:

Zero-knowledge server
The database contains only ciphertext, public keys, and PBKDF2 salts. A full database dump reveals no plaintext, no passphrases, and no private keys.
Passphrase as the sole trust anchor
Your passphrase is the only secret that must be kept private. Everything else (public keys, encrypted blobs, org-key wrappings) can be public without compromising security.
No key escrow
There is no server-side copy of your private key or org key. Meritonis cannot decrypt your data on your behalf. Account recovery is impossible without your passphrase — by design.
Forward secrecy for org-key distribution
Ephemeral ECDH key pairs are used for every org-key wrapping. Compromising a user's long-term private key after the fact does not reveal past org-key distributions.
Brute-force resistance
PBKDF2 with 200,000 iterations raises the cost of each password guess ~200,000×. Combined with a user-unique salt, precomputed attacks (rainbow tables) are impossible.
Authenticated encryption
AES-GCM provides both confidentiality and integrity. Any tampering with a ciphertext (bit flipping, truncation, substitution) is detected with overwhelming probability via the authentication tag.
Browser-native cryptography
All cryptographic operations use the browser's built-in WebCrypto API (window.crypto.subtle). No third-party cryptography library is used for the core operations — only well-audited OS/browser primitives.

Algorithm Reference

All algorithms are standard primitives available in the W3C WebCrypto API. No custom cryptography is used.

AlgorithmParametersUsage
PBKDF2SHA-256, 200,000 iterations, 32-byte outputDerive wrapping key from passphrase
SHA-256Derive deterministic user salt from userId
AES-GCM256-bit key, 12-byte random IV, 128-bit auth tagWrap private key; encrypt org key; encrypt fields
ECDHP-256 (secp256r1), extractableKey agreement for org-key distribution
getRandomValuesWebCrypto CSPRNGIV generation, org key generation, API key tokens

Ready to get started?

All encryption happens automatically in your browser. Sign in to start managing your accounts and credentials with end-to-end encryption.

Sign In with Discord