Backup file format

Published so a backup can be opened with other tools; there is no lock-in.

For the current app version. The same text ships with the app's source as BACKUP-FORMAT.md.

A backup is one UTF-8 JSON text file. Everything a user typed is inside payload and is encrypted; the outer fields only say how to open it and how much is inside, so a user can tell two backup files apart without opening them.

{
  "format": "stampchip-backup",
  "version": 1,
  "app": "0.3.1",
  "created": "2026-10-24T22:05:11.123Z",
  "summary": { "sessions": 128, "years": ["2025", "2026"], "newest": "2026-10-24", "settings": true },
  "kdf": { "name": "PBKDF2", "hash": "SHA-256" },
  "cipher": { "name": "AES-GCM", "length": 256, "tagLength": 128 },
  "keys": [
    { "type": "passphrase", "salt": "<base64, 16 bytes>", "iterations": 600000, "iv": "<base64, 12 bytes>", "wrapped": "<base64>" },
    { "type": "recovery",   "salt": "<base64, 16 bytes>", "iterations": 100000, "iv": "<base64, 12 bytes>", "wrapped": "<base64>" }
  ],
  "payload": { "iv": "<base64, 12 bytes>", "data": "<base64>" }
}

How it is built

  1. A random 256-bit data key (DEK) is generated for every backup file.
  2. payload.data = AES-256-GCM( DEK, iv = payload.iv, plaintext = UTF-8 JSON of the plain payload, additionalData = UTF-8 of "stampchip-backup|1|" + created ). The 16-byte GCM tag is the last 16 bytes of data (WebCrypto appends it).
  3. Each entry of keys is the DEK wrapped under a key-encryption key (KEK):
    • passphrase: KEK = PBKDF2-HMAC-SHA-256( secret = UTF-8 of the passphrase exactly as typed, salt, 600,000 iterations ) → 256-bit AES-GCM key.
    • recovery: KEK = PBKDF2-HMAC-SHA-256( secret = UTF-8 of the recovery code normalised to upper case with separators removed, salt, 100,000 iterations ). The code has ~99 bits of entropy, so fewer iterations are safe; 100,000 keeps restore quick on a phone.
    • wrapped = AES-256-GCM( KEK, iv, plaintext = the 32 raw DEK bytes, additionalData = UTF-8 of "stampchip-backup|1|" + created + "|" + type ).
  4. Opening a file: pick any keys entry whose secret you have, unwrap the DEK, decrypt the payload. A wrong secret fails the GCM tag check; nothing is revealed.

Plain payload (what you get after decrypting)

{
  "kind": "stampchip-payload",
  "version": 1,
  "exportedAt": "2026-10-24T22:05:11.123Z",
  "sessions": [ { "v": 1, "id": "S20261018-K7QM", "status": "closed", "date": "2026-10-18", "...": "every field the app stores" } ],
  "settings": { "rates": { "fed": 0.3, "state": 0.1 } }
}

The session objects are the app's own documents, unchanged (same fields the CSV columns are built from, plus the full check-in hashes and the change history). The Google Places key saved under Advanced is deliberately not included.

Payload version 2 (app 0.4.0 and later): photos and cash withdrawals

Three more arrays, all optional for a reader (an app 0.3.1 reader ignores them, a 0.4.0 reader treats a missing one as empty):

