Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
169 lines
5.2 KiB
TypeScript
169 lines
5.2 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import * as crypto from 'node:crypto';
|
|
import * as zlib from 'node:zlib';
|
|
import {
|
|
ENCRYPTION_KEY_VARIABLES,
|
|
devFallbackSecret,
|
|
findRingKey,
|
|
gcmOpen,
|
|
gcmSeal,
|
|
readRingSecrets,
|
|
requireJwtAccessSecret,
|
|
type KeyRing,
|
|
type RingKey,
|
|
} from '../common/crypto/key-ring';
|
|
|
|
/**
|
|
* A backup holds everything - password hashes, encrypted MFA secrets,
|
|
* webhook signing secrets - and an import writes straight into the users
|
|
* table. So the file is sealed: AES-256-GCM over the gzipped bundle, which
|
|
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
|
* hand-made file) fail authentication before a single row is touched.
|
|
*
|
|
* Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY
|
|
* seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before
|
|
* the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are
|
|
* still read (never written, except outside production without
|
|
* BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the
|
|
* AES key comes from HKDF with a backup-specific label, never the raw
|
|
* secret; derivation and file format v3 are unchanged, so older files open.
|
|
*/
|
|
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
|
|
export const SEALED_BACKUP_VERSION = 3;
|
|
|
|
// Decompressed size cap: a small file must not unpack into gigabytes.
|
|
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
|
|
|
export interface SealedBackup {
|
|
format: typeof SEALED_BACKUP_FORMAT;
|
|
version: typeof SEALED_BACKUP_VERSION;
|
|
algorithm: 'A256GCM+gzip';
|
|
/** Tells which key a file needs, without revealing the key. */
|
|
keyId: string;
|
|
exportedAt: string;
|
|
iv: string;
|
|
tag: string;
|
|
ciphertext: string;
|
|
}
|
|
|
|
type KeySource = 'backup' | 'jwt';
|
|
|
|
function deriveBackupKey(secret: string, source: KeySource): RingKey {
|
|
const key = Buffer.from(
|
|
crypto.hkdfSync(
|
|
'sha256',
|
|
secret,
|
|
'apicore-backup',
|
|
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
|
|
32,
|
|
),
|
|
);
|
|
const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
|
|
return { id, key };
|
|
}
|
|
|
|
function legacyBackupKey(): RingKey {
|
|
return deriveBackupKey(requireJwtAccessSecret(), 'jwt');
|
|
}
|
|
|
|
function loadBackupRing(): KeyRing {
|
|
const variable = ENCRYPTION_KEY_VARIABLES.backup;
|
|
const secrets = readRingSecrets(variable);
|
|
return {
|
|
current: secrets.current
|
|
? deriveBackupKey(secrets.current, 'backup')
|
|
: deriveBackupKey(devFallbackSecret(variable), 'jwt'),
|
|
previous: secrets.previous
|
|
? deriveBackupKey(secrets.previous, 'backup')
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */
|
|
function keyForReading(keyId: string): RingKey | null {
|
|
const ringKey = findRingKey(loadBackupRing(), keyId);
|
|
if (ringKey) {
|
|
return ringKey;
|
|
}
|
|
const legacy = legacyBackupKey();
|
|
return legacy.id === keyId ? legacy : null;
|
|
}
|
|
|
|
function associatedData(
|
|
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
|
|
): Buffer {
|
|
return Buffer.from(
|
|
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
|
|
);
|
|
}
|
|
|
|
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
|
const { current } = loadBackupRing();
|
|
const header = {
|
|
format: SEALED_BACKUP_FORMAT,
|
|
version: SEALED_BACKUP_VERSION,
|
|
keyId: current.id,
|
|
exportedAt,
|
|
} as const;
|
|
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
|
const sealed = gcmSeal(current.key, compressed, associatedData(header));
|
|
return {
|
|
...header,
|
|
algorithm: 'A256GCM+gzip',
|
|
iv: sealed.iv.toString('base64'),
|
|
tag: sealed.tag.toString('base64'),
|
|
ciphertext: sealed.ciphertext.toString('base64'),
|
|
};
|
|
}
|
|
|
|
export function isSealedBackup(value: unknown): value is SealedBackup {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false;
|
|
}
|
|
const candidate = value as Record<string, unknown>;
|
|
return (
|
|
candidate.format === SEALED_BACKUP_FORMAT &&
|
|
typeof candidate.ciphertext === 'string'
|
|
);
|
|
}
|
|
|
|
/** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */
|
|
export function openBackup(sealed: SealedBackup): unknown {
|
|
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
|
throw new BadRequestException(
|
|
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
|
);
|
|
}
|
|
const ringKey = keyForReading(sealed.keyId);
|
|
if (!ringKey) {
|
|
throw new BadRequestException(
|
|
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)',
|
|
);
|
|
}
|
|
let compressed: Buffer;
|
|
try {
|
|
compressed = gcmOpen(
|
|
ringKey.key,
|
|
{
|
|
iv: Buffer.from(sealed.iv, 'base64'),
|
|
tag: Buffer.from(sealed.tag, 'base64'),
|
|
ciphertext: Buffer.from(sealed.ciphertext, 'base64'),
|
|
},
|
|
associatedData(sealed),
|
|
);
|
|
} catch {
|
|
throw new BadRequestException(
|
|
'Backup-Datei ist beschädigt oder wurde verändert',
|
|
);
|
|
}
|
|
let json: string;
|
|
try {
|
|
json = zlib
|
|
.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES })
|
|
.toString('utf-8');
|
|
} catch {
|
|
throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt');
|
|
}
|
|
return JSON.parse(json) as unknown;
|
|
}
|