Violet
Blog

How Violet's encryption works, line by line

By The Violet build · published 2026-07-04 · updated 2026-07-04 · 8 minute read

Most encryption claims are marketing sentences. This post is the code instead: a line-by-line walk through the file that seals Violet's synced data, from passphrase to PBKDF2-SHA256 at 200,000 iterations to a non-extractable AES-256-GCM key to the two-field envelope the server stores. It ends with what a database thief actually gets and the honest limits of the design.

Every product with a cloud says it is encrypted. Almost none of them show you the code. This post walks through the actual file that encrypts Violet's synced data, line by line, with the real constants and the real function bodies. The file is small on purpose: it is shared verbatim by the server, the browser client, and the test suite, so there is exactly one implementation to read and one to audit. Nothing below is a simplification. It is the code.

The whole scheme in three constants

const PBKDF2_ITERS = 200_000; // >= 200k per the spec
const KEY_BITS = 256; // AES-GCM 256
const NONCE_LEN = 12; // AES-GCM 96-bit nonce

Three numbers define the whole scheme. PBKDF2_ITERS is how many times the key derivation function iterates over your passphrase: 200,000 rounds of PBKDF2 with SHA-256. KEY_BITS is the size of the AES key that comes out: 256 bits, the larger of the two common AES sizes. NONCE_LEN is the size of the random value generated fresh for every encryption: 12 bytes, which is the 96-bit nonce length that the GCM specification is designed around. There are no other tunable parameters, no cipher negotiation, and no downgrade path. One scheme, fixed, in the open.

Step 1: from passphrase to key

export async function deriveContentKey(passphrase, kdfSaltB64) {
  const baseKey = await crypto.subtle.importKey(
    "raw",
    enc.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt: b64ToBytes(kdfSaltB64), iterations: PBKDF2_ITERS, hash: "SHA-256" },
    baseKey,
    { name: "AES-GCM", length: KEY_BITS },
    false, // non-extractable: the key cannot be read back out and shipped anywhere
    ["encrypt", "decrypt"],
  );
}

This function runs on your device, never on the server. Line by line: importKey takes the raw bytes of your passphrase and hands them to the browser's built-in cryptography engine, the Web Crypto API. The false on that first call means even this intermediate key object cannot be exported back out of the engine. Then deriveKey runs PBKDF2: it mixes the passphrase with a salt and iterates SHA-256 200,000 times to produce a 256-bit AES-GCM key.

Each of those choices has a plain reason. PBKDF2 is deliberately slow: 200,000 iterations cost you a fraction of a second once at unlock, but they multiply the cost of every single guess an attacker makes by 200,000. The salt is a random 16-byte value generated once per account when the account is created. It is not a secret; its job is to make the same passphrase produce a different key for every account, which defeats precomputed guessing tables. The server stores the salt and hands it back at sign-in so that every one of your devices can re-derive the identical key from the same passphrase.

The most important single token in the function is the second false, on the derived key itself. In the Web Crypto API that flag means non-extractable: the platform will refuse any request to export the key material. The key exists only inside the crypto engine of your browser, only for the current session, and can only be used to encrypt and decrypt. It is never written to disk, never serialized, and there is no code path that could send it to the server, because the platform will not surrender the bytes to any code at all.

Step 2: sealing a record

export async function seal(key, plaintextObj) {
  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
  const data = enc.encode(JSON.stringify(plaintextObj));
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, data);
  return { nonce: bytesToB64(nonce), ciphertext: bytesToB64(new Uint8Array(ct)) };
}

Four lines. First, a fresh 96-bit nonce is drawn from the platform's cryptographically secure random generator. A nonce is a number used once: GCM's security depends on never encrypting two different messages under the same key with the same nonce, so seal never reuses one and never lets a caller supply one. Second, the record (a plain object) is serialized to JSON and encoded to bytes. Third, AES-256-GCM encrypts those bytes under the derived key. The output includes an authentication tag, which will matter in the next section. Fourth, the nonce and the ciphertext are base64-encoded and returned as a two-field envelope:

{ "nonce": "<base64>", "ciphertext": "<base64>" }

That envelope is the entire content the server ever receives for a record. The nonce travels with the ciphertext because it is needed to decrypt and is safe to expose; a nonce is not a key and reveals nothing about the plaintext. Base64 is just transport: it turns raw bytes into text that survives JSON and a database column unchanged.

