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]>
288 lines
8.8 KiB
TypeScript
288 lines
8.8 KiB
TypeScript
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;
|
||
}
|