Encryption
plasma has two encryption stories layered on top of each other:
- At-rest, client-local — the browser engine wraps
.encrypted()cells in anEnvelopebefore they hit IDB, so a device-side snapshot of the browser’s IDB store never reveals plaintext. - 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.
The .encrypted() marker
Section titled “The .encrypted() marker”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.
Story 1 — At-rest client-local
Section titled “Story 1 — At-rest client-local”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 anEnvelopeviaencryptField()before it hits IDB. If a device-side dump readsnotes.body, they see{v:1, alg:"AES-GCM-256", keyId:"k1", nonce:"...", ct:"..."}— opaque bytes. - Pull:
decryptPatchinsync/client.tssees theEnvelopeon incoming rows and unwraps them before they land in IDB.
What this DOES protect against
Section titled “What this DOES protect against”- Device seizure / IDB scrape. Chrome’s IndexedDB dir is
world-readable to anyone with local disk access.
.encrypted()columns are opaque to that reader.
What this DOES NOT protect against
Section titled “What this DOES NOT protect against”- A compromised or malicious server operator. The client’s
outbox pushes plaintext across the wire (
argsare 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.
Envelope wire format
Section titled “Envelope wire format”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.
Key rotation
Section titled “Key rotation”The keyId field lets you rotate.
- Introduce a new DEK (
k2). - New writes go under
k2. Existing envelopes underk1still decrypt because you retain thek1key. - A background job re-writes existing envelopes under
k2— a scheduled Worker callingdecryptField(k1, ...)and re-encrypting withk2per row. - Once every envelope is under
k2, dropk1.
plasma doesn’t ship the re-encryption loop; you’d write it as a mutator against a table that scans batches.
PQ hybrid (crypto-pq)
Section titled “PQ hybrid (crypto-pq)”For deployments that need post-quantum protection layered on top of
AES-GCM, @sh1n4ps/plasma-core also ships:
PqEnvelope— wraps a classicEnvelopeinside{ v, kind: "pq-hybrid", kem: { alg, ct }, inner }PqHybridProvider— a pluggable interface withwrap()/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.
What to read next
Section titled “What to read next”- 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