fix(backup): refuse a restore that needs a dropped key or reaches into another account

A backup carries every encrypted column as the stored ciphertext, and a
restore writes it back verbatim. Key rotation re-seals the stored copy's
envelope but cannot reach inside it, so a copy taken before a rotation
still needs the retired key. Without it the restore went through and
left rows no reader can open.

The restore, the restore preview, the admin upload and the monthly
restore drill now collect the key ids of every inner ciphertext value,
hold them against ENCRYPTION_KEYS and decrypt one value per key. A
missing key, or an id configured with different key material, is
refused before anything is deleted, with the key id named. The restore
status and the preview say which key to put back.

The restore also checks, inside its transaction, that it added no
reference from the account's rows to rows another account owns. The
check is derived from the schema's foreign keys rather than written per
section, so the tag and category upserts keyed on a global key are
covered along with every link table. The illness parent update is
scoped to the owner as well.

The off-host listing now follows every page: the first page held only
the oldest dates, so the drill could read an old object and report the
chain as stale.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:22:03 +02:00
1 parent 4af6b87a2a
commit 405c996b6a
24 files changed
+1249 -25

No files matched your search

@@ -22,9 +22,15 @@ import {
storedBackupRefusal,
STORED_BACKUP_SELECT,
} from "@/lib/export/stored-backup";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
@@ -72,8 +78,9 @@ export const GET = apiHandler(
let payload;
let measurementCount: number;
let streamed: StreamedBackup;
try {
const streamed = await readStreamedBackup(source);
streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch (err) {
@@ -93,6 +100,22 @@ export const GET = apiHandler(
);
}
// The preview is where the operator decides, so it says what the restore
// would refuse: a key the file's inner ciphertext needs and this host
// does not have. The instance settings are left out here, as the restore
// leaves them out unless asked; the restore itself checks them when they
// are asked for.
const keyVerdict = assessBackupKeys(streamed.keys, {
ignoreSections: new Set(["appSettings"]),
});
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
return apiError(keyProblem, 422, {
errorCode: BACKUP_KEY_MISSING_CODE,
keyIds: [...keyVerdict.missing, ...keyVerdict.unreadable],
});
}
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
+29
View File
@@ -38,6 +38,12 @@ import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
BackupKeyIdCollector,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import {
BACKUP_UPLOAD_TOO_LARGE_CODE,
BackupBlobTooLargeError,
@@ -235,6 +241,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
let firstWithoutId: number | null = null;
const keys = new BackupKeyIdCollector();
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {
@@ -251,6 +258,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.data.id && firstWithoutId === null) {
firstWithoutId = index;
}
keys.visit(element, "measurements");
},
});
} catch (err) {
@@ -319,6 +327,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
// A file whose inner ciphertext needs a key this host does not have
// can be stored but never restored: the restore would refuse it, or
// worse, a restore on a host that had lost the key would write rows
// nobody can open. Refused here, where the operator is looking, with
// the key named. The instance settings are left to the restore, which
// checks them only when they are asked for back.
keys.visit(scanned.document);
const keyVerdict = assessBackupKeys(keys, {
ignoreSections: new Set(["appSettings"]),
});
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable];
throw new UploadRefused(
422,
keyProblem,
{ reason: "key_missing", keyIds },
{ errorCode: BACKUP_KEY_MISSING_CODE, keyIds },
);
}
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.