Backup Crypto

The encryption a backup bundle is built from: Argon2id to turn the operator's passphrase into a key, HKDF-SHA3-512 to derive a per-file subkey from it, and XChaCha20-Poly1305 over the bytes.

Deriving per file matters. One key over every entry means a single ciphertext-reuse mistake reaches the whole bundle, and it also means the expensive passphrase derivation would have to run per file. A bundle key derived once and a cheap subkey per file gives separation without paying Argon2id again.

The subkey's label is derived from the file's path, so a blob moved to a different path inside the bundle no longer decrypts. That is deliberate: it makes the manifest's idea of what a file is part of what the ciphertext is bound to, and the decryption failure says so.

A derived key is zeroed as soon as the operation using it returns, so a bundle written in one pass does not leave a key reachable in the heap for the life of the process.

b.backupCrypto.checksum(buf) #

stable0.1.96

The SHA3-512 digest of a Buffer or string, as hex. This is the digest a manifest records per file, so a restore can tell a truncated or altered entry from an intact one before trying to decrypt it.

Anything that is not a Buffer or a string raises backup-crypto/bad-input, rather than digesting a coerced value and recording a checksum of something that was never written.

var b = require("@blamejs/core");
b.backupCrypto.checksum("contents").length;   // → 128 hex characters

b.backupCrypto.deriveKey(passphrase, saltHex, opts?) #

stable0.1.96
{
  memoryCost:  number,   // KiB; default 65536
  timeCost:    number,   // passes; default 3
  parallelism: number,   // lanes; default 4
}

Derive the 32-byte bundle key from the operator's passphrase with Argon2id, at 64 MiB, three passes and four lanes.

The derivation runs through the Argon2id gate, so it is bounded by the same concurrency limit and cost ceiling as a login. A backup started while the gate is full is refused as argon2/busy rather than queuing without limit.

The caller owns the returned Buffer and should zero it. Everything in this module that derives internally zeroes its own copy on the way out.

An empty or non-string passphrase raises backup-crypto/bad-passphrase, a salt that is not even-length hex raises backup-crypto/bad-salt, and a derivation answering with anything other than 32 bytes raises backup-crypto/derive-failed rather than returning a key of the wrong length.

var b = require("@blamejs/core");
var salt = b.backupCrypto.newSalt();
var key = await b.backupCrypto.deriveKey("the passphrase", salt);
try { key.length; } finally { key.fill(0); }

b.backupCrypto.fileKeyLabel(relativePath) #

stable0.20.31

The HKDF label for one file's subkey, derived from the file's path inside the bundle.

Binding the label to the path is what makes a blob undecryptable after it is moved to another path in the bundle: the subkey for the new path is a different key. A restore that finds a remapped blob therefore fails to decrypt rather than restoring one file's contents under another file's name.

The path is digested rather than used directly, so a label is a fixed length whatever the path is, and a path containing characters HKDF would otherwise carry through is reduced to hex.

var b = require("@blamejs/core");
b.backupCrypto.fileKeyLabel("db/app.db");
// → "blamejs-backup/file:"

b.backupCrypto.deriveSubkey(bundleKey, saltHex, label) #

stable0.20.31

Derive a 32-byte subkey from the bundle key with HKDF-SHA3-512, using the bundle salt and a label.

This is the cheap half of the scheme: the bundle key costs one Argon2id derivation, and every file's key comes from here, so a bundle of a thousand files does not cost a thousand passphrase derivations.

bundleKey has to be a 32-byte Buffer (backup-crypto/bad-key), saltHex even-length hex (backup-crypto/bad-salt) and label a non-empty string (backup-crypto/bad-label). An empty label would give every file the same subkey, which is the property the scheme exists to avoid.

var b = require("@blamejs/core");
var sub = b.backupCrypto.deriveSubkey(bundleKey, salt,
  b.backupCrypto.fileKeyLabel("db/app.db"));
try { sub.length; } finally { sub.fill(0); }

b.backupCrypto.encryptWithKey(plaintext, key, aad?) #

stable0.20.31

Encrypt with XChaCha20-Poly1305 under a 32-byte key and return the nonce followed by the ciphertext in one Buffer.

A fresh 24-byte nonce is generated per call and prepended, so the caller never chooses one and cannot reuse one. XChaCha20's nonce is large enough that random generation is safe for the number of entries a bundle holds.

aad is authenticated but not encrypted, which is how a ciphertext is bound to its context: the same bytes under the same key with different aad will not decrypt.

var b = require("@blamejs/core");
var sealed = b.backupCrypto.encryptWithKey("contents", key, "db/app.db");
sealed.length > b.backupCrypto.NONCE_BYTES;   // → true

b.backupCrypto.decryptWithKey(encrypted, key, aad?) #

stable0.20.31

Decrypt what encryptWithKey produced, reading the nonce from the front of the buffer.

