# Plan: Client-Side Encryption of R2 Tenant Backups

**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 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

| 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/`
