The backup format
Every local-first library says your data is yours. The question that separates the claim from the fact is simple: if this library disappeared tonight, could somebody else read your file tomorrow?
For selfstore the answer is yes, and it is checked rather than promised. The format is specified independently of this implementation in SPEC.md; a roughly 120-line Python reference reader opens real backups with no JavaScript anywhere; and canonical test vectors are committed next to it, so a second implementation can prove it agrees instead of hoping.
Two properties do most of the work here, and both were deliberate.
The spec is short enough to read in one sitting. That is not a stylistic preference. A format nobody finishes reading gets one implementation, and one implementation is indistinguishable from lock-in. The layout below is the whole of it: a ZIP, a cleartext header, and one encrypted entry.
A second implementation exists, in another language, run on every change. A spec with a single implementation describes that implementation, whatever it claims. The Python reader is the disagreement detector: when the library and the document drift, one of them fails the vectors and CI says which.
The layout
Both modes are genuine ZIP archives:
unencrypted: meta.json + selfstore.json + files/* (browsable in any ZIP tool)
encrypted: meta.json + data.enc + LISEZMOI.txt / README (still a valid ZIP)
meta.jsonis the cleartext header:format,app,appVersion,schemaVersion,createdAtandencryption.formatis the container generation, and there are three - 1 for a plain archive, 2 for group mode, 3 for the authenticated password envelope, which is what a password-protected backup writes.- A generation 3 header adds the slot table
keys[]and the payloadiv. There is no top-level KDF: the key derivation lives per slot, because each secret that opens the file wraps the same data key under its own salt and cost. selfstore.jsonholds your named collections;files/*your binary files.data.encis the inner ZIP, encrypted whole with AES-256-GCM.- The readme rides inside the archive so a person who finds the file in ten years knows what it is and what app to feed it to.
In generation 3 the exact meta.json bytes are the payload’s additional
authenticated data, so altering any header field - the slot table above
all - fails the tag. In group mode the header is covered by the author’s
signature instead. The nuance worth carrying into your own code: the header is
authenticated for a reader who already holds the key, which means it is not
trustworthy at the moment you are tempted to use it. Show it to a human, never
branch a security decision on it.
The store’s own bookkeeping (schema version, merge metadata) travels in a
dedicated sync.json entry, so your collections stay pristine.
The forward guarantee
A generation N file is always a ZIP whose meta.json carries
format: N. Any reader, however old, can therefore at least identify a newer
file and refuse it honestly with UNSUPPORTED_VERSION. A cipher or KDF the
reader does not know is refused the same way, never as a misleading
DECRYPT_FAILED. Old readers fail truthfully, which is the property that
makes long-lived archives trustworthy.
Crypto parameters travel with the file
Encryption is AES-256-GCM over an Argon2id-derived key (46 MiB, 3 passes by default). The parameters are stored per file, so backups written under older defaults keep decrypting forever, and they are bounded on read (memory up to 1 GiB, up to 10 passes, up to 4 lanes): a hostile file cannot declare absurd parameters and melt the reading machine.
Two guards protect the reading side in general: any archive entry declaring
more than 512 MiB, or an archive totalling more than 1 GiB, is refused before
inflation (TOO_LARGE, the zip-bomb guard).
What is and is not authenticated
GCM authenticates the ciphertext and the crypto parameters: a flipped byte or
altered KDF settings fail decryption outright. The cosmetic header fields
(app, appVersion, createdAt) are cleartext and not authenticated;
show them to humans, never base a security decision on them. This split is
deliberate and documented in the
threat model.
The conformance kit
The spec exists so that other software can read these files, and
spec/ is
everything needed to do it:
SPEC.md |
the normative description, at the repository root |
selfstore_reader.py |
the independent reader, ~120 lines, no JavaScript |
vectors/ |
canonical backups: plain, encrypted, multi-password envelope, external-key envelope, group |
verify_vectors.py |
runs the reader against every vector and exits non-zero on any mismatch |
CRYPTO-RATIONALE.md |
why each cryptographic decision was made, the alternatives rejected, and where its author would attack it first |
Read the vectors and you read selfstore backups:
git clone https://github.com/selfstoredev/selfstore
cd selfstore/spec
pip install argon2-cffi cryptography
python3 verify_vectors.py # the conformance run
python3 selfstore_reader.py backup.zip # or point it at your own file
The vectors are pinned: a file written by 1.0.0 still reads today, and the suite proves it on every run rather than asserting it in a release note. That is the mechanism behind the stability promise - the number on the package is not what you are trusting, the file is.
Data rescue tools, migration scripts, a CLI in Go or Rust, an import path in a competing application: all legitimate, all intended. A format you can leave is worth more than a library you cannot.