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__.