# The `.cfbak` backup archive — public format specification (v1)

**Status:** normative for `format_version: 1`. Published so that a practice —
or a successor system, auditor, or regulator acting for one — can verify and,
with the passphrase, independently read a SideClick backup **without any
SideClick software and without SideClick's cooperation**. That property is the
point: your access to your clinical data is structural, not vendor-granted.

Everything below is implemented in `src-tauri/src/backup/mod.rs` (desktop) and
mirrored byte-compatibly by the web client. Cross-implementation fixtures pin
the two against each other in CI.

## 1. Container layout

All integers are **little-endian**.

| Offset | Size | Content |
|---|---|---|
| 0 | 8 | Magic: `43 46 42 41 4B 01 00 00` (`CFBAK\x01\0\0`) |
| 8 | 16 | Argon2id salt |
| 24 | … | `AES-256-GCM(nonce ‖ ciphertext ‖ tag)` of the plaintext bundle — the 12-byte nonce is prepended, the 16-byte GCM tag appended |

## 2. Key derivation

The archive key is derived from the user's backup passphrase with **Argon2id**
(`m = 64 MiB, t = 3, p = 1`, 16-byte salt from the header, 32-byte output).
AES-256-GCM's authentication tag means a wrong passphrase or any tampered byte
fails the decrypt outright — there is no separate "is the passphrase right"
oracle, and no way to partially decrypt a corrupted archive.

The passphrase is chosen by the user at export time, is never stored by
SideClick, and cannot be recovered. This is deliberate.

## 3. Plaintext bundle framing

After decryption:

```
[4]  manifest_json length (u32, LE)
[N]  manifest JSON (UTF-8)
for each file, in manifest.files order:
  [8]  data length (u64, LE)
  [L]  data bytes
```

## 4. The manifest

```json
{
  "format_version": 1,
  "schema_version": 47,
  "app_version": "0.10.1",
  "created_at": 1754870400,
  "device_id_hash": "…",
  "files": [ { "name": "sideclick.db", "sha256": "…", "len": 123456 }, … ],
  "signing_public_key_b64": "…",   // optional
  "signature_b64": "…"             // optional
}
```

- `schema_version` is the SQLite `PRAGMA user_version` of the exported
  database — import refuses archives newer than the running build can migrate,
  and older archives are migrated forward on restore.
- Every file entry carries a **SHA-256** that is re-checked on import.
- When the exporting clinician has a signing key, the manifest carries an
  **Ed25519 signature** over a canonical payload (`cf-backup-v1` context) —
  someone who knows the passphrase but not the signing key cannot forge the
  archive's contents undetectably.

## 5. File entries

| Name | Content |
|---|---|
| `sideclick.db` | The complete SQLCipher-encrypted clinical database |
| `documents/…`, `attachments/…` | Encrypted binary blobs, one entry per file |
| `keymaterial.json` | The vault key material (JWT/AES/DB/MFA/email-hash keys, base64) — what makes the archive self-contained on a fresh machine. Protected solely by the archive passphrase; consumed on restore, never written to disk |
| `portability_manifest.json` | **Archive metadata, not app data** (skipped on restore) — see §6 |
| `web-origin.json` | Present only in archives produced by the web client; desktop and web archives are mutually refused on restore |

## 6. The portability manifest

Every desktop export ≥ this format-doc's introduction carries
`portability_manifest.json`:

```json
{
  "format": "sideclick-portability/1",
  "created_at": 1754870400,
  "app_version": "0.10.1",
  "schema_version": 47,
  "entity_counts": {
    "patients": 214, "clinical_encounters": 1892, "clinical_notes": 1854,
    "appointments": 2410, "invoices": 903, "practitioners": 3,
    "patient_documents": 155, "note_attachments": 77, "audit_events": 15230
  },
  "files": { "db": 1, "blobs": 232, "total": 235 },
  "format_doc": "https://sideclick.io/docs/cfbak-format-v1.md"
}
```

It is an **inventory of what the archive contains**, counted from the same
database connection that snapshotted the file bytes — so a practice can prove
to a regulator or a successor system that an export is complete, rather than
asserting it. It is additive (older readers ignore it) and is hashed and
signed like every other file entry.