A buffer under MIN_SEALED_BYTES, the 24-byte nonce plus the 16-byte Poly1305 tag, cannot hold a sealed value at all, so it raises backup-crypto/bad-input before any key material is touched. A key that is not 32 bytes raises backup-crypto/bad-key, and an aad that is neither a string nor a Buffer raises backup-crypto/bad-aad rather than being coerced, since a coerced aad would authenticate different bytes than the caller meant.

A failed tag raises backup-crypto/decrypt-failed, and the message names all three causes it cannot distinguish: a wrong passphrase, tampered ciphertext, or a blob remapped to a different path. They are indistinguishable because the AEAD only reports that the tag did not verify, and saying so is more useful than picking one.

var b = require("@blamejs/core");
var plain = b.backupCrypto.decryptWithKey(sealed, key, "db/app.db");
plain.toString("utf8");        // → "contents"

b.backupCrypto.encryptUnderSubkey(plaintext, bundleKey, saltHex, label, aad?) #

stable0.20.31

Derive the subkey for label, encrypt under it, and zero the subkey before returning. This is how a bundle encrypts one file.

The subkey never leaves this call, so a caller writing a thousand files leaves no derived keys reachable afterwards. The zeroing happens whether the encryption succeeded or threw.

var b = require("@blamejs/core");
var sealed = b.backupCrypto.encryptUnderSubkey(bytes, bundleKey, salt,
  b.backupCrypto.fileKeyLabel("db/app.db"));

b.backupCrypto.decryptUnderSubkey(encrypted, bundleKey, saltHex, label, aad?) #

stable0.20.31

Derive the subkey for label, decrypt under it, and zero the subkey before returning. This is how a restore reads one file.

The label comes from the path the manifest records, so a blob that has been moved within the bundle produces a different subkey and fails to decrypt. That failure is the protection, not a malfunction.

var b = require("@blamejs/core");
var plain = b.backupCrypto.decryptUnderSubkey(sealed, bundleKey, salt,
  b.backupCrypto.fileKeyLabel("db/app.db"));

b.backupCrypto.encryptWithPassphrase(plaintext, passphrase, saltHex, aad?) #

stable0.1.96

Derive a key from the passphrase and encrypt under it, zeroing the key before returning.

This is the form for the one thing a bundle seals under the passphrase itself, the vault key. Everything else goes through a subkey, because this pays a full Argon2id derivation per call.

var b = require("@blamejs/core");
var salt = b.backupCrypto.newSalt();
var sealed = await b.backupCrypto.encryptWithPassphrase(
  vaultKeyJson, passphrase, salt, b.backupCrypto.VAULT_KEY_LABEL);

b.backupCrypto.decryptWithPassphrase(encrypted, passphrase, saltHex, aad?) #

stable0.1.96

Derive a key from the passphrase and decrypt under it, zeroing the key before returning.

A buffer under MIN_SEALED_BYTES, the 24-byte nonce plus the 16-byte Poly1305 tag, raises backup-crypto/bad-input before the derivation runs, so a truncated input does not cost an Argon2id pass first. The 25-to-39 byte range used to pass this gate and fail as backup-crypto/decrypt-failed after the derivation. The derivation's own refusals reach the caller unchanged: backup-crypto/bad-passphrase for an empty or non-string passphrase, backup-crypto/bad-salt for a salt that is not even-length hex, and backup-crypto/derive-failed for a derivation that answers with the wrong length.

Because the derivation is gated, this can be refused with argon2/busy under load. That refusal is transient and the read may be retried; the passphrase is not consumed by the attempt.

var b = require("@blamejs/core");
var vaultKeyJson = await b.backupCrypto.decryptWithPassphrase(
  sealed, passphrase, manifest.vaultKeySalt, b.backupCrypto.VAULT_KEY_LABEL);

b.backupCrypto.newSalt() #

stable0.20.31

A fresh 32-byte salt as hex, for one bundle's key derivation.

A bundle records its salt in the manifest, so a restore derives the same key from the same passphrase. Reusing a salt across bundles would mean one precomputation applies to all of them, which is what a per-bundle salt prevents.

var b = require("@blamejs/core");
b.backupCrypto.newSalt().length;   // → 64 hex characters

b.backupCrypto.encryptWithFreshSalt(plaintext, passphrase, aad?) #

stable0.1.96

Generate a salt, encrypt under a key derived from the passphrase and that salt, and resolve with { encrypted, salt }.

Returning the salt alongside the ciphertext is the point: the salt is not a secret and is useless to lose, so handing both back together is what keeps a caller from storing one without the other and producing a bundle nothing can open.

var b = require("@blamejs/core");
var out = await b.backupCrypto.encryptWithFreshSalt(vaultKeyJson, passphrase);
out.salt;                      // → record this in the manifest

Last updated 2026-10-12T14:21:56.879Z by seeder.