Vault Rotate
Retiring a vault keypair means re-encrypting everything sealed under it, which is the one operation where a partial result is worse than no result: rows re-encrypted under the new key and rows still under the old one, with nothing that can read both.
So the rotation is staged, and it stops there. The new wrapping key is derived before anything is written, every re-encrypted file is written into a staging directory, the result is verified and fsynced, and the call returns. The live data directory is not touched. Putting the staged copy into service is a separate step the operator takes, which is what keeps a rotation from swapping the key out from under a running process.
The application has to be stopped for the whole of it, not just for the cutover. The staged copy reflects each file and row as it was read, so a write accepted while the rotation runs lands in the live directory and not in the staged one, and swapping the directories in afterwards would lose it.
An interrupted rotation therefore costs nothing but the staging directory: the original data directory is exactly as it was, and the deployment keeps booting on the old keypair until someone decides otherwise.
Run validateSchemaMatch before every rotation. rotate does not call it. The rotation re-seals the columns the field registry declares, and so does the verification afterwards, so a vault-prefixed value in a column nothing declared is copied across unchanged, the rotation reports success, and nothing can read that column once the old keypair is retired. validateSchemaMatch is the only call that finds it: it reports an unexpected column as a warning and a column holding sealed data the rotation cannot re-encrypt as an error.
b.vaultRotate.validateSchemaMatch(db, opts?) #
{
tables: array, // check only these tables
infraColumns: array, // columns to treat as infrastructure
driftSampleLimit: number, // rows sampled per column
}
Compare the database's sealed columns against what a rotation knows how to re-encrypt, and return { warnings, errors }.
A warning is something the rotation can proceed past. An error is a column holding sealed data the rotation would leave under the retired keypair, which makes that column unreadable once the old keypair is gone.
Nothing acts on the result for you. rotate does not call this and does not see what it found, so the caller checks errors.length and does not rotate when it is not zero. formatValidationResult renders the finding for an operator to read.
tables narrows the check to named tables, and infraColumns names columns that belong to the infrastructure rather than the data. driftSampleLimit bounds how many rows per column are sampled while deciding whether a column is sealed.
var b = require("@blamejs/core");
var result = b.vaultRotate.validateSchemaMatch(db);
result.errors.length === 0; // → the rotation may proceed
b.vaultRotate.formatValidationResult(result) #
Render a schema-match result as the text an operator reads on a terminal. A clean result is one line; otherwise the warnings and the errors are listed separately, with the errors marked as refusing the rotation, so the operator can see which findings block and which do not.
var b = require("@blamejs/core");
console.log(b.vaultRotate.formatValidationResult({ warnings: [], errors: [] }));
// → "[vault-rotate] schema match: OK"
b.vaultRotate.verify(opts) #
{
keys: object, // required; the keypair to decrypt with
db: object, // required; a node:sqlite handle
oldKeys: object, // also check rows no longer open under these
sampleMin: number, // minimum rows per table; default 5
samplePercent: number, // FRACTION of rows per table; default 0.01
tables: array, // verify only these tables
}
Sample the sealed rows and check they decrypt under the keypair given, so a rotation can be confirmed before the old keypair is destroyed.
keys and db are required: without the keypair there is nothing to decrypt with (vault-rotate/no-keys), and without a handle there is nothing to read (vault-rotate/no-db). Passing oldKeys as well checks that the rows no longer decrypt under the retired keypair, which is the half that catches a rotation that copied rather than re-encrypted.
It samples rather than reading everything, since the point is confirmation and a full pass is the rotation itself. Per table it reads the larger of sampleMin rows and samplePercent of the row count. samplePercent is a fraction, not a percentage, and defaults to 0.01: passing 1 reads every row. sampleMin must be a positive integer, raising vault-rotate/bad-opt otherwise.
var b = require("@blamejs/core");
var report = b.vaultRotate.verify({ keys: newKeys, db: db, oldKeys: oldKeys });
report.ok; // → every sampled row opened under newKeys
b.vaultRotate.rotate(opts) #
{
dataDir: string, // required; the live data directory
stagingDir: string, // required; must not exist
mode: string, // "wrapped" or "plaintext"
oldKeys: object, // the keypair being retired
newKeys: object, // the keypair taking over
newPassphrase: string, // wrapped mode; a Buffer, required there
paths: object, // override the framework file names
tables: array, // tables to re-encrypt
rowBatchSize: number, // rows per batch
sampleMin: number, // forwarded to verify; default 5
samplePercent: number, // forwarded to verify; a fraction, default 0.01
externalAadResealed: object, // true, or the list of re-sealed stores
progressCallback: object, // function (event) per phase
}
Re-encrypt everything sealed under the old keypair into stagingDir, verify it, and resolve with { durationMs, tablesProcessed, totalRowsProcessed, verifyResult, warnings }.
It does not cut over. dataDir is read and left alone, and the deployment goes on using the old keypair until the operator puts the staged directory in its place. Resolution means the staged copy is complete and verified, not that the rotation is live.
Stop the writers before calling this, and leave them stopped through the cutover. The staged copy is taken from the files and rows as they are when each is read; a write that lands afterwards is in the live directory and not in the staged one, so swapping the directories in would discard it. A verified staging directory holds a sound copy of the data as it stood when the rotation read it.
In wrapped mode the new wrapping key is derived from newPassphrase before anything is written, so a wrong passphrase or a busy derivation gate refuses with nothing staged. newPassphrase has to be a Buffer there, raising vault-rotate/no-passphrase otherwise, because a Buffer can be zeroed once the derivation is done and a string cannot.
The call's own requirements each have a code: vault-rotate/no-datadir, vault-rotate/no-staging and vault-rotate/no-keys for the three it cannot proceed without, vault-rotate/bad-mode for a mode outside the two, vault-rotate/bad-opt for a bound of the wrong shape, and vault-rotate/staging-exists when the staging directory is already there. Every one of them is raised before stagingDir is created, so a call refused for a bad option leaves nothing behind and can be corrected and re-run. The directory is then claimed with a create, and that create's EEXIST raises vault-rotate/staging-exists a second time, so two rotations cannot share one staging directory.
While it works: vault-rotate/missing-verbatim and vault-rotate/missing-verbatim-dir name a required verbatim file or directory that is not in dataDir, vault-rotate/missing-sealed a sealed file the manifest expects, vault-rotate/bad-sealed one that will not open under the old keypair, and vault-rotate/bad-dbkey a database key it cannot re-seal. vault-rotate/external-aad-unresealed names the stores this pipeline does not reach, with the hook for each, and vault-rotate/verify-failed means the staged copy did not verify, so it is not fit to cut over to.
The verification runs through b.vaultRotate.verify over a decrypted copy of the staged database, on a handle the rotation opens itself. So rotate takes no db, and vault-rotate/no-db, which verify raises for a missing handle, cannot come from a rotation.
Call b.vaultRotate.validateSchemaMatch before calling this. rotate does not call it, and rotate re-seals the columns the field registry declares: a vault-prefixed value in a column nothing declared is copied across unchanged, and nothing can read it once the old keypair is retired. The final verification reads the declared columns as well, so it reports success over that column. validateSchemaMatch is the only call that finds it, and a rotation that skips the check can lose data at cutover.
State sealed in stores the pipeline does not reach is named in a refusal, with the hook to re-seal each one; acknowledge it through externalAadResealed once that is done.
progressCallback receives a phase per step, from init through copy_verbatim, reseal_files, rotate_db, verify and fsync to done.
var b = require("@blamejs/core");
// The application is already stopped and its writes flushed.
var report = await b.vaultRotate.rotate({
dataDir: "./data",
stagingDir: "./data-rotate-staging",
mode: "wrapped",
oldKeys: oldKeys,
newKeys: newKeys,
newPassphrase: Buffer.from(process.env.NEW_VAULT_PASSPHRASE, "utf8"),
db: db,
progressCallback: function (ev) { console.log(ev.phase); },
});
report.totalRowsProcessed;
// ./data still holds the old keypair. With the application still
// stopped, move ./data aside, put ./data-rotate-staging in its place,
// and start it again under the new passphrase.