# Keepsake Vault Format v2 (KVF2) — Specification

**Status:** **FROZEN v1 (2026-07-03)** — desktop (C#) and Android (Kotlin) implementations pass the shared vector suite and open each other's vaults in both directions (`docs/interop-fixtures/`). Changes now require a version bump + migration path (§12).
**Applies to:** Windows desktop, Android (Kotlin), Web PWA — one format, byte-identical on every platform.

This document is normative. Implementations MUST NOT deviate; anything ambiguous is a spec bug — fix the spec first.

---

## 1. Design goals

1. **Zero-knowledge:** everything leaving the device (BYO-cloud sync, Secure Send) is ciphertext under keys only the user holds.
2. **Authenticated encryption only:** AES-256-GCM everywhere. No unauthenticated CBC (v1's flaw).
3. **Envelope keys:** files are encrypted by a random Master Key (MK), never directly by a password-derived key → password change, biometric unlock, recovery kits, and family sharing are all *key-wrapping* operations, never re-encryption of data.
4. **Sync = file replication:** the on-disk layout is directly replicable by Drive/OneDrive with no server logic; concurrent edits merge deterministically via an op-log.
5. **Survivability:** the format is documented publicly; a competent engineer can recover a vault with only this document, the files, and the password/recovery phrase.

## 2. Vault layout on disk

```
<vault-root>/
  vault.json                  # manifest: version, KDF params, key slots (public)
  index.db.enc                # encrypted SQLite index (metadata, tags, audit…)
  blobs/
    <blobId>.kps              # immutable encrypted document content
  ops/
    <deviceId>/
      <seq10>.op.enc          # append-only encrypted op-log (sync)
  local/                      # NOT synced, device-private (thumb cache, lock state)
```

- `vault.json` is the only plaintext file. It contains **no secrets** (only salts, params, wrapped keys — all safe to disclose; wrapped keys are AES-GCM ciphertext).
- `blobs/` files are **immutable**: any content change produces a new blobId + a new op. This makes blob-level sync conflicts impossible.
- `local/` MUST be excluded from sync providers.

## 3. Cryptographic primitives

| Purpose | Primitive | Notes |
|---|---|---|
| Content encryption | **AES-256-GCM**, 12-byte nonce, 16-byte tag | Hardware-accelerated on all targets |
| Password KDF | **Argon2id** | Params stored per-slot in manifest (§5). Defaults: desktop/web `m=64MiB, t=3, p=4`; Android `m=32MiB, t=3, p=2`. Output 32 bytes. |
| Subkey derivation | **HKDF-SHA256** | From MK: `HKDF(MK, salt=vaultId, info=<label>)` |
| Content addressing | **HMAC-SHA256(K_blobid, plaintext)** | Private content addressing: dedup without exposing a public plaintext fingerprint. `K_blobid = HKDF(MK, info="kvf2/blobid")` |
| Audit chain | **SHA-256** | §8 |
| Recovery phrase | **BIP-39 English, 24 words** | 256-bit entropy → RK |
| Randomness | Platform CSPRNG only | `RandomNumberGenerator` / `SecureRandom` / `crypto.getRandomValues` |

Derived subkeys (all via HKDF from MK):
`K_index` (info=`"kvf2/index"`), `K_blob` (info=`"kvf2/blob"`), `K_blobid` (info=`"kvf2/blobid"`), `K_ops` (info=`"kvf2/ops"`).

## 4. Key hierarchy

```
password ──Argon2id──► KEK_pw  ─┐
recovery phrase ──BIP39──► RK  ─┤ each wraps MK        MK ──HKDF──► K_index, K_blob,
platform keystore ──► BK       ─┤ (AES-256-GCM)                     K_blobid, K_ops
family invite ──► (member MK)  ─┘
MK (random 256-bit, generated once at vault creation)
FK (Family Key, Premium): random 256-bit; wrapped by each member's MK; wraps shared blobs/index entries
```

- **Password change** = re-derive KEK_pw, re-wrap MK, bump slot. No data re-encryption.
- **Unlock** = open any one slot → MK in memory → zeroed on lock/logout/process death.

## 5. `vault.json` manifest

```json
{
  "format": "KVF2",
  "version": 1,
  "vaultId": "018f2c7e-....-....",         // UUIDv7, generated at creation
  "created": "2026-07-02T10:00:00Z",
  "cipher": "AES-256-GCM",
  "slots": [
    {
      "id": 0,
      "type": "password",                   // password | recovery | keystore | family-invite
      "kdf": { "alg": "argon2id", "mem_kib": 65536, "iters": 3, "par": 4,
               "salt": "<b64, 16 bytes>" },
      "wrap": { "nonce": "<b64, 12B>", "ct": "<b64, 32B MK ciphertext>",
                "tag": "<b64, 16B>" },
      "created": "2026-07-02T10:00:00Z"
    }
  ],
  "opsEpoch": 0                             // bumped on key rotation (§9.5)
}
```

Wrap AAD (UTF-8): `"<vaultId>:slot:<id>:<type>"`.
Slot types `recovery` and `keystore` omit `kdf` (`recovery` uses the BIP-39 entropy directly as the wrapping key; `keystore` keys never leave the platform keystore).
Implementations MUST tolerate unknown fields (forward compatibility) and unknown slot types (skip).

## 6. Blob file format (`blobs/<blobId>.kps`)

Binary layout (all integers little-endian):

| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic `"KPS2"` (0x4B 0x50 0x53 0x32) |
| 4 | 1 | Format version = `2` |
| 5 | 1 | Flags (bit0: compressed-deflate before encryption; others 0) |
| 6 | 12 | Nonce (random per file) |
| 18 | N | Ciphertext (AES-256-GCM under `K_blob`) |
| 18+N | 16 | GCM tag |

- **AAD** (UTF-8): `"<docId>:<contentVersion>"` — binds ciphertext to its document row and version; a blob copied onto another document fails authentication.
- `blobId` = lowercase hex `HMAC-SHA256(K_blobid, plaintext)` truncated to 32 hex chars (128 bits). Before writing, if `blobs/<blobId>.kps` exists, content is deduplicated (verify decrypt-ability, reuse).
- Blobs are immutable and garbage-collected only when no index row or version references them **and** the deletion op has synced (tombstone age > 30 days).

### 6.1 Test vector (blob payload encryption)

```
K_blob   = 42×32 (32 bytes, each 0x42)
nonce    = 000102030405060708090a0b
docId    = 018f2c7e-0000-7000-8000-000000000001
version  = 1
AAD      = UTF8("018f2c7e-0000-7000-8000-000000000001:1")
         = 30313866326337652d303030302d373030302d383030302d3030303030303030303030313a31
PT       = UTF8("Keepsake vault format v2 test vector.")
         = 4b65657073616b65207661756c7420666f726d6174207632207465737420766563746f722e
CT       = 4ebbf4adc24d996ebd702566bda97b5d5defa30bd46994db33ed4554ee34cb9a37cfe6d05f
TAG      = 4564924be72d6e08023ebada37ba5a66
```

Every implementation MUST reproduce this vector exactly (encrypt) and reject a flipped bit anywhere in CT/TAG/AAD (decrypt).

### 6.2 Interop fixtures (locked at freeze)

- `docs/interop-fixtures/` — vault.json + blob created by the **C#** implementation; opened by Android (`DesktopInteropTest`).
- `docs/interop-fixtures/from-android/` — vault.json + blob created by the **Kotlin** implementation; opened by desktop (`AndroidInteropTests`).
- Both cover: password slot (Argon2id), recovery slot (BIP-39), blob decrypt with AAD binding, and private content-address agreement. New implementations (web PWA) MUST pass both fixture sets plus §6.1.
- Op-log merge fixtures (§9.4) are added in Phase 3, step 3.1.

## 7. Encrypted index (`index.db.enc`)

Whole-file encryption of a standard SQLite database: same binary framing as §6 (magic `"KPS2"`, version 2), key `K_index`, AAD = `"<vaultId>:index:<generation>"` where `generation` is a monotonically increasing integer stored in plaintext between tag and EOF as 8-byte LE (prevents rollback: implementations MUST refuse a generation lower than the highest ever seen locally, stored in `local/`).

Canonical schema (SQL, executed in this order — table/column names are normative):

```sql
CREATE TABLE documents (
  id TEXT PRIMARY KEY,              -- UUIDv7
  title TEXT NOT NULL,
  category_id INTEGER NOT NULL,     -- built-ins 1..99, custom >=100
  blob_id TEXT NOT NULL,
  content_version INTEGER NOT NULL DEFAULT 1,
  file_name TEXT, file_size INTEGER, mime TEXT,
  ocr_text TEXT, notes TEXT,
  doc_number TEXT, issuing_authority TEXT,
  issue_date TEXT, expiry_date TEXT,          -- ISO 8601 date
  created TEXT NOT NULL, modified TEXT NOT NULL,
  deleted INTEGER NOT NULL DEFAULT 0          -- tombstone
);
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, icon TEXT, deleted INTEGER DEFAULT 0);
CREATE TABLE tags      (id TEXT PRIMARY KEY, name TEXT UNIQUE, deleted INTEGER DEFAULT 0);
CREATE TABLE doc_tags  (doc_id TEXT, tag_id TEXT, PRIMARY KEY (doc_id, tag_id));
CREATE TABLE versions  (doc_id TEXT, content_version INTEGER, blob_id TEXT,
                        created TEXT, note TEXT, PRIMARY KEY (doc_id, content_version));
CREATE TABLE reminders (id TEXT PRIMARY KEY, doc_id TEXT, remind_on TEXT, level INTEGER,
                        dismissed INTEGER DEFAULT 0);
CREATE TABLE identity_fields (id TEXT PRIMARY KEY, doc_id TEXT, field TEXT, value TEXT,
                              confidence REAL, source TEXT);
CREATE TABLE audit (seq INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, device_id TEXT,
                    action TEXT, doc_id TEXT, detail TEXT, prev_hash TEXT, hash TEXT);
CREATE TABLE meta  (key TEXT PRIMARY KEY, value TEXT);   -- schema_version, device registry…
```

v1 → v2 migration tools map each platform's legacy schema into this one (desktop step 1.2, Android step 1.4).

## 8. Tamper-evident audit chain

For each audit row:
`hash = hex(SHA-256(prev_hash || ts || device_id || action || coalesce(doc_id,'') || coalesce(detail,'')))`
with `prev_hash` of the first row = 64 zeros. UTF-8, `||` = concatenation, lowercase hex.
"Verify history" in Settings recomputes the chain and reports the first broken link. Audit rows are never deleted; they replicate via ops like other rows.

## 9. Sync op-log

### 9.1 Model
Each device owns a unique `deviceId` (UUIDv4, in `meta` + `local/`). A device appends ops **only** to its own directory `ops/<deviceId>/`, numbered `0000000001.op.enc` … strictly sequential. Sync = upload own new op files + download others' new op files + apply. The provider (Drive/OneDrive/folder) needs only: list, upload-new, download. No server code.

### 9.2 Op file format
Binary framing as §6 with key `K_ops`, AAD = `"<vaultId>:ops:<deviceId>:<seq>:<opsEpoch>"`. Plaintext = JSON array of ops (a batch):

```json
{ "op": "put", "table": "documents", "id": "<uuid>", "ts": "2026-07-02T10:00:00.123Z",
  "dev": "<deviceId>", "fields": { "title": "Passport — Ali", "expiry_date": "2031-04-01" } }
{ "op": "del", "table": "documents", "id": "<uuid>", "ts": "...", "dev": "..." }
{ "op": "blob", "id": "<blobId>", "size": 123456 }        // announces a blob upload
{ "op": "audit", "row": { ... } }                          // audit rows append-only
```

### 9.3 Snapshots
Every 500 ops (or on demand) a device writes `index.db.enc` (generation++) and records `{"op":"snapshot","gen":N,"upTo":{"<dev>":seq,...}}`. New devices bootstrap from the latest snapshot + later ops. Op files covered by a snapshot older than 30 days may be pruned by the device that wrote them.

### 9.4 Merge rules (deterministic, no server)
- Total order over op metadata: compare `ts` (identical ISO-8601 `yyyy-MM-ddTHH:mm:ss.SSSZ` strings compare lexicographically), tie-break by `dev` (ordinal) then `seq`. Written `meta(a) > meta(b)` below.
- Unit of conflict = **field** (from `fields` maps). Last-Writer-Wins: a field's value is the one whose op has the greatest meta.
- **Tombstone semantics (normative):** for each row keep the greatest `del` meta `D` (if any). A field is visible iff its winning put meta `> D`. The row is deleted iff `D` exists and no visible field remains. Consequence: a delete wipes prior state; later puts revive the row carrying only fields written after the delete. This makes the merge a pure function of the op **set** — application order never matters.
- Applying the same op twice is a no-op (idempotent by construction).
- `doc_tags` is a set: `put`/`del` per pair, LWW per pair.
- Audit rows: append-only union, re-sorted by `ts`; chain hashes recomputed locally per device order — audit verification is per-device-sequence, cross-device order is display-only.
- Clock skew: ops with `ts` > 24h in the future are clamped to local now (logged). Sanity, not security.
- Applying ops is idempotent; devices track `applied[<dev>] = seq` in `meta`.

### 9.5 Key rotation
Rotating MK (e.g., suspected compromise): create new MK', re-wrap all slots, re-encrypt `index.db.enc` and *newly written* files under MK'-derived keys, bump `opsEpoch`; blobs are lazily re-encrypted in background (both keys valid during transition, old-epoch keys retained wrapped under MK' in `meta`).

## 10. Family Vault (Premium — phase 5.5)

- Family vault = a second KVF2 vault in a **shared** cloud folder.
- `FK` (its MK) is wrapped for each member as a `family-invite` slot: inviter wraps FK under an ephemeral X25519 shared secret; invite QR/file carries the ephemeral public key + slot reference; member's app unwraps and re-wraps FK under their own MK. (Full ECDH ceremony specced in step 5.5; slot `type:"family-invite"` is reserved now.)
- Per-category permissions = per-category HKDF subkeys of FK; members receive only the subkeys their role allows. Possession of keys **is** the ACL — no server to enforce anything weaker.

## 11. Free-tier enforcement

The 25-document free cap is enforced in the app layer (license check, §MASTER_PLAN 8), never in the format. A vault is always fully readable regardless of license state — **reading your own documents is never paywalled.**

## 12. Versioning

- `vault.json.version` bumps only on breaking changes; apps refuse versions above their max with an "update Keepsake" message and never write to a newer vault.
- This spec freezes at implementation sign-off (step 1.6); after that, changes require a new version number and a migration path.
