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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:41:47 +02:00
1 parent 2a4dc868d9
commit 9795f98006
27 files changed
+2212 -31

No files matched your search

+168 -14
View File
@@ -13,13 +13,29 @@ import {
sealWebhookSecret,
webhookSecretNeedsRekey,
} from '../webhooks/webhook-secret';
import { VoiceCaptureStatus } from '../../generated/prisma';
import {
isVoiceSealedWithCurrentKey,
openVoiceBytes,
openVoiceText,
sealVoiceBytes,
sealVoiceText,
voiceAad,
} from '../voice/core/voice-crypto';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/** `rekey --dry-run`: open and count every secret, write none. */
export const REKEY_DRY_RUN_ARG = '--dry-run';
/** What one run did with the secrets of one purpose (mfa, webhook). */
/** Voice content of these states is already deleted (or about to be) and is not resealed. */
const FINISHED_VOICE_STATUSES: VoiceCaptureStatus[] = [
VoiceCaptureStatus.CONFIRMED,
VoiceCaptureStatus.DISCARDED,
VoiceCaptureStatus.EXPIRED,
];
/** What one run did with the secrets of one purpose (mfa, webhook, voice). */
export interface RekeyCounts {
/** Rows with a stored secret. */
total: number;
@@ -51,6 +67,13 @@ interface SecretColumn {
/** How log lines name a row: "<entity> <id>". */
entity: string;
read(): Promise<StoredSecret[]>;
/**
* Instead of read(): the ids first, then one row at a time with readOne(),
* so large values (audio) are never held all at once. readOne gives null
* for a row that is gone or no longer eligible.
*/
readIds?(): Promise<string[]>;
readOne?(id: string): Promise<StoredSecret | null>;
/**
* Writes `next` only while the row still holds `previous` - one UPDATE
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
@@ -59,8 +82,9 @@ interface SecretColumn {
swap(id: string, previous: string, next: string): Promise<boolean>;
needsRekey(stored: string): boolean;
isPlaintext(stored: string): boolean;
open(stored: string): string;
seal(plaintext: string): string;
/** `id` binds voice content to its row (associated data); the other purposes ignore it. */
open(stored: string, id: string): string;
seal(plaintext: string, id: string): string;
}
/** Refuses anything but --dry-run: a typo must never start the real run. */
@@ -87,6 +111,20 @@ function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
};
}
/** Sums the counts of columns that share one purpose (voice has three). */
function sumCounts(all: readonly RekeyCounts[]): RekeyCounts {
const sum = (key: keyof RekeyCounts): number =>
all.reduce((total, each) => total + each[key], 0);
return {
total: sum('total'),
rekeyed: sum('rekeyed'),
alreadyCurrent: sum('alreadyCurrent'),
plaintextSealed: sum('plaintextSealed'),
changedMeanwhile: sum('changedMeanwhile'),
failed: sum('failed'),
};
}
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
function toJobResult(
dryRun: boolean,
@@ -112,7 +150,8 @@ function describeResult(result: JobResult): string {
}
/**
* Reseals every stored MFA (TOTP) and webhook signing secret with the
* Reseals every stored MFA (TOTP) and webhook signing secret, and the
* content of open voice notes (transcript, suggestion, audio), with the
* current key of its purpose (common/crypto/key-ring.ts): values sealed
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
@@ -135,15 +174,30 @@ export class RekeyJob implements Job {
async run(args: readonly string[]): Promise<JobResult> {
const dryRun = isDryRun(args);
const columns = [this.mfaColumn(), this.webhookColumn()];
const columns = [
this.mfaColumn(),
this.webhookColumn(),
...this.voiceColumns(),
];
// A key that is set but unusable fails the run before any row is read.
for (const column of columns) {
loadKeyRing(column.purpose);
for (const purpose of new Set(columns.map((column) => column.purpose))) {
loadKeyRing(purpose);
}
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
const perColumn: (readonly [SecretPurpose, RekeyCounts])[] = [];
for (const column of columns) {
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
perColumn.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
}
const counts = (['mfa', 'webhook', 'voice'] as const).map(
(purpose) =>
[
purpose,
sumCounts(
perColumn
.filter(([each]) => each === purpose)
.map(([, each]) => each),
),
] as const,
);
const result = toJobResult(dryRun, counts);
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
if (failed > 0) {
@@ -158,9 +212,17 @@ export class RekeyJob implements Job {
column: SecretColumn,
dryRun: boolean,
): Promise<RekeyCounts> {
const rows = await column.read();
const outcomes: RowOutcome[] = [];
for (const row of rows) {
if (column.readIds && column.readOne) {
for (const id of await column.readIds()) {
const row = await column.readOne(id);
if (row !== null) {
outcomes.push(await this.rekeyRow(column, row, dryRun));
}
}
return countOutcomes(outcomes);
}
for (const row of await column.read()) {
outcomes.push(await this.rekeyRow(column, row, dryRun));
}
return countOutcomes(outcomes);
@@ -185,13 +247,13 @@ export class RekeyJob implements Job {
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
try {
// Opened in a dry-run too: it reports what a real run could not reseal.
const plaintext = column.open(row.stored);
const plaintext = column.open(row.stored, row.id);
if (dryRun) {
return outcome;
}
const resealed = column.seal(plaintext);
const resealed = column.seal(plaintext, row.id);
// The swap replaces the only copy of the old value.
if (column.open(resealed) !== plaintext) {
if (column.open(resealed, row.id) !== plaintext) {
throw new Error('resealed secret does not open to the same value');
}
if (await column.swap(row.id, row.stored, resealed)) {
@@ -262,4 +324,96 @@ export class RekeyJob implements Job {
seal: sealWebhookSecret,
};
}
/**
* Transcript, suggestion and audio of open voice notes. Audio is handled
* as base64 text so it shares the row logic; it is read only for open
* notes (at most a few small files).
*/
private voiceColumns(): SecretColumn[] {
const openStatus = { notIn: FINISHED_VOICE_STATUSES };
const textColumn = (
field: 'transcriptEnc' | 'suggestionEnc',
kind: 'transcript' | 'suggestion',
): SecretColumn => ({
purpose: 'voice',
entity: 'VoiceCapture',
read: async () => {
const rows = await this.prisma.voiceCapture.findMany({
where: { status: openStatus, [field]: { not: null } },
select: { id: true, [field]: true },
orderBy: { id: 'asc' },
});
return rows.flatMap((row) => {
const stored = row[field];
return typeof stored === 'string' ? [{ id: row.id, stored }] : [];
});
},
swap: async (id, previous, next) => {
const { count } = await this.prisma.voiceCapture.updateMany({
where: { id, status: openStatus, [field]: previous },
data: { [field]: next },
});
return count === 1;
},
needsRekey: (stored) => !isVoiceSealedWithCurrentKey(stored),
isPlaintext: () => false,
open: (stored, id) => openVoiceText(stored, voiceAad(id, kind)),
seal: (plaintext, id) => sealVoiceText(plaintext, voiceAad(id, kind)),
});
const audioColumn: SecretColumn = {
purpose: 'voice',
entity: 'VoiceCapture',
read: () => Promise.resolve([]),
readIds: async () => {
const rows = await this.prisma.voiceCaptureAudio.findMany({
where: { capture: { status: openStatus } },
select: { captureId: true },
orderBy: { captureId: 'asc' },
});
return rows.map((row) => row.captureId);
},
readOne: async (id) => {
const row = await this.prisma.voiceCaptureAudio.findFirst({
where: { captureId: id, capture: { status: openStatus } },
select: { captureId: true, dataEnc: true },
});
return row
? {
id: row.captureId,
stored: Buffer.from(row.dataEnc).toString('base64'),
}
: null;
},
swap: async (id, previous, next) => {
const { count } = await this.prisma.voiceCaptureAudio.updateMany({
where: {
captureId: id,
dataEnc: Buffer.from(previous, 'base64'),
capture: { status: openStatus },
},
data: { dataEnc: Buffer.from(next, 'base64') },
});
return count === 1;
},
needsRekey: (stored) =>
!isVoiceSealedWithCurrentKey(Buffer.from(stored, 'base64')),
isPlaintext: () => false,
open: (stored, id) =>
openVoiceBytes(
Buffer.from(stored, 'base64'),
voiceAad(id, 'audio'),
).toString('base64'),
seal: (plaintext, id) =>
sealVoiceBytes(
Buffer.from(plaintext, 'base64'),
voiceAad(id, 'audio'),
).toString('base64'),
};
return [
textColumn('transcriptEnc', 'transcript'),
textColumn('suggestionEnc', 'suggestion'),
audioColumn,
];
}
}