Search selfstore
v1.8.21

Encryption and backups

A backup is one snapshot serialized to a portable ZIP, optionally encrypted. With a store you rarely touch the file layer directly; without one, the fluent API drives the same engine.

From the store: one call each

await store.protect('a passphrase the user chose'); // end-to-end from here on
await store.unprotect();                             // reversible

await store.downloadBackup();          // a portable .zip (encrypted while protected)
const blob = await store.exportBackup(); // the same file, yours to route
await store.importBackup(pickedFile);  // read one back INTO the store

While protect() is on, every byte that leaves the device, to a Drive, a WebDAV server, a downloaded file, is AES-256-GCM ciphertext. The local working copy is sealed at rest too, under a non-extractable per-device key, so nothing readable sits on disk. importBackup replaces the store’s data with the file’s (removals propagate like edits) and throws PASSWORD_REQUIRED / DECRYPT_FAILED up front.

Backup FILES without a store

backup, restore and changePassword are pure of any storage, they take and return blobs, so an import/export feature is just calling them. They stay at the package root. The write chain is staged: an illegal order does not compile.

import { backup, restore, changePassword } from 'selfstore';

// Write
const blob = await backup({ collections: { notes }, files: [] })
  .as('my-app', '1.2.0')      // backup() only offers .as(); name the app first
  .encryptedWith(password)    // omit for a plain, browsable ZIP
  .withReadme('Import this file into MyApp with your password.')
  .toBlob();                  // or .toBytes(), or .toDisk('my-backup.zip')

// Read
const meta = await restore(file).meta();          // cleartext header, no password
if (await restore(file).isEncrypted()) { /* ask the user */ }
const snap = await restore(file).withPassword(password).read();

// Add, rotate or remove a file's password
const rekeyed = await changePassword(blob, { from: 'old', to: 'new' });

meta() never needs a password: the header (app, versions, dates, encryption parameters) is cleartext by design, so you can show “backup of my-app from March 3rd, encrypted” before asking for anything.

The crypto, precisely

  • Cipher: AES-256-GCM. A wrong password, a flipped ciphertext byte or altered parameters all fail decryption. There is no partially valid read.
  • Key derivation: Argon2id, memory-hard, 46 MiB and 3 passes by default, stored per file (old backups keep decrypting when defaults improve) and bounded on read (up to 1 GiB / 10 passes) so a hostile file cannot melt the machine.
  • Where it runs: a dedicated Web Worker when the platform allows, falling back silently to the main thread with byte-identical results. The UI never janks on a password.

DECRYPT_FAILED means “wrong password or tampered file”, indistinguishable by design; present both possibilities. An empty or omitted password means “not encrypted”, in both directions.

Common mistakes

  • Numeric record ids. The simple store throws at put(); the fluent API will happily write them, but they will not sync when imported into a store.
  • Guessing at errors. Branch on err.code, never parse messages. See the error reference.
  • Reserved names. backup() refuses collections starting with __.