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
- A random 256-bit data key (DEK) is generated for every backup file.
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 ofdata(WebCrypto appends it).- Each entry of
keysis 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).
- Opening a file: pick any
keysentry 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" } ] } ]
}
attachmentsare the photo records (metadata and a small thumbnail);blobshold 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;
hashis the SHA-256 of those stored bytes, which is whatscbak_decrypt.py --verifyrecomputes. The original camera file is not kept. - The stamp chain: when a photo is linked to a session,
previs the newest hash in that session's chain at capture time (a check-in'shashor an earlier photo'sstamp),chainnames the session, andstamp = sha256(prev + "|" + hash + "|" + capturedAt). Check-ins and photo stamps therefore form one chain per session, verifiable from the backup alone. linkssays 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).cashrecords are withdrawals: never a wager, never part of win/loss; the app matches them against the day's cash and ATM buy-ins.summaryin the outer file gainsphotos(images carried),attachments(photo records) andcashcounts.
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>" }, wherebodyis the session document serialised as JSON with keys sorted at every level and thesealsandchainHeadfields removed. Recomputing the newest seal from the stored document shows whether the record still matches its last save; theprevlinks 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-inhash, a photostampor a sealhash); the next element added takes it asprev.venuemay carryplaceId(the Google place id from the built-in casino list) andname, 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.