Encryption and keys
A two-tier envelope — one data key per workspace, wrapped under one deployment key — and the commands that prove and rotate it.
The envelope
Every workspace owns a 32-byte AES-256-GCM data-encryption key (DEK). The control database stores that key only in wrapped form, encrypted under the deployment's key-encryption key (KEK), together with the id of the provider that wrapped it. Unwrapping happens in the server process when a workspace is opened; the unwrapped DEK is held in memory there and is not written to a tenant database, a blob store or a log.
The KEK comes from VITRINKA_KEK, 64 hex characters. When it is unset the server falls back to VITRINKA_MASTER_KEY, so sealing is on from the first boot with no extra configuration; a VITRINKA_KEK that is present but malformed refuses to boot rather than silently running unsealed. With VITRINKA_KEYS_PROVIDER=openbao the KEK is not in the environment at all — it stays inside a transit engine and the server only ever sends wrapped keys to it — see The key store.
What is sealed
| Data | Format | Bound to |
|---|---|---|
| Sensitive tenant columns — recorded-session and bug-report capture events, annotation thread messages and revision prompts, page documents and their edit history, project memory, frozen share snapshots, screenshot and file text maps, work-time records, review-judgment jobs and feedback | enc2 — version, key id, 12-byte nonce, AES-256-GCM | workspace id + content kind |
| Every blob — uploads, screenshots, recordings, thumbnails' sources | enc2 with kind blob | workspace id + blob |
| Backup archives and other operator artifacts | enc2f — a fresh wrapped DEK, then framed 4 MiB AES-256-GCM chunks | frame index + frame type |
The associated data is what makes the binding real: a ciphertext copied from one workspace's column into another's, or from one column into a different kind, fails authentication instead of decrypting. In the streaming format every frame carries its index and type, so a reordered, dropped or truncated backup fails loudly rather than restoring a partial tree.
What the table does not list is stored as written and protected by volume encryption and access control: the rest of a board (cards other than pages, their positions and labels), tasks, the full-text search indexes — which hold text derived from sealed content — and identifiers and timestamps. The hosted service's data volume uses LUKS2 encryption; a self-hosted deployment supplies its own.
Object storage
With BLOB_BACKEND=s3 the server also asks the bucket for server-side encryption: VITRINKA_S3_SSE is aes256 by default, kms:<key id> for a customer-managed key, or off. A backend that rejects the header degrades to off with one line in the boot log; the client-side enc2 sealing is what confidentiality rests on either way, so a bucket that refuses SSE still holds only ciphertext.
The read-through cache that fronts S3 (BLOB_CACHE_MAX_GB) stores the same ciphertext the bucket holds. A stolen cache directory yields nothing a stolen bucket would not.
Strict mode and the sealing pass
Deployments that predate encryption may hold rows and objects written in plaintext. By default the server reads those through (dual-read) so an upgrade never breaks a read. VITRINKA_SEAL_STRICT=on turns every such encounter into a named refusal — the switch to flip once the sealing pass has proven there is nothing left to refuse.
vitrinka seal-existing -data /data # dry-run census, writes nothing
vitrinka seal-existing -data /data -manifest /root/seal.json -execute # seal every plaintext row and object in place
vitrinka seal-existing -data /data -verify # the gate: zero unsealed items, every leftover namedThe executor seals row by row with a seal-open round trip before each update and an equality-guarded write, so a row the live server rewrites mid-pass is never clobbered. -verify exits non-zero when anything is left and when nothing was in scope — a gate that verified nothing never passes.
Flip VITRINKA_SEAL_STRICT=on only after an unfiltered -verify run prints its pass line. Strict mode with leftover plaintext turns those items into read errors, by design.
Rotating the deployment key
Rotating the KEK never re-encrypts data: it unwraps each workspace DEK under the old KEK and rewraps it under the new one, one transaction per row. The DEK bytes do not change, so nothing sealed under them is touched, and a run interrupted halfway is finished by running it again.
vitrinka rewrap-deks -data /data -old-kek <hex> -new-kek <hex> # dry-run
vitrinka rewrap-deks -data /data -old-kek <hex> -new-kek <hex> -executeThe keys may come from VITRINKA_REWRAP_OLD_KEK and VITRINKA_REWRAP_NEW_KEK instead of flags, keeping them out of ps and shell history. Run it with the server stopped: a live process still holds the old KEK, and the boot-time key check confirms the new one before serving resumes. Adopting a dedicated VITRINKA_KEK on a deployment that ran on the master-key fallback is itself a rotation — the old KEK is the master-key value.
The key store
VITRINKA_KEYS_PROVIDER decides who holds the KEK. The default, env, is the envelope above: the KEK sits in the server's environment and nothing about keys leaves the host. openbao moves the KEK into an OpenBao transit key. The server never sees that key: it sends each wrapped DEK to the transit engine to be decrypted and receives the DEK back, so a copy of the data host's disks yields wrapped keys and no key to open them. The running server holds a workspace's DEK in memory only while that workspace is open, and every unwrap is an audit row. The transit key is created non-exportable, so a dump of the key server yields no KEK either, and rotating it in OpenBao is a rewrap pass, exactly like rotating VITRINKA_KEK.
This is the topology the hosted service is moving to: the transit engine lives on a dedicated key server that hosts only secret stores, reached over a private WireGuard network, and each of the two servers is unsealed at boot by material held on the other. The security overview states which posture production runs at any given time. A self-hosted deployment keeps env unless its operator chooses otherwise; under env the server makes no outbound call for keys, so an air-gapped install is unaffected by this section.
| Variable | Default | Notes |
|---|---|---|
VITRINKA_KEYS_PROVIDER | env | env or openbao. |
VITRINKA_OPENBAO_ADDR | — | The transit engine's URL, private network only. Required for openbao. |
VITRINKA_OPENBAO_MOUNT | transit | The transit mount path. |
VITRINKA_OPENBAO_KEY | vitrinka-kek | The transit key name; each wrapped DEK is stamped with the provider id openbao:<key>. |
VITRINKA_OPENBAO_ROLE_ID VITRINKA_OPENBAO_SECRET_ID | — | AppRole login. Bind the role to the data host's private address on the OpenBao side. Secret. |
VITRINKA_OPENBAO_TOKEN | — | A static token instead of AppRole — development only. Secret. |
VITRINKA_OPENBAO_CA | system roots | A CA bundle for a private certificate on the transit endpoint — a PEM file path, or the PEM text itself (a value starting with -----BEGIN; literal \n sequences are unescaped) where the container mounts no host file. |
VITRINKA_DEK_REVERIFY | 15m | How often every open workspace's DEK is re-verified against the key store. |
The boot check
At boot the server unwraps the master key through the provider, then runs the same key check env runs: every stored wrapped DEK is decrypted through transit once, and each decrypt lands as a workspace.dek_unwrap row in the audit trail with reason verify. Any failure — the engine unreachable, the AppRole refused, a DEK that no longer decrypts — refuses the boot with the remedy named, exactly as a malformed VITRINKA_KEK does. A server never serves on a key store it could not prove.
Re-verification
Opening a workspace unwraps its DEK through transit (reason open) and holds it in memory. Every VITRINKA_DEK_REVERIFY the tenant pool decrypts each open workspace's wrapped DEK again (reason reverify); a mismatch or a refusal evicts the workspace from the pool and records the result as denied. A key revoked or rotated away in OpenBao therefore stops serving within one interval, with no restart. The loop never holds the pool lock across the network call, so a slow key server delays a re-verify, not a request. Rotation records reason rewrap; cryptographic deletion unwraps nothing and is recorded on its own workspace.dek.shred row, which now also names the provider. The key server keeps its own audit device as the independent record of the same calls.
Moving into and out of the key store
rewrap-deks is the migration door in both directions. -to openbao rewraps every DEK from the environment KEK into the transit key, one transaction per row, and stamps the new provider id beside each; the per-row provider id is what lets env and openbao rows coexist mid-run, so an interrupted pass is finished by running it again. The master key moves with it: under openbao it is stored wrapped in the control database and unwrapped once at boot, so the environment holds only the AppRole credential and no long-lived key material.
vitrinka rewrap-deks -data /data -old-kek <hex> -to openbao # dry-run: counts rows per provider, writes nothing
vitrinka rewrap-deks -data /data -old-kek <hex> -to openbao -execute
vitrinka rewrap-deks -data /data -to env -new-kek <hex> -execute # back out: rewrap under an environment KEK againRun it with the server stopped, with the OpenBao variables set for the openbao side and the environment KEK (flag or VITRINKA_REWRAP_OLD_KEK / VITRINKA_REWRAP_NEW_KEK) for the env side; the next boot's key check proves the destination before serving resumes.
Leaving the key store is a deliberate downgrade: after -to env the KEK is back in the environment and the unwrap trail ends at the host. Do it only to decommission a key server you are replacing, and rewrap into its successor first.
When the key store is unreachable
Boot fails closed. A running server degrades one workspace at a time: workspaces already open keep serving until their next re-verify fails, a workspace that is not open cannot be opened and its requests answer 503 naming the key store. A failed re-verify is logged and counted on vitrinka_tenant_dek_reverify_failures_total, served on the VITRINKA_METRICS_ADDR listener — alert on it from your monitoring. A short network blip costs nothing; a long outage ends service within one VITRINKA_DEK_REVERIFY interval rather than all at once. Restore the key server and the next open succeeds without a restart.
Audit export
The audit trail leaves the host as hash-chained batches signed with the deployment's own key; the variables, the targets and the audit-verify routine are on Audit trail.