Files
ApiCore/ApiCore-Backend/src/common/crypto/key-ring.ts
T
Nick SzaunigandClaude Opus 5.5 9795f98006 feat: Schlüssel und gemeinsamer Kern für Sprachnotizen (T7 L2 Tasks 5+6)
Task 5: Schlüsselbund-Zweck voice (VOICE_ENCRYPTION_KEY), AES-256-GCM-
Hilfen für Text und Bytes mit AAD capture:<id>:<art>, voice-config
(Modell, Tageslimit, Aufnahme, Whisper-Grösse) und anthropic-config
(nie geloggt, nur worker/all), Startprüfung verlangt den Schlüssel in
Produktion in jeder Rolle, rekey versiegelt offene Sprachnotizen neu
(Audio einzeln gelesen, CAS mit Status), Secret-Vorlagen und
ensure-secrets.sh ergänzt.

Task 6: Text-Normalisierung und Bereinigung (Steuer- und Formatzeichen,
Codepoint-Kürzung), Zitatprüfung mit Dezimalzahlen als ein Wort
(Ruling 7), Felderliste passend zu CreateInspectionDto, versiegeltes
Transkript mit Grenzen, Fachwörter und Skalen mit Paritätstests.

Rollout: Ein Image mit diesem Stand startet in Produktion nur mit
VOICE_ENCRYPTION_KEY in apicore-backend-secret (ARCHITECTURE Annahme 22).

Gates: Backend tsc, eslint, 922 Unit-Tests, e2e 100 Suites / 1004 Tests.

Refs #27 #28

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:41:47 +02:00

288 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
* voice notes, 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',
voice: 'VOICE_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' | 'voice';
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;
}