feat: platform backbone and field capture, first part (checkpoint)

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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

+67 -54
View File
@@ -1,6 +1,17 @@
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,
@@ -9,17 +20,17 @@ import * as zlib from 'node:zlib';
* keeps it unreadable outside ApiCore AND makes any change to it (or a
* hand-made file) fail authentication before a single row is touched.
*
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
* can always restore its own backups. Either way the AES key comes from
* HKDF with a backup-specific label, never the raw secret.
* 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;
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const MIN_KEY_LENGTH = 32;
// Decompressed size cap: a small file must not unpack into gigabytes.
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
@@ -35,25 +46,9 @@ export interface SealedBackup {
ciphertext: string;
}
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
if (backupKey) {
if (backupKey.length < MIN_KEY_LENGTH) {
throw new Error(
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
);
}
return { secret: backupKey, source: 'backup' };
}
const jwtSecret = process.env.JWT_ACCESS_SECRET;
if (!jwtSecret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return { secret: jwtSecret, source: 'jwt' };
}
type KeySource = 'backup' | 'jwt';
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
function deriveBackupKey(secret: string, source: KeySource): RingKey {
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
@@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } {
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
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(
@@ -80,24 +98,21 @@ function associatedData(
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const { current } = loadBackupRing();
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
keyId: current.id,
exportedAt,
} as const;
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
cipher.setAAD(associatedData(header));
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const sealed = gcmSeal(current.key, compressed, associatedData(header));
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
iv: sealed.iv.toString('base64'),
tag: sealed.tag.toString('base64'),
ciphertext: sealed.ciphertext.toString('base64'),
};
}
@@ -112,32 +127,30 @@ export function isSealedBackup(value: unknown): value is SealedBackup {
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
/** 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 { key, keyId } = deriveKey();
if (sealed.keyId !== keyId) {
const ringKey = keyForReading(sealed.keyId);
if (!ringKey) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
compressed = gcmOpen(
ringKey.key,
{
iv: Buffer.from(sealed.iv, 'base64'),
tag: Buffer.from(sealed.tag, 'base64'),
ciphertext: Buffer.from(sealed.ciphertext, 'base64'),
},
associatedData(sealed),
);
decipher.setAAD(associatedData(sealed));
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
compressed = Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
decipher.final(),
]);
} catch {
throw new BadRequestException(
'Backup-Datei ist beschädigt oder wurde verändert',