115 lines
5.1 KiB
TypeScript
115 lines
5.1 KiB
TypeScript
/**
|
|
* Regression guard for audit 2026-06-26 #6: backup_destinations were excluded
|
|
* from ENCRYPTION_KEY rotation/migration in encryption.ts::migrateCredentials.
|
|
* After an app-key rotation every OTHER credential table was re-encrypted with
|
|
* the new key while backup destination password/envVars stayed on the OLD key,
|
|
* so restic got the literal `enc:v1:...` blob as its password and every backup
|
|
* broke silently.
|
|
*
|
|
* migrateCredentials talks to better-sqlite3, which bun test can't load, so we
|
|
* can't drive the rotation end-to-end here. Instead we assert — at the source
|
|
* level — that the migrator actually references the backupDestinations table in
|
|
* BOTH the rotation branch and the plain-text migration branch. This is the
|
|
* exact thing the bug got wrong: a hardcoded table list that silently omitted
|
|
* one table. If someone drops the coverage again, this fails.
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const encryptionSrc = readFileSync(
|
|
join(here, '..', 'src', 'lib', 'server', 'encryption.ts'),
|
|
'utf8'
|
|
);
|
|
|
|
describe('encryption key rotation covers backup destinations (audit #6)', () => {
|
|
it('imports the backupDestinations table into migrateCredentials', () => {
|
|
assert.match(
|
|
encryptionSrc,
|
|
/backupDestinations/,
|
|
'migrateCredentials must import/use backupDestinations or key rotation silently breaks backups'
|
|
);
|
|
});
|
|
|
|
it('re-encrypts backup destination password and envVars during rotation', () => {
|
|
// The rotation branch collects encrypted values into allEncrypted; both
|
|
// fields must be pushed so the new key re-encrypts them.
|
|
assert.match(encryptionSrc, /table:\s*'backupDestinations',\s*id:\s*dest\.id,\s*field:\s*'password'/);
|
|
assert.match(encryptionSrc, /table:\s*'backupDestinations',\s*id:\s*dest\.id,\s*field:\s*'envVars'/);
|
|
});
|
|
|
|
it('writes the re-encrypted backup destination values back on rotation', () => {
|
|
// HIGH #7 (2026-07-02) moved the rotation writes into ONE transaction, so
|
|
// the write goes through the tx handle rather than db directly.
|
|
assert.match(
|
|
encryptionSrc,
|
|
/item\.table === 'backupDestinations'[\s\S]{0,200}(db|tx)\.update\(backupDestinations\)/,
|
|
'rotation must (db|tx).update(backupDestinations) with the re-encrypted value'
|
|
);
|
|
});
|
|
|
|
it('migrates legacy plain-text backup destination secrets to encrypted form', () => {
|
|
// The plain-text branch must also touch backupDestinations, not just the
|
|
// other credential tables.
|
|
assert.match(
|
|
encryptionSrc,
|
|
/dest\.password[\s\S]{0,120}encrypt\(dest\.password\)/,
|
|
'plain-text migration must encrypt an unencrypted backup destination password'
|
|
);
|
|
});
|
|
});
|
|
|
|
// Same bug class for the secret_providers table: its `config` column holds the
|
|
// encrypted Vault/Doppler/1Password token. If rotation omits it, the provider
|
|
// config stays on the OLD key and becomes permanently undecryptable once the key
|
|
// file is deleted, so every provider-bound stack fails to deploy.
|
|
describe('encryption key rotation covers secret providers', () => {
|
|
it('imports the secretProviders table into migrateCredentials', () => {
|
|
assert.match(
|
|
encryptionSrc,
|
|
/secretProviders/,
|
|
'migrateCredentials must import/use secretProviders or key rotation breaks provider-bound stacks'
|
|
);
|
|
});
|
|
|
|
it('collects the secret provider config for re-encryption during rotation', () => {
|
|
assert.match(encryptionSrc, /table:\s*'secretProviders',\s*id:\s*p\.id,\s*field:\s*'config'/);
|
|
});
|
|
|
|
it('writes the re-encrypted secret provider config back on rotation', () => {
|
|
assert.match(
|
|
encryptionSrc,
|
|
/item\.table === 'secretProviders'[\s\S]{0,200}(db|tx)\.update\(secretProviders\)/,
|
|
'rotation must (db|tx).update(secretProviders) with the re-encrypted config'
|
|
);
|
|
});
|
|
});
|
|
|
|
// The rollback envelope is the reason a mid-rotation crash can't strand
|
|
// credentials as an undecryptable old/new-key mix. A true execution test isn't
|
|
// possible here (migrateCredentials imports better-sqlite3, which bun test can't
|
|
// load, and the HTTP API can't force a mid-transaction failure), so we assert the
|
|
// safety contract at the source level: it must survive any future refactor.
|
|
describe('encryption key rotation rolls back atomically on failure', () => {
|
|
it('re-encrypts every credential inside a single db.transaction', () => {
|
|
assert.match(
|
|
encryptionSrc,
|
|
/db\.transaction\(async \(tx[\s\S]*?for \(const item of allEncrypted\)/,
|
|
'the re-encrypt loop must run inside db.transaction so a mid-loop failure rolls back'
|
|
);
|
|
});
|
|
|
|
it('captures the old key and restores it when the transaction throws', () => {
|
|
assert.match(encryptionSrc, /const oldKey = cachedKey;/, 'must capture the old key before switching');
|
|
// The catch block must put the in-memory key back to match the rolled-back DB.
|
|
assert.match(
|
|
encryptionSrc,
|
|
/catch \([\s\S]*?cachedKey = oldKey;[\s\S]*?throw /,
|
|
'on failure the catch must restore cachedKey = oldKey AND rethrow so nothing becomes unrecoverable'
|
|
);
|
|
});
|
|
});
|