Plan: Client-Side Encryption of R2 Tenant Backups
On this page
Status: In implementation (WS1-WS4) | Date: 2026-07-26
1. Goal & Threat Model
Ensure Cloudflare account administrators (anyone with dashboard/R2 browse access) cannot read tenant data at rest. Per PRIVACY.md, message content, transcripts, API keys, and personal conversations are “never collected, stored, or transmitted” - but they are persisted as plaintext squashfs snapshots in BACKUP_BUCKET. This plan closes that gap.
2. What Must Be Encrypted (mapped to PRIVACY.md)
| R2 object | Contents | Sensitive? | Action |
|---|---|---|---|
backups/<id>/data.sqsh | Full /home/openclaw snapshot: OpenClaw memories, session transcripts, SQLite DBs, auth-profiles.json (API keys/OAuth tokens), workspace/knowledge files | Yes - primary target | Encrypt (streaming) |
configs/<ts>.json | openclaw.json versions (secret-free by moltlazy design, but private config) | Moderate | Encrypt (single-shot) |
backups/<id>/meta.json | id, dir, sizeBytes, ttl, createdAt | No - SDK restore requires it readable | Keep plaintext, add encrypted: true flag |
backup-handle.json, backup-lock, restore-needed, cron store | Operational metadata | No | Leave plaintext |
3. Key Architectural Finding (verified in SDK source)
@cloudflare/sandbox production createBackup/restoreBackup transfer data.sqsh
via presigned URLs directly between container and R2 - the Worker’s R2 binding cannot
transparently intercept. doRestoreBackup also requires plaintext data.sqsh to exist
(heads it for size) and validates meta.json TTL.
Chosen design: Worker-side post-encryption with transient decrypt-before-restore:
- Backup:
createBackup()returns -> Worker streamsdata.sqsh-> chunked AES-256-GCM -> writesdata.sqsh.enc-> patchesmeta.json(encrypted: true, original fields preserved) -> deletes plaintext. - Restore: if
meta.encrypted-> decrypt.enc-> write plaintextdata.sqsh->sandbox.restoreBackup()-> delete plaintext infinally. - Residual risk (documented): two short plaintext windows - (a) between SDK upload and post-encryption (~seconds per backup cycle), (b) during restore. Phase 2 (future) closes (a) via container-side encrypt-before-upload replacing the SDK presigned flow.
4. Crypto Format & Key Management
- Algorithm: AES-256-GCM (WebCrypto), 4 MiB chunks, nonce = 8-byte random base || u32 BE counter per chunk. Streaming via TransformStream (Worker memory is bounded).
- Header (17 B): magic
F4E1(4B) | version u8=1 | baseNonce (8B) | chunkSize u32 BE. - Key:
BACKUP_ENCRYPTION_KEYWorker secret (base64 32 B). Per-object key via HKDF-SHA256(salt=objectId, info=“fouria-r2-encryption/v1”). Key never enters the container, R2, or logs. - Missing key: encrypt when key present; otherwise legacy plaintext + loud warning.
BACKUP_ENCRYPTION_REQUIRED=truefails closed (recommended for prod).
5. Workstreams
| WS | Scope | Tests |
|---|---|---|
| WS1 | src/crypto/backup-encryption.ts | backup-encryption.test.ts: roundtrip, chunk boundaries, tamper/wrong-key/truncation -> throw, HKDF determinism, streaming |
| WS2 | src/persistence.ts + src/crypto/r2-backup.ts | plaintext deleted after backup; meta flag; restore writes+deletes plaintext even on failure; legacy plaintext restore; .enc cleanup |
| WS3 | saveConfigVersion/getConfigVersion | roundtrip; legacy plaintext readable; API shape unchanged |
| WS4 | types, .dev.vars.example, ENV-VARIABLES.md, secrets manifest, PRIVACY.md, AGENTS.md, business-rules corpus | test/business-rules/backup-encryption.txt |
| WS5 | E2E (deployed/local container) | snapshot object starts with F4E1, not mountable; restore works - deferred to deployed env validation |
6. Validation
bun run typecheck | bun run lint | bun run test | cctr apps/fouria/test/business-rules/