F FourIA GitHub ↗

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 objectContentsSensitive?Action
backups/<id>/data.sqshFull /home/openclaw snapshot: OpenClaw memories, session transcripts, SQLite DBs, auth-profiles.json (API keys/OAuth tokens), workspace/knowledge filesYes - primary targetEncrypt (streaming)
configs/<ts>.jsonopenclaw.json versions (secret-free by moltlazy design, but private config)ModerateEncrypt (single-shot)
backups/<id>/meta.jsonid, dir, sizeBytes, ttl, createdAtNo - SDK restore requires it readableKeep plaintext, add encrypted: true flag
backup-handle.json, backup-lock, restore-needed, cron storeOperational metadataNoLeave 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 streams data.sqsh -> chunked AES-256-GCM -> writes data.sqsh.enc -> patches meta.json (encrypted: true, original fields preserved) -> deletes plaintext.
  • Restore: if meta.encrypted -> decrypt .enc -> write plaintext data.sqsh -> sandbox.restoreBackup() -> delete plaintext in finally.
  • 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_KEY Worker 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=true fails closed (recommended for prod).

5. Workstreams

WSScopeTests
WS1src/crypto/backup-encryption.tsbackup-encryption.test.ts: roundtrip, chunk boundaries, tamper/wrong-key/truncation -> throw, HKDF determinism, streaming
WS2src/persistence.ts + src/crypto/r2-backup.tsplaintext deleted after backup; meta flag; restore writes+deletes plaintext even on failure; legacy plaintext restore; .enc cleanup
WS3saveConfigVersion/getConfigVersionroundtrip; legacy plaintext readable; API shape unchanged
WS4types, .dev.vars.example, ENV-VARIABLES.md, secrets manifest, PRIVACY.md, AGENTS.md, business-rules corpustest/business-rules/backup-encryption.txt
WS5E2E (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/