Encryption at rest
AES-256-GCM envelope encryption for sensitive database fields.
OpenPlaud encrypts user content fields in the database with AES-256-GCM,
keyed off the ENCRYPTION_KEY environment variable that the runtime
already requires.
This is server-held-key envelope encryption. It is not zero-knowledge.
What is encrypted
| Column | Type | Notes |
|---|---|---|
recordings.filename | text | Often carries topic info (e.g. AI-generated titles) |
transcriptions.text | text | Full transcript |
ai_enhancements.summary | text | LLM summary |
ai_enhancements.key_points | jsonb | Stored as { "c": "v1:..." } envelope |
ai_enhancements.action_items | jsonb | Same envelope shape |
user_settings.summary_prompt | jsonb | Custom prompt configs may carry user context |
user_settings.title_generation_prompt | jsonb | Same |
Pre-existing encryption (unchanged):
plaud_connections.bearer_tokenapi_credentials.api_keystorage_config.s3_config
Out of scope for this layer:
- Audio files in storage (local FS / S3). Object-level encryption is a separate, heavier change. For S3, prefer server-side encryption at the bucket level today; revisit per-object app-layer encryption later.
- Search. No full-text search on transcripts is implemented today, so
encrypting
textcauses no regression. If search lands later, it will need a tokenized HMAC index — not this PR.
What this protects against
- Stolen DB backups or snapshots
- Read-replica access without app-server access
- A SQL-injection that reads but does not execute application code
- Operators with DB-only access (e.g. via an admin console) cannot read content without also having the app-server key
What this does not protect against
- A compromised application server. The server holds the key and decrypts content at request time so it can run AI on it. This is unavoidable while server-side transcription and summarization are part of the product.
- A compromised
ENCRYPTION_KEY. Treat the key like a database master credential. - A compromised AI provider. Plaintext is sent to whichever transcription / enhancement provider the user configured. That trust boundary is the user's choice and is independent of this layer.
If you need to keep plaintext audio and transcripts off third-party infrastructure entirely, run a local AI provider (Ollama or LM Studio) on the same machine as your OpenPlaud instance. The server still holds the encryption key, but no plaintext leaves the box.
Key management
ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Generate one
with:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"Losing the key makes encrypted content unrecoverable. Backup the key separately from the database.
Backup files produced by /api/backup are plaintext by design — they
are the user's own export. Store them with the same care you'd give the
unencrypted database.
Versioning and key rotation
New ciphertext written by this layer is prefixed with v1:. Older formats
are still readable:
v1:iv:tag:hex— current. Identifies the key/version that produced it.iv:tag:hex(no prefix) — pre-existing format used for Plaud bearer tokens and AI keys. Read path tolerates it.- Anything else — treated as legacy plaintext and returned verbatim. This is the deploy → backfill compatibility window.
Key rotation is not implemented yet. The v1: prefix exists so a future
rotation pass can identify which rows need re-encrypting.
Backfill
Pre-rollout rows stay plaintext until rewritten. The read path tolerates both shapes during this window. To eagerly encrypt existing rows:
Docker / self-host (the supported path): the script is bundled into the
image at /app/encrypt-backfill.js.
# Report what would change (no writes)
docker compose exec app bun encrypt-backfill.js --dry-run
# Apply
docker compose exec app bun encrypt-backfill.jsFrom a source checkout (development):
bun scripts/encrypt-backfill.ts --dry-run
bun scripts/encrypt-backfill.tsThe script is idempotent (skips rows already in v1: form), batched, and
safe to interrupt and resume.
Last updated on