{
  "kind": "stampchip-payload", "version": 2, "sessions": [ "..." ], "settings": { "..." : "..." },
  "attachments": [ { "v": 1, "id": "A20261024-JWXT", "kind": "atm", "amount": 500, "note": "Lobby ATM", "mime": "image/jpeg", "w": 1600, "h": 1200, "bytes": 89138,
                     "thumb": "data:image/jpeg;base64,...", "hash": "<sha256 hex of the stored JPEG bytes>", "prev": "<the stamp or check-in hash before it in the session's chain, or empty>",
                     "stamp": "<sha256 hex of prev|hash|capturedAt>", "chain": "S20261024-DXN7", "capturedAt": "2026-10-25T02:27:03.439Z",
                     "geo": { "lat": 45.3011, "lon": -122.9472, "acc": 20, "at": "..." }, "dist": 0,
                     "links": [ { "t": "session", "id": "S20261024-DXN7" }, { "t": "day", "date": "2026-10-24" }, { "t": "cash", "id": "C20261024-Q2RX" } ],
                     "createdAt": "...", "updatedAt": "...", "tz": "America/Los_Angeles" } ],
  "blobs": [ { "id": "A20261024-JWXT", "data": "data:image/jpeg;base64,..." } ],
  "cash":  [ { "v": 1, "id": "C20261024-Q2RX", "date": "2026-10-24", "time": "18:05", "amount": 500, "fee": 4.99, "source": "ATM", "place": "Riverbend Casino", "location": "", "note": "",
               "sessionIds": [ "S20261024-DXN7" ], "createdAt": "...", "updatedAt": "...", "tz": "...", "log": [ { "at": "...", "what": "Recorded: $500 ATM, fee $4.99, linked to 1 session" } ] } ]
}
  • attachments are the photo records (metadata and a small thumbnail); blobs hold the images themselves, one per attachment id, as JPEG data URLs. The user can leave the images out of a backup ("Include photos" unticked): the records and their hashes still travel, and a device restored without images shows the record and says the image is missing.
  • A photo is downscaled on the device to at most 1600 px on the long side (JPEG, quality 0.82) before it is stored; hash is the SHA-256 of those stored bytes, which is what scbak_decrypt.py --verify recomputes. The original camera file is not kept.
  • The stamp chain: when a photo is linked to a session, prev is the newest hash in that session's chain at capture time (a check-in's hash or an earlier photo's stamp), chain names the session, and stamp = sha256(prev + "|" + hash + "|" + capturedAt). Check-ins and photo stamps therefore form one chain per session, verifiable from the backup alone.
  • links says where the photo is shown: one or more sessions, a whole day, a cash withdrawal, or nothing (then it is listed under the day it was captured).
  • cash records are withdrawals: never a wager, never part of win/loss; the app matches them against the day's cash and ATM buy-ins.
  • summary in the outer file gains photos (images carried), attachments (photo records) and cash counts.

App 0.8.0 and later: record seals, the chain head, deletion markers, venue ids

Sessions gain three fields and the payload gains one array; all optional for a reader (older apps ignore them, a 0.8.0 reader treats a missing one as empty).

  • seals: one entry per save of the session, newest last (at most the last 200): { "at": "<ISO time of the save>", "prev": "<the newest hash in the session's chain before this save>", "hash": "<sha256 hex of prev + '|' + body>" }, where body is the session document serialised as JSON with keys sorted at every level and the seals and chainHead fields removed. Recomputing the newest seal from the stored document shows whether the record still matches its last save; the prev links join seals, check-ins and photo stamps into one chain per session in the order they were added.
  • chainHead: the newest hash in that chain (a check-in hash, a photo stamp or a seal hash); the next element added takes it as prev.
  • venue may carry placeId (the Google place id from the built-in casino list) and name, so a venue picked from the list is the same venue however it was typed.
  • tombstones (payload array): a dated marker for each session deleted after it was voided: { "id", "date", "place", "net", "wasVoid": true, "voidReason", "checkins": <count>, "seal": "<the chain head at deletion>", "createdAt", "deletedAt" }. Nothing else about a deleted session is kept.

Recovery code

Twenty characters from the alphabet ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no I, L, O, 0, 1), shown as four groups of five (XXXXX-XXXXX-XXXXX-XXXXX), generated once per device and kept in the app's settings on that device. It is a second way to open any backup made on that device, for the case where the passphrase is forgotten but the phone (or the code written down) is still at hand. Neither the passphrase nor the code ever leaves the device, and BFVZ cannot reset either one.

Opening a backup without the app

scbak_decrypt.py <file.scbak> [--out payload.json] [--photos DIR] [--verify] (Python 3, needs the cryptography package) asks for the passphrase or recovery code and writes the plain payload; --photos writes the images as JPEG files and --verify checks every image hash and photo stamp. The format is documented here so anyone can write the same in another language; there is no lock-in.

Restore rules in the app

  • Merge (default): sessions not on the device are added; a session that exists on both sides is replaced only if the backup's copy has a later updatedAt.
  • Replace: everything on the device is deleted first, then the backup is loaded. Requires a typed confirmation.
  • Settings (tax rates) are restored in both modes.
  • Photo records and cash withdrawals follow the session rule (add what is missing, keep the newer copy by updatedAt); an image is written only if the device does not already have one for that id.