Vault Wrap
The sealed-file format the vault keeps its keypair in: an Argon2id derivation from the operator's passphrase, then XChaCha20-Poly1305 over the plaintext, with the derivation's own parameters written into the header.
Recording the parameters is what makes an old file readable after the defaults change. Unsealing derives at the cost the file names rather than the cost this build would choose, so raising the default does not orphan the files already written. The parameters are bounded on the way in, so a file cannot ask the reader for a four-gigabyte derivation.
Every header and parameter check raises its own code, and an unreadable file is distinguishable from a rejected passphrase. A truncated or misversioned file is an operator pointing at the wrong path or a damaged disk, which is a different problem from a wrong passphrase and wants a different answer.
b.vaultWrap.isUnreadableFile(err) #
Answer whether an error from unwrap or parseHeader means the sealed file could not be read, rather than that the passphrase was rejected or that a derivation bound refused the work. Every parse failure carries a vault-wrap/* code, so separating the file from the passphrase by code alone means restating the list of parse codes, and that list grows with each new check.
A passphrase rejection, vault-wrap/passphrase-rejected, and a derivation refusal such as argon2/cost-over-ceiling both answer false: the file was read, and something else failed.
try {
b.vaultWrap.unwrap(sealedBytes, passphrase);
} catch (err) {
b.vaultWrap.isUnreadableFile(err); // → true when the file is the problem
}
b.vaultWrap.buildHeader(params) #
Build the header a sealed file starts with: the magic byte, the format version, the KDF and cipher identifiers, the Argon2id cost, and the salt and nonce with their lengths.
wrap calls this, and it is exported for a tool that writes or inspects the format directly. salt and nonce must be byte arrays, each raising vault-wrap/bad-salt or vault-wrap/bad-nonce otherwise, and the nonce has to be the cipher's 24 bytes.
var b = require("@blamejs/core");
var header = b.vaultWrap.buildHeader({
salt: Buffer.alloc(16),
nonce: Buffer.alloc(b.vaultWrap.NONCE_LENGTH),
memoryCost: b.vaultWrap.DEFAULT_ARGON2.memoryCost,
timeCost: b.vaultWrap.DEFAULT_ARGON2.timeCost,
parallelism: b.vaultWrap.DEFAULT_ARGON2.parallelism,
ciphertextLength: 48,
});
header[0] === b.vaultWrap.MAGIC; // → true
b.vaultWrap.parseHeader(buf) #
Read a sealed file's header and return its parameters, including where the ciphertext begins and how long it is.
Each check has its own code, so a reader can say what is wrong with the file: vault-wrap/too-short, /bad-magic, /unsupported-version, /unsupported-kdf, /unsupported-cipher, /bad-memory-cost, /bad-time-cost, /bad-parallelism, /bad-salt-length, /bad-nonce-length, /ciphertext-too-short and /truncated.
The cost bounds are checked here rather than at derivation time, so a file claiming a four-gigabyte derivation is refused before any memory is allocated for it.
var b = require("@blamejs/core");
var parsed = b.vaultWrap.parseHeader(sealed);
parsed.params.memoryCost; // → the cost this file was written at
b.vaultWrap.deriveWrappingKey(passphrase, salt, argonParams?) #
{
memoryCost: number, // KiB; default 65536
timeCost: number, // passes; default 3
parallelism: number, // lanes; default 4
}
Derive the 32-byte wrapping key from a passphrase and salt, through the Argon2id gate. Missing parameters fall back to the format's defaults.
wrap and unwrap both go through this, which is why both are subject to the gate's concurrency bound and to the cost ceiling: a sealed read is an Argon2id derivation like any other, and a deployment that bounds its login path has bounded this too.
An empty passphrase, or one over 4 KiB, raises vault-wrap/bad-passphrase, and a salt outside 8 to 64 bytes raises vault-wrap/bad-salt.
var b = require("@blamejs/core");
var key = await b.vaultWrap.deriveWrappingKey("passphrase", Buffer.alloc(16));
key.length; // → 32
b.vaultWrap.wrap(plaintext, passphrase, opts?) #
{
memoryCost: number, // KiB; default 65536 (64 MiB)
timeCost: number, // passes; default 3
parallelism: number, // lanes; default 4
saltLength: number, // bytes; default 16
salt: string, // fixed salt; tests only
nonce: string, // fixed nonce; tests only
}
Seal bytes under a passphrase and resolve with the sealed file: header, salt, nonce and ciphertext in one Buffer.
The defaults are 64 MiB of memory, three passes and four lanes, and they are written into the header so unwrap can reproduce them. Raising them here changes what new files cost to open without affecting the files already written.
The derivation runs through the Argon2id gate, so a sealed write under load can be refused with argon2/busy rather than queuing without limit, and it is bounded by the cost ceiling. An empty passphrase, or one over 4 KiB, raises vault-wrap/bad-passphrase.
salt and nonce are generated unless supplied; supplying them is for reproducing a known vector in a test, not for production, since reusing a nonce under one key breaks the cipher's guarantee.
var b = require("@blamejs/core");
var sealed = await b.vaultWrap.wrap("the-plaintext", "correct horse battery");
sealed.slice(0, 1)[0] === b.vaultWrap.MAGIC; // → true
b.vaultWrap.unwrap(sealed, passphrase) #
Open a sealed file and resolve with the plaintext Buffer.
The derivation uses the parameters recorded in the file, not this build's defaults, which is how a file written under an older cost still opens. The cost ceiling still applies, so a header naming more work than the ceiling allows raises argon2/cost-over-ceiling rather than being honored.
A file that is not readable as a sealed file raises its own code and sets a marker the callers read, so an operator pointed at the wrong path, or holding a file a full disk truncated, is told that rather than being told the passphrase was wrong. A wrong passphrase fails the AEAD tag, which cannot be told apart from tampering, so that error says both.
var b = require("@blamejs/core");
var plain = await b.vaultWrap.unwrap(sealed, "correct horse battery");
plain.toString("utf8"); // → "the-plaintext"