## 7. Inside the database: reading the clinical content

The archive's `sideclick.db` is a **SQLCipher** database, and clinical fields
inside it carry a second, field-level encryption layer. Both layers open with
material from `keymaterial.json` — nothing else is needed.

### 7.1 Opening the database

`db_key` (base64 in `keymaterial.json`, 32 bytes) is the SQLCipher key in
**raw-key form** — no passphrase KDF is involved:

```sql
PRAGMA key = "x'<64 lowercase hex chars of db_key>'";
```

Any SQLCipher-compatible build (default parameters) can then read every table.
Of the five keys in `keymaterial.json`, only two matter for reading records:
`db_key` (this step) and `aes_key` (the next one). The remaining three
(`jwt_secret`, `mfa_key`, `email_hash_key`) protect sessions and sign-in
material, not clinical content.

### 7.2 Unwrapping a record's key (DEK)

Every encrypted entity has one row in `encryption_key_ref`:

| Column | Content |
|---|---|
| `entity_type`, `entity_id` | e.g. `note`, `<note id>` — one DEK per entity |
| `wrapped_dek` | base64 of `AES-256-GCM(aes_key, dek)` with **AAD = `"entity_type:entity_id"`** (the literal UTF-8 string, colon-joined) |

The decoded blob is `nonce(12) ‖ ciphertext ‖ tag(16)` — 60 bytes for a
32-byte DEK. The AAD binds each wrap to its entity: unwrapping with the wrong
`entity_type:entity_id` string fails the GCM authentication check, so wrapped
keys cannot be swapped between records.

### 7.3 Decrypting the fields

Field ciphertexts are base64 of `AES-256-GCM(dek, plaintext)` with **empty
AAD**, same `nonce(12) ‖ ciphertext ‖ tag(16)` layout:

- clinical note text: `note_section.content_enc`, under the note's DEK
  (`entity_type = 'note'`), ordered by `ordinal` with `section_key`
  (`S`/`O`/`A`/`P`, …);
- patient demographics: `patient_demographics.given_name_enc`,
  `family_name_enc`, `dob_enc`, …, under the patient's DEK
  (`entity_type = 'patient'`).

### 7.4 Worked recipe (archive → plaintext note)

```text
1. key   = Argon2id(passphrase, salt from archive header)          (§2)
2. bundle= AES-256-GCM-decrypt(key, archive[24..])                 (§1)
3. parse manifest + files                                          (§3–5)
4. keys  = parse files["keymaterial.json"]; db to disk
5. open db with PRAGMA key = "x'<hex(db_key)>'"                    (§7.1)
6. for the note:  wrapped = b64decode(encryption_key_ref.wrapped_dek
                             WHERE entity_type='note', entity_id=note_id)
   dek   = AES-256-GCM-decrypt(aes_key, wrapped, aad="note:"+note_id)
7. text  = AES-256-GCM-decrypt(dek, b64decode(content_enc), aad="")
```

This recipe is **enforced by a test** (`cfbak_format_doc_recipe_decrypts_real_fields`
in `src-tauri/src/crypto/mod.rs`): CI seals real content with the production
code and decrypts it using only a stock AES-GCM implementation plus the byte
offsets and AAD strings written above. If the software's scheme ever drifts
from this document, the build fails.

## 8. Compatibility policy

- `format_version` is **1** and has never changed. Any breaking change to the
  container, framing, or manifest bumps it; readers refuse versions they do
  not know.
- New *file entries* (like the portability manifest) are additive and safe:
  restore writes only what it recognises as app data and skips archive
  metadata.
- The Ed25519 canonical signing payload is frozen (`cf-backup-v1`); it is
  bumped only with `format_version`.

## 9. What this means in practice

A practice holding a `.cfbak` file and its passphrase can, with ~50 lines of
code in any language with Argon2id + AES-GCM: derive the key (§2), decrypt
(§1), read the manifest (§4), verify every SHA-256, and extract a standard
SQLCipher database plus every attachment. No SideClick account, licence,
server, or permission is involved. Licence expiry never changes this: export
works in every licence state, including `unlicensed` — that invariant is
product law and is enforced by tests.
