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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user