mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user