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

@@ -0,0 +1,286 @@
import { Logger } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
hkdfSync,
randomBytes,
} from 'node:crypto';
/**
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
* backup files) are encrypted with a key of their own per purpose, never
* with a JWT secret. Each purpose has a key ring: the current key from
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
* opens, so a key can be rotated without losing data. A sealed value names
* its key: `k<8 hex>:<iv>.<tag>.<data>` (base64 parts, AES-256-GCM).
*
* The AES key comes from HKDF-SHA256 with a per-purpose label, so even the
* same secret never yields the same key for two purposes. Outside
* production a missing variable falls back to JWT_ACCESS_SECRET (still per
* purpose, logged once); production refuses to start without the variables
* (runtime/startup-checks.ts).
*
* Values from before the key ring carry no key id and were encrypted with
* sha256(JWT_ACCESS_SECRET). They stay readable (never written) until the
* rekey job reseals them - and only as long as JWT_ACCESS_SECRET is unchanged.
*/
export const MIN_ENCRYPTION_KEY_LENGTH = 32;
export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/;
export const PREVIOUS_KEY_SUFFIX = '_PREVIOUS';
export const ENCRYPTION_KEY_VARIABLES = {
mfa: 'MFA_ENCRYPTION_KEY',
webhook: 'WEBHOOK_ENCRYPTION_KEY',
backup: 'BACKUP_ENCRYPTION_KEY',
} as const;
export type EncryptionKeyVariable =
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
export type SecretPurpose = 'mfa' | 'webhook';
export interface RingKey {
id: string;
key: Buffer;
}
export interface KeyRing {
current: RingKey;
previous: RingKey | null;
}
/** The configured secrets behind one ring, before key derivation. */
export interface RingSecrets {
current: string | null;
previous: string | null;
}
/** AES-256-GCM output, in parts, so each format chooses its own encoding. */
export interface GcmSealed {
iv: Buffer;
tag: Buffer;
ciphertext: Buffer;
}
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const KEY_BYTES = 32;
const HKDF_SALT = 'apicore-key-ring';
const LEGACY_READ_WARNING = 'legacy ciphertext read – run the rekey job';
const DEV_FALLBACK_WARNING =
'not set – using development key derived from JWT_ACCESS_SECRET; never in production';
const logger = new Logger('KeyRing');
const issuedWarnings = new Set<string>();
/** Each warning once per process: they describe configuration, not events. */
function warnOnce(message: string): void {
if (issuedWarnings.has(message)) {
return;
}
issuedWarnings.add(message);
logger.warn(message);
}
function readKey(name: string, env: NodeJS.ProcessEnv): string | null {
const value = env[name];
if (!value) {
return null;
}
if (value.length < MIN_ENCRYPTION_KEY_LENGTH) {
throw new Error(
`${name} must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters - generate one with e.g. "openssl rand -hex 32"`,
);
}
return value;
}
/**
* `<variable>` and `<variable>_PREVIOUS`, each at least 32 characters when
* set. The current one is mandatory in production. Errors name the
* variable, never its value.
*/
export function readRingSecrets(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): RingSecrets {
const current = readKey(variable, env);
const previous = readKey(`${variable}${PREVIOUS_KEY_SUFFIX}`, env);
if (!current && env.NODE_ENV === 'production') {
throw new Error(`${variable} is not set`);
}
return { current, previous };
}
export function requireJwtAccessSecret(
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return secret;
}
/**
* The stand-in secret for an unset `variable` outside production (in
* production readRingSecrets() has already refused). Logged once per
* variable, without values.
*/
export function devFallbackSecret(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = requireJwtAccessSecret(env);
warnOnce(`${variable} ${DEV_FALLBACK_WARNING}`);
return secret;
}
function deriveRingKey(purpose: SecretPurpose, secret: string): RingKey {
const key = Buffer.from(
hkdfSync('sha256', secret, HKDF_SALT, `apicore-${purpose}-v1`, KEY_BYTES),
);
const id = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
return { id, key };
}
/** The ring of one purpose; outside production a missing key falls back to JWT_ACCESS_SECRET. */
export function loadKeyRing(
purpose: SecretPurpose,
env: NodeJS.ProcessEnv = process.env,
): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES[purpose];
const secrets = readRingSecrets(variable, env);
return {
current: deriveRingKey(
purpose,
secrets.current ?? devFallbackSecret(variable, env),
),
previous: secrets.previous
? deriveRingKey(purpose, secrets.previous)
: null,
};
}
export function findRingKey(ring: KeyRing, keyId: string): RingKey | null {
if (ring.current.id === keyId) {
return ring.current;
}
return ring.previous?.id === keyId ? ring.previous : null;
}
export function gcmSeal(
key: Buffer,
plaintext: Buffer,
associatedData?: Buffer,
): GcmSealed {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
cipher.setAAD(associatedData);
}
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return { iv, tag: cipher.getAuthTag(), ciphertext };
}
/**
* Fails closed: a short tag (which GCM would otherwise accept), a wrong key
* or any changed byte throws, and the error carries no input.
*/
export function gcmOpen(
key: Buffer,
sealed: GcmSealed,
associatedData?: Buffer,
): Buffer {
if (sealed.iv.length !== IV_BYTES || sealed.tag.length !== AUTH_TAG_BYTES) {
throw new Error('malformed ciphertext');
}
try {
const decipher = createDecipheriv(ALGORITHM, key, sealed.iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
decipher.setAAD(associatedData);
}
decipher.setAuthTag(sealed.tag);
return Buffer.concat([
decipher.update(sealed.ciphertext),
decipher.final(),
]);
} catch {
throw new Error('ciphertext failed authentication');
}
}
function sealPayload(key: Buffer, plaintext: string): string {
const sealed = gcmSeal(key, Buffer.from(plaintext, 'utf8'));
return [sealed.iv, sealed.tag, sealed.ciphertext]
.map((part) => part.toString('base64'))
.join('.');
}
function openPayload(key: Buffer, payload: string): string {
const parts = payload.split('.');
if (parts.length !== 3) {
throw new Error('malformed ciphertext');
}
const [iv, tag, ciphertext] = parts.map((part) =>
Buffer.from(part, 'base64'),
);
return gcmOpen(key, { iv, tag, ciphertext }).toString('utf8');
}
/** `${id}:${iv}.${tag}.${data}`, always with the current key. */
export function sealWithRing(ring: KeyRing, plaintext: string): string {
return `${ring.current.id}:${sealPayload(ring.current.key, plaintext)}`;
}
export function openWithRing(
ring: KeyRing,
stored: string,
): { plaintext: string; keyId: string } {
if (!KEY_ID_PATTERN.test(stored)) {
throw new Error('missing key id');
}
const separator = stored.indexOf(':');
const keyId = stored.slice(0, separator);
const ringKey = findRingKey(ring, keyId);
if (!ringKey) {
throw new Error('unknown key id');
}
return {
plaintext: openPayload(ringKey.key, stored.slice(separator + 1)),
keyId,
};
}
/** False for anything the rekey job should reseal: previous key, legacy or plain values. */
export function isSealedWithCurrentKey(ring: KeyRing, stored: string): boolean {
return stored.startsWith(`${ring.current.id}:`);
}
/** sha256(JWT_ACCESS_SECRET), the key of values from before the key ring - only ever used to read. */
export function legacyJwtKey(env: NodeJS.ProcessEnv = process.env): Buffer {
return createHash('sha256').update(requireJwtAccessSecret(env)).digest();
}
/** Logs once per process and purpose that a pre-key-ring value was read, without the value. */
export function warnLegacyRead(purpose: SecretPurpose): void {
warnOnce(`${LEGACY_READ_WARNING} (${purpose})`);
}
/** Opens a pre-key-ring `<iv>.<tag>.<data>` value sealed with legacyJwtKey(). */
export function openLegacyCiphertext(
purpose: SecretPurpose,
payload: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const plaintext = openPayload(legacyJwtKey(env), payload);
warnLegacyRead(purpose);
return plaintext;
}