Step 3: opening, and failing closed

export async function open(key, nonce, ciphertext) {
  const pt = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: b64ToBytes(nonce) },
    key,
    b64ToBytes(ciphertext),
  );
  return JSON.parse(dec.decode(pt));
}

Decryption is the mirror image, with one property doing quiet heavy lifting: GCM is authenticated encryption. Every ciphertext carries a tag computed over the data, and decrypt verifies that tag before returning a single byte. If the key is wrong, or if anyone flipped so much as one bit of the nonce or ciphertext in storage or in transit, the call throws. There is no mode where tampered data decrypts to something subtly wrong and flows onward. The system fails closed, loudly.

What the server stores, exactly

The server side of this protocol validates envelopes for shape and size and stores them opaquely. Two tables hold everything. Here is every column that relates to your content, and what each one is.

Stored valueWhat it isCan the server read your content with it?
emailYour account identifierNo. It is who you are, not what you wrote.
pw_hash + pw_saltA PBKDF2-SHA256 hash of your sign-in password, used only to verify login (compared in constant time)No. It gates the API; it is never used to derive the content key.
kdf_saltThe random per-account salt your devices use in deriveContentKeyNo. A salt without the passphrase derives nothing.
collection, record_idWhich bucket a record belongs to and its opaque idNo. This is addressing metadata.
version, updated_at, deletedMerge metadata for last-writer-wins sync and tombstoned deletesNo. Sync merges on these numbers without opening anything.
nonce, ciphertextThe sealed envelope itselfNo. Without the key this is random-looking bytes, and the key never left your device.

Notice what is absent: there is no key column, no key escrow, no recovery blob, and no server-side code path that calls decrypt on an envelope. The functions deriveContentKey, seal, and open live in the shared protocol file, but they are invoked only by the client and the tests. The worker's own crypto helpers handle password verification and session tokens and never touch a content key. This is checkable by reading the code, which is the point of publishing it.

What an attacker who steals the database gets

Assume the worst case: an attacker walks away with a full copy of the database. Here is their haul, honestly itemized.

So the attacker's only route to your content is the same one they had before the breach: guess your passphrase, running 200,000 PBKDF2 iterations per guess, per account. Against a long, unique passphrase that is not a practical attack. Against a short or reused one, it can be. That boundary belongs in the next section, not in fine print.

The honest limits

Why this post exists

You should not have to trust an encryption claim you cannot check. Violet is pre-launch, so the honest proof available today is not testimonials or audits; it is the design itself, stated precisely enough to be falsifiable. This post is that statement: the constants, the functions, the storage schema, and the failure modes, with nothing rounded up. If any of it stops being true of the shipped code, this page is wrong and should be called out.

Questions

Why store the salt on the server if the key must stay secret?

Because a salt is not a secret. Its job is to make the same passphrase produce a different key for every account, which defeats precomputed guessing tables. Storing it server-side lets each of your devices re-derive the identical key from your passphrase at sign-in, while the derived key itself never exists anywhere but inside your device's crypto engine.

Why PBKDF2 instead of Argon2 or scrypt?

PBKDF2 is the one password-based derivation function built into the Web Crypto API, which means it runs natively in the browser, in the Workers runtime, and in Node from the same file with zero dependencies. Argon2 has better resistance to GPU attacks but would require shipping third-party cryptographic code to the client. Using the platform primitive keeps the implementation small, auditable, and identical everywhere, at the cost of needing a healthy iteration count, which is why the constant is 200,000 and lives on one visible line.

What happens if two records are encrypted with the same nonce?

Nonce reuse under the same key is the classic way to break GCM, which is why seal generates a fresh random 96-bit nonce inside the function on every call and never accepts one from the caller. At random, a collision becomes a concern only near the NIST usage bound of roughly four billion messages per key, orders of magnitude beyond personal sync volume.

If I forget my passphrase, can support recover my data?

No, and that is the design working, not a support gap. The server holds envelopes, salts, and password hashes, none of which can derive the content key. A provider that can recover your content after a forgotten passphrase is a provider that holds a key to your content. The trade is stated plainly: no recovery is the price of nobody else being able to read it.

Related pages

Sources