Search selfstore
v1.8.21

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.json is the cleartext header: format, app, appVersion, schemaVersion, createdAt and encryption. format is 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 payload iv. 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.json holds your named collections; files/* your binary files.
  • data.enc is 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.