Skip to content

Encryption

plasma has two encryption stories layered on top of each other:

  1. At-rest, client-local — the browser engine wraps .encrypted() cells in an Envelope before they hit IDB, so a device-side snapshot of the browser’s IDB store never reveals plaintext.
  2. End-to-end against the operator — the server should never see plaintext for a marked column. Requires the mutator body to explicitly call encryptField().

Both stories share the Envelope wire format and the AES-GCM-256 primitive. They differ in where the encryption boundary sits.

import { text } from "@sh1n4ps/plasma-core"
const notes = table("notes", {
id: id(),
title: text(),
body: text().encrypted(),
})

.encrypted() sets a meta.encrypted flag on the column. This flag is what the client engine reads to decide whether to auto-wrap on insert / update.

The marker alone does nothing — you also need to supply a DEK when constructing the client.

The simplest wiring. Pass an encryption config to createPlasmaClient:

import { createPlasmaClient } from "@sh1n4ps/plasma-client"
const dek = await deriveKeyFromPassword(userPassword)
const plasma = createPlasmaClient({
schema,
mutators,
endpoint: "/sync",
dbName: "notes",
schemaVersion: "v1",
clientGroupID: user.id,
getContext: async () => ({ userId: user.id }),
encryption: {
dek, // Uint8Array, 32 bytes
keyId: "k1", // identifier for future key rotation
},
})

Now:

  • Insert / update: the client engine sees the .encrypted() marker, wraps the value in an Envelope via encryptField() before it hits IDB. If a device-side dump reads notes.body, they see {v:1, alg:"AES-GCM-256", keyId:"k1", nonce:"...", ct:"..."} — opaque bytes.
  • Pull: decryptPatch in sync/client.ts sees the Envelope on incoming rows and unwraps them before they land in IDB.
  • Device seizure / IDB scrape. Chrome’s IndexedDB dir is world-readable to anyone with local disk access. .encrypted() columns are opaque to that reader.
  • A compromised or malicious server operator. The client’s outbox pushes plaintext across the wire (args are as-declared). The server sees plaintext and writes plaintext to D1.
  • An MITM on the wire. Use HTTPS; this is transport, not content, encryption.
  • Another tab of the same client that has the DEK. Same-origin.

Story 2 — E2EE against the operator (manual)

Section titled “Story 2 — E2EE against the operator (manual)”

If your threat model requires the server to never see plaintext, you need to encrypt inside the mutator body before passing the value to db.insert(). Both the client’s optimistic run and the server’s canonical run then observe the envelope.

import { encryptField } from "@sh1n4ps/plasma-core"
const notes = table("notes", {
id: id(),
body: text().encrypted(),
})
export const mutators = defineMutators<typeof schema, Ctx>()({
writeNote: async ({ db, args, ctx }) => {
// Encrypt on both sides. The server-side run of this mutator
// computes the envelope too — using the same DEK the client
// used, because the DEK is threaded through ctx (or a
// per-user KMS lookup in a real deploy).
const envelope = await encryptField(
ctx.dek,
{ v: 1, table: "notes", rowId: args.id, column: "body", keyId: "k1" },
args.body,
)
await db.insert(notes).values({
id: args.id,
body: envelope,
})
},
})

The catch: ctx.dek must be reachable from both sides. Client provides it via getContext(); server via auth(). In a real E2EE deploy the server would fetch a per-user envelope-encryption key that wraps the actual DEK — the operator holds the wrapping key, not the wrapped DEK.

interface Envelope {
readonly v: 1
readonly alg: "AES-GCM-256"
readonly keyId: string
readonly nonce: string // base64 12-byte
readonly ct: string // base64 ciphertext + tag
}

The AAD (Authenticated Additional Data) is derived from (table, rowId, column, keyId) and enforces that a valid envelope for one column can’t be swapped into another (a re-parenting attack is caught by the decrypt).

validateEnvelope(env, { maxCiphertextBytes, allowedKeyIds }) is what the server-side sync-handler runs on every incoming mutation’s args — a PushRequest carrying an envelope with a disallowed keyId or oversized ciphertext is rejected.

The keyId field lets you rotate.

  1. Introduce a new DEK (k2).
  2. New writes go under k2. Existing envelopes under k1 still decrypt because you retain the k1 key.
  3. A background job re-writes existing envelopes under k2 — a scheduled Worker calling decryptField(k1, ...) and re-encrypting with k2 per row.
  4. Once every envelope is under k2, drop k1.

plasma doesn’t ship the re-encryption loop; you’d write it as a mutator against a table that scans batches.

For deployments that need post-quantum protection layered on top of AES-GCM, @sh1n4ps/plasma-core also ships:

  • PqEnvelope — wraps a classic Envelope inside { v, kind: "pq-hybrid", kem: { alg, ct }, inner }
  • PqHybridProvider — a pluggable interface with wrap() / unwrap(). Callers plug in ML-KEM / X-Wing (from an external library) via this interface.
  • encryptFieldPq(provider, aad, value) / decryptFieldPq(provider, aad, envelope)
  • insecurePlaceholderProvider(dek, { acceptInsecure: true }) — staging only, provides ZERO cryptographic protection, throws without the explicit opt-in

The actual ML-KEM / X-Wing primitive is caller-supplied — plasma doesn’t bundle it because it drags a multi-megabyte dependency and the space of desirable KEMs is evolving too fast to freeze at v1.0.

  • Auth and permissions — the request-level auth that gates who sees encrypted rows at all
  • Roadmap — the args-boundary walker (Phase 4.1) that will remove the manual encrypt-in-mutator ceremony