Meander has two independent at-rest encryption stories: one for the comment store (the val's SQLite), one for walkthrough HTML blobs (Val Town blob storage, optional). Both use the same envelope scheme but differ in lifecycle because the data classes have different recoverability properties.
| Data class | Encrypted? | Key | Rotation |
|---|---|---|---|
Comment body + author |
Always (envelope) | MEANDER_DB_KEY_<n> |
Re-wrap DEKs, atomic generation flip |
| Comment metadata (id, file, lines) | No (indexable plaintext) | — | — |
| Walkthrough HTML in Val Town blobs | Opt-in (encryptBlobs) |
MEANDER_BLOB_KEY |
Re-publish under a fresh key |
| Walkthrough HTML on GitHub Pages | No (Pages-gated access) | — | — |
meander.css, manifest.json |
No | — | — |
| Magic-code hashes | One-way SHA-256 | (salted by email) | One-shot, ten-minute expiry |
| Session JWTs | Signed (HS256), not encrypted | MEANDER_JWT_SECRET |
Rotation logs every user out |
What the encryption defends against:
- Cold storage leak: someone obtains a SQLite dump or a blob snapshot independent of the val process. Without the wrapping key, they get ciphertext only.
- Val Town platform compromise: an employee or an attacker with platform-level access reads stored data. Same defense: ciphertext only, until they also obtain the val's env.
What it does not defend against:
- Live val compromise: an attacker with code execution inside the running val sees plaintext (the val must decrypt to serve).
- Reader-side leakage: anyone with a valid JWT (or anyone, for open reads) gets plaintext from the API.
- Custodian compromise: if more than
(shares − threshold)share-holders are compromised, the wrapping key is recoverable by an attacker.
Both encryption stories use the same construction:
- Data Encryption Key (DEK) — 32 random bytes. Encrypts the payload (a comment body, a walkthrough blob) with AES-256-GCM.
- Wrapping key — 32 random bytes. Encrypts the DEK with AES-256-GCM. The wrapped DEK is stored alongside the ciphertext.
This is the standard NIST envelope pattern, also known as "key-encryption keys + data-encryption keys" (KEK/DEK in cryptographic literature; we call it wrapping key + data key because the term "KEK" carries unfortunate cultural baggage). The benefit is that rotating the wrapping key only requires re-wrapping the (small) DEKs — comment ciphertext is never decrypted in a rotation.
Binary formats:
Body ciphertext [version 0x10] [12-byte IV] [ciphertext + 16-byte GCM tag] base64
Wrapped DEK [version 0x20] [12-byte IV] [32-byte ciphertext + 16-byte GCM tag] base64
Envelope blob "ENVELOPE:1:" + <wrappedDEK> + ":" + <body ciphertext> ASCII
The version bytes (0x10, 0x20) are reserved; future migrations
can introduce new layouts without breaking older readers' version
checks. The blob envelope's ENVELOPE:1: prefix is a literal text
sentinel — the val recognizes it without parsing, and falls back
to "serve as plaintext" when the prefix is absent.
Comments are encrypted unconditionally. Each row in the val's SQLite carries:
body,author— encrypted under a per-row DEK.dek_wrapped— that DEK, wrapped underMEANDER_DB_KEY_<key_generation>.key_generation— integer pointing at which generation's wrapping key wrapped this row's DEK.
MEANDER_DB_KEY_CURRENT is the integer pointer used for new
writes. Old generations stay live until every row that references
them has been re-wrapped (rotation) and the generation is retired.
The lifecycle commands are under meander db key:
| Command | Effect |
|---|---|
meander db key init |
First-time setup. Generates MEANDER_DB_KEY_1, plants MEANDER_DB_KEY_CURRENT=1, prints Shamir shares. |
meander db key rotate |
Reconstructs the current key from shares, mints MEANDER_DB_KEY_<N+1>, drives /admin/rewrap to re-wrap every row, atomically flips MEANDER_DB_KEY_CURRENT, prints new shares. |
meander db key restore |
Reassembles a wrapping key from shares + plants it on the val. Used after env-var loss. |
meander db key audit |
Prints visible generations, the current pointer, and per-generation row counts. |
meander db key retire <N> |
Removes MEANDER_DB_KEY_<N> from env. Pre-flights audit; refuses if any rows still reference generation N. |
The wrapping key never leaves the val after init. The operator's
machine doesn't hold it; only the custodians' shares do.
Most projects publish walkthroughs to GitHub Pages, where GitHub's own access controls and at-rest encryption are sufficient and Val Town blob storage isn't involved. For those projects, walkthrough HTML encryption is irrelevant and not engaged.
Projects publishing to Val Town blob storage (meander publish) opt in via meander.config.json:
{
"encryptBlobs": true
}When enabled, meander publish:
- Generates a per-blob DEK (random 32 bytes).
- Encrypts the HTML with the DEK.
- Wraps the DEK with the operator's
MEANDER_BLOB_KEY. - Uploads
ENVELOPE:1:<wrappedDEK>:<ciphertext>.
The val recognizes the ENVELOPE: prefix and decrypts before
serving (gated by JWT auth). Plaintext blobs (no prefix) are served
as-is. The val and the operator both hold MEANDER_BLOB_KEY —
the val needs it to serve, the publisher needs it to encrypt.
The lifecycle commands are under meander blob key:
| Command | Effect |
|---|---|
meander blob key init |
First-time setup. Generates MEANDER_BLOB_KEY, plants it on the val, prints Shamir shares + a shell snippet. |
meander blob key rotate |
Reconstructs the current key from shares, mints a new key, plants it on the val, prints new shares + a shell snippet for the operator's local env. After rotation, re-publish (existing blobs become unreadable until then). |
meander blob key restore |
Reassembles MEANDER_BLOB_KEY from shares + plants it on the val. Used after env-var loss. |
meander blob key show |
Prints the val's current MEANDER_BLOB_KEY in hex. Bare output (pipe to pbcopy / a password manager). |
There's no rewrap dance for blobs because blobs are regenerable from source. Rotation = re-publish, which the CLI prompts explicitly.
Both ceremonies split their wrapping key with Shamir's Secret
Sharing before printing it. The operator distributes shares to
distinct custodians; reconstruction needs threshold of them.
Defaults: 2-of-3 (operator's password manager, paper printout in a safe, second person's password manager). Tune via flags:
meander db key init --threshold 2 --shares 3 # default
meander db key init --threshold 3 --shares 5 # serious-org default
meander db key init --threshold 4 --shares 7 # belt-and-suspendersConstraints:
threshold >= 2(1-of-N is plaintext)threshold <= sharesshares <= 255(GF(2^8) limit)
Shares are base58-encoded (Bitcoin alphabet — no 0/O/I/l
ambiguity). The encoded form carries version + threshold + the
share's x-coordinate inline, so combine() validates without
external metadata.
What share-loss tolerance buys you:
- A 2-of-3 split tolerates losing any one custodian's share.
- A 3-of-5 split tolerates losing any two.
- A
T-of-Ssplit tolerates losingS - Tshares.
What it costs: every share you add is one more place that can leak. Custodian count should match real custodian independence — five entries in the same password manager is one custodian, not five.
Lost the local copy of MEANDER_DB_KEY_<n>, but the val still
has it. Nothing to recover — the val is the source of truth.
You only "lose" a db key because comments stop decrypting; if
they're decrypting, the val has the key.
Lost the val's MEANDER_DB_KEY_<n> env var (it was wiped or
the val was deleted). Reassemble from shares:
meander db key restore walkthrough --threshold 2
# (interactive: prompts for 2 shares)Lost more than (shares - threshold) shares. The wrapping
key is unrecoverable. Comment ciphertext is permanently
undecryptable. Walkthrough blobs (if encrypted) are recoverable
only by re-publishing under a fresh MEANDER_BLOB_KEY.
Suspected key compromise. Rotate immediately:
meander db key rotate walkthrough --threshold 2
meander blob key rotate walkthrough --threshold 2 # if encryptBlobs: true
meander publish meander.config.json # re-publish blobsThe old generation stays in the val's env until you confirm via audit + retire that no rows reference it anymore. After retire, the old key is gone from the val and from local memory; only shares remain, in custodian hands.
See operating.md for the runbook-format day-2 ops guide: rotation cadence, custodian responsibilities, backup strategy, restoration drills.