diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f161c37..5789525 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -141,7 +141,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. 20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`. 21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist. -22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:..` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore--v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. +22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien), `VOICE_ENCRYPTION_KEY` (Sprachnotizen: Transkript, Vorschlag, Aufnahme; seit T7, AAD `capture::`). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:..` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore--v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die vier Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. Der Job `rekey` versiegelt beim Schlüsseltausch nur offene Sprachnotizen neu (bestätigte, verworfene und abgelaufene haben keinen Inhalt mehr). **Rollout:** Ein Image mit T7 startet in Produktion in keiner Rolle ohne `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret`; vorher `deploy/k8s/scripts/ensure-secrets.sh` ausführen und den Schlüssel wie die anderen sichern. 23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. **Rotation über `*_PREVIOUS`:** alten Wert nach `_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. **Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar). diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 98359ad..3b65b95 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -82,9 +82,12 @@ BACKUP_RETENTION_COUNT=14 # MFA_ENCRYPTION_KEY="" # WEBHOOK_ENCRYPTION_KEY="" # BACKUP_ENCRYPTION_KEY="" +# Seals voice notes (audio, transcripts, suggestions); required in production. +# VOICE_ENCRYPTION_KEY="" # MFA_ENCRYPTION_KEY_PREVIOUS="" # WEBHOOK_ENCRYPTION_KEY_PREVIOUS="" # BACKUP_ENCRYPTION_KEY_PREVIOUS="" +# VOICE_ENCRYPTION_KEY_PREVIOUS="" # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). diff --git a/ApiCore-Backend/src/common/crypto/key-ring.ts b/ApiCore-Backend/src/common/crypto/key-ring.ts index 1ba5235..d9e6ede 100644 --- a/ApiCore-Backend/src/common/crypto/key-ring.ts +++ b/ApiCore-Backend/src/common/crypto/key-ring.ts @@ -9,7 +9,7 @@ import { /** * Secrets the server must read back (TOTP secrets, webhook signing secrets, - * backup files) are encrypted with a key of their own per purpose, never + * 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 * `` seals, the previous one from `_PREVIOUS` still * opens, so a key can be rotated without losing data. A sealed value names @@ -33,13 +33,14 @@ 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'; +export type SecretPurpose = 'mfa' | 'webhook' | 'voice'; export interface RingKey { id: string; diff --git a/ApiCore-Backend/src/jobs/rekey.job.spec.ts b/ApiCore-Backend/src/jobs/rekey.job.spec.ts index 815fbf9..ed33257 100644 --- a/ApiCore-Backend/src/jobs/rekey.job.spec.ts +++ b/ApiCore-Backend/src/jobs/rekey.job.spec.ts @@ -10,6 +10,14 @@ import { openWebhookSecret, sealWebhookSecret, } from '../webhooks/webhook-secret'; +import { + isVoiceSealedWithCurrentKey, + openVoiceBytes, + openVoiceText, + sealVoiceBytes, + sealVoiceText, + voiceAad, +} from '../voice/core/voice-crypto'; import { RekeyJob } from './rekey.job'; // Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40 @@ -67,11 +75,139 @@ function sealedWithPrevious(variable: string, seal: () => string): string { } } +interface CaptureRow { + id: string; + status: string; + transcriptEnc: string | null; + suggestionEnc: string | null; +} + +interface AudioRow { + captureId: string; + dataEnc: Buffer; +} + +interface VoiceFixture { + captures: CaptureRow[]; + audio: AudioRow[]; +} + +interface StatusFilter { + status?: { notIn: string[] }; + capture?: { status: { notIn: string[] } }; + transcriptEnc?: { not: null } | string; + suggestionEnc?: { not: null } | string; +} + +interface CaptureUpdate { + where: { id: string; status: { notIn: string[] } } & Record; + data: Record; +} + +interface AudioUpdate { + where: { + captureId: string; + dataEnc: Buffer; + capture: { status: { notIn: string[] } }; + }; + data: { dataEnc: Buffer }; +} + +/** The voice tables, in memory, with the where-shapes the job uses. */ +function fakeVoice({ captures, audio }: VoiceFixture) { + const captureRows = new Map(captures.map((row) => [row.id, { ...row }])); + const audioRows = new Map(audio.map((row) => [row.captureId, { ...row }])); + const open = (id: string, notIn: string[]): boolean => { + const row = captureRows.get(id); + return row !== undefined && !notIn.includes(row.status); + }; + const voiceCapture = { + findMany: jest.fn(({ where }: { where: StatusFilter }) => + Promise.resolve( + [...captureRows.values()].filter( + (row) => + open(row.id, where.status?.notIn ?? []) && + (where.transcriptEnc === undefined || row.transcriptEnc !== null) && + (where.suggestionEnc === undefined || row.suggestionEnc !== null), + ), + ), + ), + updateMany: jest.fn, [CaptureUpdate]>( + ({ where, data }) => { + const row = captureRows.get(where.id); + const column = + 'transcriptEnc' in where ? 'transcriptEnc' : 'suggestionEnc'; + if ( + !row || + !open(where.id, where.status.notIn) || + row[column] !== where[column] + ) { + return Promise.resolve({ count: 0 }); + } + captureRows.set(where.id, { ...row, [column]: data[column] }); + return Promise.resolve({ count: 1 }); + }, + ), + }; + const voiceCaptureAudio = { + findMany: jest.fn(({ where }: { where: StatusFilter }) => + Promise.resolve( + [...audioRows.values()] + .filter((row) => + open(row.captureId, where.capture?.status.notIn ?? []), + ) + .map((row) => ({ captureId: row.captureId })), + ), + ), + findFirst: jest.fn( + ({ where }: { where: { captureId: string } & StatusFilter }) => { + const row = audioRows.get(where.captureId); + return Promise.resolve( + row && open(row.captureId, where.capture?.status.notIn ?? []) + ? { ...row } + : null, + ); + }, + ), + updateMany: jest.fn, [AudioUpdate]>( + ({ where, data }) => { + const row = audioRows.get(where.captureId); + if ( + !row || + !open(where.captureId, where.capture.status.notIn) || + !row.dataEnc.equals(where.dataEnc) + ) { + return Promise.resolve({ count: 0 }); + } + audioRows.set(where.captureId, { ...row, dataEnc: data.dataEnc }); + return Promise.resolve({ count: 1 }); + }, + ), + }; + return { + voiceCapture, + voiceCaptureAudio, + transcript: (id: string): string => + captureRows.get(id)?.transcriptEnc ?? '', + suggestion: (id: string): string => + captureRows.get(id)?.suggestionEnc ?? '', + audio: (id: string): Buffer => + audioRows.get(id)?.dataEnc ?? Buffer.alloc(0), + captureRows, + audioRows, + }; +} + /** - * The two tables, in memory. updateMany behaves like the SQL compare-and- + * The tables, in memory. updateMany behaves like the SQL compare-and- * swap: it writes only while id and old value still match. */ -function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) { +function fakeDb( + users: UserRow[], + subscriptions: SubscriptionRow[], + voiceFixture: VoiceFixture = { captures: [], audio: [] }, +) { + const voice = fakeVoice(voiceFixture); const userRows = new Map(users.map((row) => [row.id, row])); const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row])); const user = { @@ -105,9 +241,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) { ), }; return { - prisma: { user, webhookSubscription } as unknown as PrismaService, + prisma: { + user, + webhookSubscription, + voiceCapture: voice.voiceCapture, + voiceCaptureAudio: voice.voiceCaptureAudio, + } as unknown as PrismaService, user, webhookSubscription, + voice, userRows, subscriptionRows, mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '', @@ -118,6 +260,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) { const keyIdOf = (stored: string): string => stored.split(':')[0]; +const NO_VOICE = { + 'voice.total': 0, + 'voice.rekeyed': 0, + 'voice.alreadyCurrent': 0, + 'voice.plaintextSealed': 0, + 'voice.changedMeanwhile': 0, + 'voice.failed': 0, +}; + describe('RekeyJob', () => { const originalEnv = { ...process.env }; let warn: jest.SpyInstance; @@ -129,6 +280,8 @@ describe('RekeyJob', () => { process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40); process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40); process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40); + process.env.VOICE_ENCRYPTION_KEY = 'c'.repeat(40); + process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = 'p'.repeat(40); warn = jest .spyOn(Logger.prototype, 'warn') .mockImplementation(() => undefined); @@ -192,6 +345,7 @@ describe('RekeyJob', () => { 'webhook.plaintextSealed': 1, 'webhook.changedMeanwhile': 0, 'webhook.failed': 0, + ...NO_VOICE, }; it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => { @@ -411,7 +565,7 @@ describe('RekeyJob', () => { it('dry-run opens them and fails the run on a value that does not open', async () => { await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow( - /^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/, + /^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1, voice\..*$/, ); expect(db.user.updateMany).not.toHaveBeenCalled(); expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled(); @@ -490,7 +644,7 @@ describe('RekeyJob', () => { it('dry-run reports them too and writes nothing', async () => { await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow( - /^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/, + /^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1, voice\..*$/, ); expect(db.user.updateMany).not.toHaveBeenCalled(); expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled(); @@ -566,4 +720,253 @@ describe('RekeyJob', () => { expect(db.user.findMany).not.toHaveBeenCalled(); }, ); + + describe('voice content', () => { + const TRANSCRIPT = 'Koenigin gesehen, acht Waben besetzt'; + const SUGGESTION = '{"suggestions":[{"field":"queenSeen"}]}'; + const AUDIO = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 9, 8, 7]); + + /** Seals with the key that VOICE_ENCRYPTION_KEY_PREVIOUS names, as before a rotation. */ + function underPrevious(seal: () => T): T { + const current = process.env.VOICE_ENCRYPTION_KEY; + process.env.VOICE_ENCRYPTION_KEY = + process.env.VOICE_ENCRYPTION_KEY_PREVIOUS; + try { + return seal(); + } finally { + process.env.VOICE_ENCRYPTION_KEY = current; + } + } + + function openFixture(): ReturnType { + return fakeDb([], [], { + captures: [ + { + // READY, everything under the previous key. + id: 'c1', + status: 'READY', + transcriptEnc: underPrevious(() => + sealVoiceText(TRANSCRIPT, voiceAad('c1', 'transcript')), + ), + suggestionEnc: underPrevious(() => + sealVoiceText(SUGGESTION, voiceAad('c1', 'suggestion')), + ), + }, + { + // QUEUED, audio under the previous key, no text yet. + id: 'c2', + status: 'QUEUED', + transcriptEnc: null, + suggestionEnc: null, + }, + { + // TRANSCRIBED, already current. + id: 'c3', + status: 'TRANSCRIBED', + transcriptEnc: sealVoiceText( + TRANSCRIPT, + voiceAad('c3', 'transcript'), + ), + suggestionEnc: null, + }, + { + // CONFIRMED rows hold no content any more; a leftover is not touched. + id: 'c4', + status: 'CONFIRMED', + transcriptEnc: underPrevious(() => + sealVoiceText(TRANSCRIPT, voiceAad('c4', 'transcript')), + ), + suggestionEnc: null, + }, + ], + audio: [ + { + captureId: 'c2', + dataEnc: underPrevious(() => + sealVoiceBytes(AUDIO, voiceAad('c2', 'audio')), + ), + }, + { + captureId: 'c4', + dataEnc: underPrevious(() => + sealVoiceBytes(AUDIO, voiceAad('c4', 'audio')), + ), + }, + ], + }); + } + + it('rekey reseals open voice content and skips confirmed rows', async () => { + const db = openFixture(); + const confirmedTranscript = db.voice.transcript('c4'); + const confirmedAudio = db.voice.audio('c4'); + + const result = await new RekeyJob(db.prisma).run([]); + + // c1: transcript + suggestion, c2: audio, c3: already current. + expect(result).toMatchObject({ + 'voice.total': 4, + 'voice.rekeyed': 3, + 'voice.alreadyCurrent': 1, + 'voice.plaintextSealed': 0, + 'voice.changedMeanwhile': 0, + 'voice.failed': 0, + }); + expect(isVoiceSealedWithCurrentKey(db.voice.transcript('c1'))).toBe(true); + expect(isVoiceSealedWithCurrentKey(db.voice.suggestion('c1'))).toBe(true); + expect(isVoiceSealedWithCurrentKey(db.voice.audio('c2'))).toBe(true); + expect(db.voice.transcript('c4')).toBe(confirmedTranscript); + expect(db.voice.audio('c4').equals(confirmedAudio)).toBe(true); + // Every filter excludes the three finished states. + expect(db.voice.voiceCapture.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] }, + }) as unknown, + }), + ); + }); + + it('leaves voice content that opens to the original after the previous key is gone', async () => { + const db = openFixture(); + await new RekeyJob(db.prisma).run([]); + delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS; + + expect( + openVoiceText(db.voice.transcript('c1'), voiceAad('c1', 'transcript')), + ).toBe(TRANSCRIPT); + expect( + openVoiceText(db.voice.suggestion('c1'), voiceAad('c1', 'suggestion')), + ).toBe(SUGGESTION); + expect( + openVoiceBytes(db.voice.audio('c2'), voiceAad('c2', 'audio')).equals( + AUDIO, + ), + ).toBe(true); + }); + + it('dry-run counts voice content and writes nothing', async () => { + const db = openFixture(); + const before = db.voice.transcript('c1'); + + const result = await new RekeyJob(db.prisma).run(['--dry-run']); + + expect(result).toMatchObject({ + dryRun: true, + 'voice.total': 4, + 'voice.rekeyed': 3, + 'voice.alreadyCurrent': 1, + }); + expect(db.voice.voiceCapture.updateMany).not.toHaveBeenCalled(); + expect(db.voice.voiceCaptureAudio.updateMany).not.toHaveBeenCalled(); + expect(db.voice.transcript('c1')).toBe(before); + }); + + it('does not overwrite voice content that changed since it was read', async () => { + const db = openFixture(); + const newer = sealVoiceText('neu', voiceAad('c1', 'transcript')); + db.voice.voiceCapture.findMany.mockImplementationOnce(() => { + const read = [...db.voice.captureRows.values()] + .filter( + (row) => row.transcriptEnc !== null && row.status !== 'CONFIRMED', + ) + .map((row) => ({ ...row })); + const row = db.voice.captureRows.get('c1'); + if (row) { + db.voice.captureRows.set('c1', { ...row, transcriptEnc: newer }); + } + return Promise.resolve(read); + }); + + const result = await new RekeyJob(db.prisma).run([]); + + expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 }); + expect(db.voice.transcript('c1')).toBe(newer); + }); + + it('fails the run on a voice value that cannot be opened and never logs content', async () => { + const db = fakeDb([], [], { + captures: [ + { + id: 'c1', + status: 'READY', + // Bound to another capture: does not authenticate here. + transcriptEnc: underPrevious(() => + sealVoiceText(TRANSCRIPT, voiceAad('other', 'transcript')), + ), + suggestionEnc: null, + }, + ], + audio: [], + }); + + const message = await new RekeyJob(db.prisma).run([]).then( + () => '', + (failure: Error) => failure.message, + ); + + expect(message).toContain('voice.failed=1'); + expect(logged()).toContainEqual( + expect.stringContaining( + 'VoiceCapture c1: voice secret not rekeyed: ciphertext failed authentication', + ), + ); + for (const text of [message, ...logged()]) { + expect(text).not.toContain('Koenigin'); + } + }); + + it('reads audio one blob at a time and never selects the data in the id listing', async () => { + const db = openFixture(); + + await new RekeyJob(db.prisma).run([]); + + expect(db.voice.voiceCaptureAudio.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: { captureId: true } }), + ); + expect(db.voice.voiceCaptureAudio.findFirst).toHaveBeenCalled(); + }); + + it('does not write audio of a capture that was confirmed since it was read', async () => { + const db = openFixture(); + const before = db.voice.audio('c2'); + db.voice.voiceCaptureAudio.findFirst.mockImplementationOnce( + ({ where }: { where: { captureId: string } }) => { + const read = { captureId: where.captureId, dataEnc: before }; + const row = db.voice.captureRows.get(where.captureId); + if (row) { + db.voice.captureRows.set(where.captureId, { + ...row, + status: 'CONFIRMED', + }); + } + return Promise.resolve(read); + }, + ); + + const result = await new RekeyJob(db.prisma).run([]); + + expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 }); + expect(db.voice.audio('c2').equals(before)).toBe(true); + expect(db.voice.voiceCaptureAudio.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + capture: { + status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] }, + }, + }) as unknown, + }), + ); + }); + + it('fails before reading any row when the voice key is unusable', async () => { + const db = openFixture(); + process.env.VOICE_ENCRYPTION_KEY = 'too-short'; + + await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow( + 'VOICE_ENCRYPTION_KEY must be at least 32 characters', + ); + expect(db.voice.voiceCapture.findMany).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/jobs/rekey.job.ts b/ApiCore-Backend/src/jobs/rekey.job.ts index 81631d2..2b39e2e 100644 --- a/ApiCore-Backend/src/jobs/rekey.job.ts +++ b/ApiCore-Backend/src/jobs/rekey.job.ts @@ -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: string; read(): Promise; + /** + * 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; + readOne?(id: string): Promise; /** * 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; 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 { 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 { - 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, + ]; + } } diff --git a/ApiCore-Backend/src/runtime/startup-checks.spec.ts b/ApiCore-Backend/src/runtime/startup-checks.spec.ts index 8891aba..ffcbf3b 100644 --- a/ApiCore-Backend/src/runtime/startup-checks.spec.ts +++ b/ApiCore-Backend/src/runtime/startup-checks.spec.ts @@ -6,6 +6,7 @@ const KEYS = { MFA_ENCRYPTION_KEY: 'm'.repeat(32), WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32), BACKUP_ENCRYPTION_KEY: 'b'.repeat(32), + VOICE_ENCRYPTION_KEY: 'v'.repeat(32), }; const PROD_ENV = { ...BASE_ENV, @@ -81,16 +82,17 @@ describe('assertStartupEnvironment', () => { }); describe('encryption keys', () => { - it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => { + it('production refuses to start without MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY', () => { expect(() => assertStartupEnvironment('worker', { ...PROD_ENV, MFA_ENCRYPTION_KEY: undefined, WEBHOOK_ENCRYPTION_KEY: undefined, BACKUP_ENCRYPTION_KEY: undefined, + VOICE_ENCRYPTION_KEY: undefined, }), ).toThrow( - 'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY', + 'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY, VOICE_ENCRYPTION_KEY', ); expect(() => assertStartupEnvironment('job', { @@ -126,7 +128,7 @@ describe('assertStartupEnvironment', () => { WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY, }), ).toThrow( - 'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different', + 'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY and VOICE_ENCRYPTION_KEY must be pairwise different', ); expect(() => assertStartupEnvironment('api', { @@ -198,9 +200,11 @@ describe('assertStartupEnvironment', () => { 'MFA_ENCRYPTION_KEY', 'WEBHOOK_ENCRYPTION_KEY', 'BACKUP_ENCRYPTION_KEY', + 'VOICE_ENCRYPTION_KEY', 'MFA_ENCRYPTION_KEY_PREVIOUS', 'WEBHOOK_ENCRYPTION_KEY_PREVIOUS', 'BACKUP_ENCRYPTION_KEY_PREVIOUS', + 'VOICE_ENCRYPTION_KEY_PREVIOUS', ])('production refuses a change-me placeholder in %s', (name) => { expect(() => assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }), @@ -360,4 +364,70 @@ describe('assertStartupEnvironment', () => { ).not.toThrow(); }); }); + + describe('voice', () => { + it('production requires VOICE_ENCRYPTION_KEY distinct from the others', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + VOICE_ENCRYPTION_KEY: undefined, + }), + ).toThrow( + 'Missing required environment variable(s): VOICE_ENCRYPTION_KEY', + ); + for (const other of [ + 'MFA_ENCRYPTION_KEY', + 'WEBHOOK_ENCRYPTION_KEY', + 'BACKUP_ENCRYPTION_KEY', + ] as const) { + expect(() => + assertStartupEnvironment('job', { + ...PROD_ENV, + VOICE_ENCRYPTION_KEY: KEYS[other], + }), + ).toThrow('must be pairwise different'); + } + expect(() => + assertStartupEnvironment('worker', { + ...PROD_ENV, + VOICE_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET, + }), + ).toThrow( + 'VOICE_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET', + ); + }); + + it('rejects a change-me VOICE_ENCRYPTION_KEY in production', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + VOICE_ENCRYPTION_KEY: 'change-me-to-a-fifth-random-value-32-chars', + }), + ).toThrow( + 'Secret(s) still set to an example placeholder: VOICE_ENCRYPTION_KEY', + ); + }); + + it('refuses an unknown VOICE_MODEL in every role', () => { + for (const role of ['api', 'worker', 'job', 'all'] as const) { + expect(() => + assertStartupEnvironment(role, { + ...PROD_ENV, + VOICE_MODEL: 'claude-unknown', + }), + ).toThrow('VOICE_MODEL must be one of'); + } + }); + + it('a bad ANTHROPIC_API_KEY never stops a pod', () => { + for (const role of ['api', 'worker', 'job', 'all'] as const) { + expect(() => + assertStartupEnvironment(role, { + ...PROD_ENV, + ANTHROPIC_API_KEY: 'change-me-anthropic', + }), + ).not.toThrow(); + } + }); + }); }); diff --git a/ApiCore-Backend/src/runtime/startup-checks.ts b/ApiCore-Backend/src/runtime/startup-checks.ts index 8f0f4f1..d7fa9d5 100644 --- a/ApiCore-Backend/src/runtime/startup-checks.ts +++ b/ApiCore-Backend/src/runtime/startup-checks.ts @@ -4,6 +4,8 @@ import { readRingSecrets, } from '../common/crypto/key-ring'; import { assertTelegramToken } from '../telegram/telegram-config'; +import { assertAnthropicKey } from '../voice/anthropic-config'; +import { voiceConfig } from '../voice/voice-config'; import type { ApicoreRole } from './role'; const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const; @@ -132,8 +134,9 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void { /** * Startup checks shared by every entry point (API, worker, CLI job). All * roles load the same modules and secrets today, so the checks hardly differ - * per role; the Telegram bot token is the first role-specific one (only the - * worker holds it). + * per role; the Telegram bot token and the Anthropic key are role-specific + * (only the worker holds them, both optional). The voice settings are + * validated in every role: a mistyped model id stops the pod, not a night job. */ export function assertStartupEnvironment( role: ApicoreRole, @@ -144,4 +147,6 @@ export function assertStartupEnvironment( assertNoPlaceholderSecrets(env); assertEncryptionKeys(env); assertTelegramToken(role, env); + voiceConfig(env); + assertAnthropicKey(role, env); } diff --git a/ApiCore-Backend/src/voice/anthropic-config.spec.ts b/ApiCore-Backend/src/voice/anthropic-config.spec.ts new file mode 100644 index 0000000..9db5663 --- /dev/null +++ b/ApiCore-Backend/src/voice/anthropic-config.spec.ts @@ -0,0 +1,91 @@ +import { Logger } from '@nestjs/common'; +import { anthropicApiKey, assertAnthropicKey } from './anthropic-config'; + +const KEY = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789'; + +function fakeLogger() { + return { warn: jest.fn(), error: jest.fn() } satisfies Pick< + Logger, + 'warn' | 'error' + >; +} + +describe('anthropicApiKey', () => { + it('returns a well-formed key, trimmed', () => { + expect(anthropicApiKey({ ANTHROPIC_API_KEY: ` ${KEY} ` })).toBe(KEY); + }); + + it.each([ + ['unset', {}], + ['empty', { ANTHROPIC_API_KEY: ' ' }], + ['a placeholder', { ANTHROPIC_API_KEY: 'change-me-anthropic-key' }], + ['the wrong format', { ANTHROPIC_API_KEY: 'sk-ant-short' }], + ['dummy', { ANTHROPIC_API_KEY: 'dummy' }], + ])('returns null for %s', (_label, env) => { + expect(anthropicApiKey(env)).toBeNull(); + }); +}); + +describe('assertAnthropicKey', () => { + it('assertAnthropicKey never throws and disables on a placeholder', () => { + for (const value of ['change-me-anthropic-key', 'dummy', '', 'sk-ant-x']) { + const logger = fakeLogger(); + expect(() => + assertAnthropicKey( + 'worker', + { NODE_ENV: 'production', ANTHROPIC_API_KEY: value }, + logger, + ), + ).not.toThrow(); + } + const logger = fakeLogger(); + assertAnthropicKey( + 'worker', + { NODE_ENV: 'production', ANTHROPIC_API_KEY: 'change-me-x' }, + logger, + ); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(anthropicApiKey({ ANTHROPIC_API_KEY: 'change-me-x' })).toBeNull(); + }); + + it('logs the variable name once, never the value', () => { + const logger = fakeLogger(); + assertAnthropicKey( + 'worker', + { ANTHROPIC_API_KEY: 'sk-ant-short-secret' }, + logger, + ); + const output = JSON.stringify([ + logger.warn.mock.calls, + logger.error.mock.calls, + ]); + expect(output).toContain('ANTHROPIC_API_KEY'); + expect(output).not.toContain('short-secret'); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('api and job roles skip the Anthropic check', () => { + for (const role of ['api', 'job'] as const) { + const logger = fakeLogger(); + assertAnthropicKey(role, { ANTHROPIC_API_KEY: 'garbage' }, logger); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + } + }); + + it('accepts a good key silently in the worker and in all-in-one mode', () => { + for (const role of ['worker', 'all'] as const) { + const logger = fakeLogger(); + assertAnthropicKey(role, { ANTHROPIC_API_KEY: KEY }, logger); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + } + }); + + it('warns that extraction stays off when the worker has no key', () => { + const logger = fakeLogger(); + assertAnthropicKey('worker', {}, logger); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/voice/anthropic-config.ts b/ApiCore-Backend/src/voice/anthropic-config.ts new file mode 100644 index 0000000..ec7008e --- /dev/null +++ b/ApiCore-Backend/src/voice/anthropic-config.ts @@ -0,0 +1,59 @@ +import { Logger } from '@nestjs/common'; +import type { ApicoreRole } from '../runtime/role'; + +/** Key format issued by Anthropic: "sk-ant-" followed by URL-safe characters. */ +export const ANTHROPIC_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]{20,}$/; + +const PLACEHOLDER_PATTERN = /^change-me/i; +const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all']; + +function trimmed(value: string | undefined): string | null { + const text = value?.trim(); + return text ? text : null; +} + +/** + * The API key for voice extraction, or null if it is unset, a placeholder or + * malformed - extraction then stays off (code EXTRACT_DISABLED). Only the + * worker holds the key (Secret apicore-anthropic, optional). + */ +export function anthropicApiKey( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const key = trimmed(env.ANTHROPIC_API_KEY); + return key !== null && ANTHROPIC_KEY_PATTERN.test(key) ? key : null; +} + +/** + * Startup check, same rules as the Telegram token: optional, a bad value + * never stops a pod. In the worker, a missing key is a warning and a + * placeholder or malformed key an error, once, naming the variable and + * never its value. API and job pods never hold the key and skip the check. + */ +export function assertAnthropicKey( + role: ApicoreRole, + env: NodeJS.ProcessEnv = process.env, + logger: Pick = new Logger('AnthropicStartup'), +): void { + if (!WORKER_ROLES.includes(role)) { + return; + } + const raw = trimmed(env.ANTHROPIC_API_KEY); + if (raw === null) { + logger.warn( + 'ANTHROPIC_API_KEY is not set: voice extraction stays off (EXTRACT_DISABLED).', + ); + return; + } + if (PLACEHOLDER_PATTERN.test(raw)) { + logger.error( + 'ANTHROPIC_API_KEY is still set to an example placeholder: voice extraction stays off - set the real key or remove it.', + ); + return; + } + if (!ANTHROPIC_KEY_PATTERN.test(raw)) { + logger.error( + 'ANTHROPIC_API_KEY is malformed (expected "sk-ant-..."): voice extraction stays off.', + ); + } +} diff --git a/ApiCore-Backend/src/voice/core/inspection-field-specs.spec.ts b/ApiCore-Backend/src/voice/core/inspection-field-specs.spec.ts new file mode 100644 index 0000000..bc45376 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/inspection-field-specs.spec.ts @@ -0,0 +1,155 @@ +import { getMetadataStorage } from 'class-validator'; +import { ColonyCondition } from '../../../generated/prisma'; +import { CreateInspectionDto } from '../../inspections/dto/create-inspection.dto'; +import { + FIELD_SPECS, + INSPECTION_VOICE_FIELDS, + VOICE_EXCLUDED_INSPECTION_FIELDS, + checkFieldValue, +} from './inspection-field-specs'; + +interface DtoRule { + name: string; + constraints: unknown[]; +} + +/** The class-validator rules of every property of the DTO. */ +function dtoRules(): Map { + const rules = new Map(); + for (const meta of getMetadataStorage().getTargetValidationMetadatas( + CreateInspectionDto, + '', + false, + false, + )) { + const list = rules.get(meta.propertyName) ?? []; + list.push({ + name: meta.name ?? meta.type, + constraints: meta.constraints ?? [], + }); + rules.set(meta.propertyName, list); + } + return rules; +} + +describe('FIELD_SPECS', () => { + it('FIELD_SPECS covers exactly the DTO fields minus the excluded list', () => { + const dtoFields = [...dtoRules().keys()]; + const covered = dtoFields + .filter((field) => !(field in VOICE_EXCLUDED_INSPECTION_FIELDS)) + .sort(); + + expect(covered).toEqual([...INSPECTION_VOICE_FIELDS].sort()); + expect(Object.keys(FIELD_SPECS).sort()).toEqual(covered); + expect(INSPECTION_VOICE_FIELDS).toHaveLength(17); + }); + + it('gives every excluded field a reason', () => { + expect(Object.keys(VOICE_EXCLUDED_INSPECTION_FIELDS).sort()).toEqual( + [ + 'applyToColony', + 'broodAmount', + 'colonyId', + 'date', + 'hiveChange', + 'temperature', + 'voiceCaptureId', + 'weather', + ].sort(), + ); + for (const reason of Object.values(VOICE_EXCLUDED_INSPECTION_FIELDS)) { + expect(reason.length).toBeGreaterThan(10); + } + }); + + it('FIELD_SPECS bounds lie within CreateInspectionDto', () => { + const rules = dtoRules(); + for (const field of INSPECTION_VOICE_FIELDS) { + const spec = FIELD_SPECS[field]; + const fieldRules = rules.get(field) ?? []; + if (spec.kind === 'int') { + const min = fieldRules.find((rule) => rule.name === 'min'); + const max = fieldRules.find((rule) => rule.name === 'max'); + if (min) { + expect(spec.min).toBeGreaterThanOrEqual(min.constraints[0] as number); + } + if (max) { + expect(spec.max).toBeLessThanOrEqual(max.constraints[0] as number); + } + expect(fieldRules.map((rule) => rule.name)).toContain('isInt'); + } + if (spec.kind === 'bool') { + expect(fieldRules.map((rule) => rule.name)).toContain('isBoolean'); + } + if (spec.kind === 'text') { + expect(fieldRules.map((rule) => rule.name)).toContain('isString'); + } + } + // The DTO has no upper bound for broodFrames; the voice path adds one. + expect(FIELD_SPECS.broodFrames).toEqual({ kind: 'int', min: 0, max: 40 }); + expect(FIELD_SPECS.colonyStrength).toEqual({ + kind: 'int', + min: 0, + max: 40, + }); + }); + + it('feedStatus accepts every ColonyCondition and nothing else', () => { + for (const value of Object.values(ColonyCondition)) { + expect(checkFieldValue('feedStatus', value)).toMatchObject({ + ok: true, + value, + }); + } + for (const value of ['ausreichend', 'good', 'OK', '', 3, null]) { + expect(checkFieldValue('feedStatus', value)).toEqual({ ok: false }); + } + }); +}); + +describe('checkFieldValue', () => { + it('accepts integers within the bounds and rejects the rest', () => { + expect(checkFieldValue('colonyStrength', 40)).toMatchObject({ + ok: true, + value: 40, + }); + expect(checkFieldValue('colonyStrength', 0)).toMatchObject({ ok: true }); + for (const value of [41, -1, 7.5, '7', true, null, Number.NaN]) { + expect(checkFieldValue('colonyStrength', value)).toEqual({ ok: false }); + } + expect(checkFieldValue('broodPattern', 1)).toMatchObject({ ok: true }); + expect(checkFieldValue('broodPattern', 5)).toMatchObject({ ok: true }); + expect(checkFieldValue('broodPattern', 0)).toEqual({ ok: false }); + expect(checkFieldValue('broodPattern', 6)).toEqual({ ok: false }); + }); + + it('accepts booleans only as booleans', () => { + expect(checkFieldValue('queenSeen', false)).toMatchObject({ + ok: true, + value: false, + }); + expect(checkFieldValue('queenSeen', 'true')).toEqual({ ok: false }); + expect(checkFieldValue('queenSeen', 1)).toEqual({ ok: false }); + }); + + it('sanitizes text and reports shortening', () => { + expect(checkFieldValue('notes', 'Ruhig und sanft')).toMatchObject({ + ok: true, + value: 'Ruhig bund/b sanft', + truncated: false, + }); + const long = checkFieldValue('healthIssues', 'x'.repeat(700)); + expect(long).toMatchObject({ ok: true, truncated: true }); + expect(long.ok && String(long.value)).toHaveLength(500); + expect(checkFieldValue('measures', ' <> ')).toEqual({ ok: false }); + expect(checkFieldValue('measures', 5)).toEqual({ ok: false }); + }); + + it('rejects weather and temperature and unknown fields', () => { + expect(checkFieldValue('weather', 'sonnig')).toEqual({ ok: false }); + expect(checkFieldValue('temperature', 21)).toEqual({ ok: false }); + expect(checkFieldValue('colonyId', 'abc')).toEqual({ ok: false }); + expect(checkFieldValue('constructor', 'x')).toEqual({ ok: false }); + expect(checkFieldValue('__proto__', 'x')).toEqual({ ok: false }); + }); +}); diff --git a/ApiCore-Backend/src/voice/core/inspection-field-specs.ts b/ApiCore-Backend/src/voice/core/inspection-field-specs.ts new file mode 100644 index 0000000..e3bfc5b --- /dev/null +++ b/ApiCore-Backend/src/voice/core/inspection-field-specs.ts @@ -0,0 +1,130 @@ +import { ColonyCondition } from '../../../generated/prisma'; +import { sanitizeFreeText } from './voice-text'; + +/** + * The inspection fields a voice note may suggest. The limits follow the + * existing form and CreateInspectionDto (ruling 2): strength up to 40, five + * feed levels. broodFrames has no upper bound in the DTO; the voice path + * adds 40 so a misheard number cannot reach the form. + */ +export const INSPECTION_VOICE_FIELDS = [ + 'colonyStrength', + 'broodFrames', + 'broodPattern', + 'openBroodPresent', + 'cappedBroodPresent', + 'eggsPresent', + 'queenSeen', + 'queenCellsPresent', + 'swarmMood', + 'gentleness', + 'combBehavior', + 'feedStatus', + 'honeySuperStatus', + 'healthIssues', + 'varroaIssues', + 'measures', + 'notes', +] as const; + +export type InspectionVoiceField = (typeof INSPECTION_VOICE_FIELDS)[number]; + +export type FieldSpec = + | { kind: 'int'; min: number; max: number } + | { kind: 'bool' } + | { kind: 'enum'; values: readonly string[] } + | { kind: 'text'; max: 500 }; + +const TEXT_MAX = 500; +const FRAME_MAX = 40; +const SCALE: FieldSpec = { kind: 'int', min: 1, max: 5 }; +const BOOL: FieldSpec = { kind: 'bool' }; +const TEXT: FieldSpec = { kind: 'text', max: TEXT_MAX }; + +export const FIELD_SPECS: Readonly> = { + colonyStrength: { kind: 'int', min: 0, max: FRAME_MAX }, + broodFrames: { kind: 'int', min: 0, max: FRAME_MAX }, + broodPattern: SCALE, + openBroodPresent: BOOL, + cappedBroodPresent: BOOL, + eggsPresent: BOOL, + queenSeen: BOOL, + queenCellsPresent: BOOL, + swarmMood: SCALE, + gentleness: SCALE, + combBehavior: SCALE, + feedStatus: { kind: 'enum', values: Object.values(ColonyCondition) }, + honeySuperStatus: TEXT, + healthIssues: TEXT, + varroaIssues: TEXT, + measures: TEXT, + notes: TEXT, +}; + +/** DTO properties a voice note never fills, each with the reason. */ +export const VOICE_EXCLUDED_INSPECTION_FIELDS: Readonly< + Record +> = { + colonyId: 'The colony comes from the voice note itself, never from speech.', + date: 'The date comes from the voice note (inspectionDate), not from speech.', + weather: 'Weather is fetched from the weather service, not spoken.', + temperature: 'Temperature is fetched from the weather service, not spoken.', + broodAmount: 'Legacy free text; broodFrames and broodPattern replace it.', + applyToColony: 'Taking values over to the colony card is a separate click.', + hiveChange: 'Hive operations are never created from speech.', + voiceCaptureId: 'Links the voice note; it is not an observation.', +}; + +export type CheckedFieldValue = + | { + ok: true; + field: InspectionVoiceField; + value: number | boolean | string; + truncated: boolean; + } + | { ok: false }; + +const REJECTED: CheckedFieldValue = { ok: false }; + +function accepted( + field: InspectionVoiceField, + value: number | boolean | string, + truncated = false, +): CheckedFieldValue { + return { ok: true, field, value, truncated }; +} + +function isVoiceField(field: string): field is InspectionVoiceField { + return Object.hasOwn(FIELD_SPECS, field); +} + +/** Checks one suggested value against its limits; text is cleaned and shortened. */ +export function checkFieldValue( + field: string, + value: unknown, +): CheckedFieldValue { + if (!isVoiceField(field)) { + return REJECTED; + } + const spec = FIELD_SPECS[field]; + switch (spec.kind) { + case 'int': + return typeof value === 'number' && + Number.isInteger(value) && + value >= spec.min && + value <= spec.max + ? accepted(field, value) + : REJECTED; + case 'bool': + return typeof value === 'boolean' ? accepted(field, value) : REJECTED; + case 'enum': + return typeof value === 'string' && spec.values.includes(value) + ? accepted(field, value) + : REJECTED; + case 'text': { + const text = + typeof value === 'string' ? sanitizeFreeText(value, spec.max) : null; + return text ? accepted(field, text.text, text.truncated) : REJECTED; + } + } +} diff --git a/ApiCore-Backend/src/voice/core/inspection-scales.ts b/ApiCore-Backend/src/voice/core/inspection-scales.ts new file mode 100644 index 0000000..73cb7a8 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/inspection-scales.ts @@ -0,0 +1,44 @@ +/** + * The four 1-5 scales of the inspection form: what the STORED ends mean + * (the form shows swarm mood mirrored, but stores 1 = ruhig) and the words + * a beekeeper says for them. The same data is in + * test/fixtures/voice/inspection-scales.json for the frontend guard; a test + * keeps both equal (the TS module cannot import the JSON: it lies outside + * src, and the build's root directory must stay src). + */ +export interface ScaleSpec { + field: 'broodPattern' | 'swarmMood' | 'gentleness' | 'combBehavior'; + /** Meaning of the stored value 1. */ + storedOne: string; + /** Meaning of the stored value 5. */ + storedFive: string; + /** Spoken word -> stored value. */ + anchors: Record; +} + +export const INSPECTION_SCALES: readonly ScaleSpec[] = [ + { + field: 'broodPattern', + storedOne: 'lückenhaft', + storedFive: 'geschlossen', + anchors: { lückenhaft: 1, mittel: 3, geschlossen: 5 }, + }, + { + field: 'swarmMood', + storedOne: 'ruhig', + storedFive: 'stark', + anchors: { ruhig: 1, mittel: 3, stark: 5 }, + }, + { + field: 'gentleness', + storedOne: 'stechlustig', + storedFive: 'sehr sanft', + anchors: { stechlustig: 1, normal: 3, 'sehr sanft': 5 }, + }, + { + field: 'combBehavior', + storedOne: 'läuft weg', + storedFive: 'sitzt ruhig', + anchors: { 'läuft weg': 1, mittel: 3, 'sitzt ruhig': 5 }, + }, +]; diff --git a/ApiCore-Backend/src/voice/core/transcript.spec.ts b/ApiCore-Backend/src/voice/core/transcript.spec.ts new file mode 100644 index 0000000..96e6132 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/transcript.spec.ts @@ -0,0 +1,122 @@ +import { Logger } from '@nestjs/common'; +import { sealVoiceText, voiceAad } from './voice-crypto'; +import { + MAX_TRANSCRIPT_CHARS, + type TranscriptPayload, + openTranscript, + sealTranscript, +} from './transcript'; + +describe('transcript', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'j'.repeat(40); + process.env.VOICE_ENCRYPTION_KEY = 'a'.repeat(40); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + it('transcript payload round-trips with segments', () => { + const payload: TranscriptPayload = { + v: 1, + text: 'Königin gesehen. Acht Waben.', + durationSec: 12, + segments: [ + { start: 0, end: 3.2, text: 'Königin gesehen.' }, + { start: 3.2, end: 6, text: 'Acht Waben.' }, + ], + }; + + const sealed = sealTranscript('c1', payload); + + expect(sealed).toMatch(/^k[0-9a-f]{8}:/); + expect(sealed).not.toContain('Königin'); + expect(openTranscript('c1', sealed)).toEqual(payload); + }); + + it('round-trips a text-only payload', () => { + const payload: TranscriptPayload = { v: 1, text: 'Nur Text' }; + expect(openTranscript('c1', sealTranscript('c1', payload))).toEqual( + payload, + ); + }); + + it('is bound to its capture', () => { + const sealed = sealTranscript('c1', { v: 1, text: 'x' }); + expect(() => openTranscript('c2', sealed)).toThrow( + 'ciphertext failed authentication', + ); + }); + + it('rejects a payload with another version', () => { + const sealed = sealVoiceText( + JSON.stringify({ v: 2, text: 'geheim' }), + voiceAad('c1', 'transcript'), + ); + expect(() => openTranscript('c1', sealed)).toThrow( + 'unsupported transcript payload', + ); + }); + + it.each([ + ['not json at all geheim', 'unsupported transcript payload'], + [JSON.stringify({ v: 1, text: 5 }), 'unsupported transcript payload'], + [ + JSON.stringify({ v: 1, text: 'a', segments: [{ start: 'x' }] }), + 'unsupported transcript payload', + ], + [JSON.stringify(null), 'unsupported transcript payload'], + ])('rejects a malformed payload without echoing it', (body, message) => { + const sealed = sealVoiceText(body, voiceAad('c1', 'transcript')); + let thrown = ''; + try { + openTranscript('c1', sealed); + } catch (error) { + thrown = (error as Error).message; + } + expect(thrown).toBe(message); + expect(thrown).not.toContain('geheim'); + }); + + it('rejects non-finite numbers and oversized text', () => { + const open = (body: string): unknown => + openTranscript('c1', sealVoiceText(body, voiceAad('c1', 'transcript'))); + const bad = 'unsupported transcript payload'; + expect(() => open('{"v":1,"text":"a","durationSec":1e999}')).toThrow(bad); + expect(() => + open( + '{"v":1,"text":"a","segments":[{"start":1e999,"end":2,"text":"b"}]}', + ), + ).toThrow(bad); + expect(() => + open( + JSON.stringify({ v: 1, text: 'x'.repeat(MAX_TRANSCRIPT_CHARS + 1) }), + ), + ).toThrow(bad); + expect( + open(JSON.stringify({ v: 1, text: 'x'.repeat(MAX_TRANSCRIPT_CHARS) })), + ).toBeTruthy(); + }); + + it('returns only the known keys', () => { + const sealed = sealVoiceText( + JSON.stringify({ + v: 1, + text: 'a', + extra: 'x', + segments: [{ start: 0, end: 1, text: 'b', more: 1 }], + }), + voiceAad('c1', 'transcript'), + ); + expect(openTranscript('c1', sealed)).toEqual({ + v: 1, + text: 'a', + segments: [{ start: 0, end: 1, text: 'b' }], + }); + }); +}); diff --git a/ApiCore-Backend/src/voice/core/transcript.ts b/ApiCore-Backend/src/voice/core/transcript.ts new file mode 100644 index 0000000..635e3dd --- /dev/null +++ b/ApiCore-Backend/src/voice/core/transcript.ts @@ -0,0 +1,100 @@ +import { openVoiceText, sealVoiceText, voiceAad } from './voice-crypto'; + +export interface TranscriptSegment { + start: number; + end: number; + text: string; +} + +/** What `transcriptEnc` holds once opened. Segments let part 4 join chunks. */ +export interface TranscriptPayload { + v: 1; + text: string; + durationSec?: number; + segments?: TranscriptSegment[]; +} + +/** + * Upper bound for the text of one transcript. Whisper gets at most 240 s of + * speech (a few thousand characters); this leaves room and stops a forged + * or damaged payload from growing without limit. + */ +export const MAX_TRANSCRIPT_CHARS = 50000; +const MAX_SEGMENTS = 5000; + +const UNSUPPORTED = 'unsupported transcript payload'; + +function isText(value: unknown): value is string { + return typeof value === 'string' && value.length <= MAX_TRANSCRIPT_CHARS; +} + +/** Only the known keys survive. */ +function toSegment(value: unknown): TranscriptSegment | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const { start, end, text } = value as Record; + return Number.isFinite(start) && Number.isFinite(end) && isText(text) + ? { start: start as number, end: end as number, text } + : null; +} + +function toPayload(value: unknown): TranscriptPayload | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const { v, text, durationSec, segments } = value as Record; + if (v !== 1 || !isText(text)) { + return null; + } + const payload: TranscriptPayload = { v: 1, text }; + if (durationSec !== undefined) { + if (!Number.isFinite(durationSec)) { + return null; + } + payload.durationSec = durationSec as number; + } + if (segments !== undefined) { + if (!Array.isArray(segments) || segments.length > MAX_SEGMENTS) { + return null; + } + const checked = segments.map(toSegment); + if (checked.some((each) => each === null)) { + return null; + } + payload.segments = checked as TranscriptSegment[]; + } + return payload; +} + +export function sealTranscript( + captureId: string, + payload: TranscriptPayload, +): string { + return sealVoiceText( + JSON.stringify(payload), + voiceAad(captureId, 'transcript'), + ); +} + +/** + * Opens and checks a transcript. A broken payload throws a fixed message: + * JSON.parse errors quote the input, so they are never passed on. + */ +export function openTranscript( + captureId: string, + stored: string, +): TranscriptPayload { + const json = openVoiceText(stored, voiceAad(captureId, 'transcript')); + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(UNSUPPORTED); + } + const payload = toPayload(parsed); + if (payload === null) { + throw new Error(UNSUPPORTED); + } + return payload; +} diff --git a/ApiCore-Backend/src/voice/core/voice-crypto.spec.ts b/ApiCore-Backend/src/voice/core/voice-crypto.spec.ts new file mode 100644 index 0000000..32f18e9 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-crypto.spec.ts @@ -0,0 +1,155 @@ +import { Logger } from '@nestjs/common'; +import { createHash, hkdfSync } from 'node:crypto'; +import { loadKeyRing } from '../../common/crypto/key-ring'; +import { + isVoiceSealedWithCurrentKey, + openVoiceBytes, + openVoiceText, + sealVoiceBytes, + sealVoiceText, + voiceAad, +} from './voice-crypto'; + +const JWT_SECRET = 'j'.repeat(40); +const KEY_A = 'a'.repeat(40); +const KEY_B = 'b'.repeat(40); +const AAD = 'capture:c1:transcript'; + +describe('voice-crypto', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = JWT_SECRET; + process.env.VOICE_ENCRYPTION_KEY = KEY_A; + delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS; + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + it('audio and transcript round-trip through the key ring', () => { + const text = 'Königin gesehen, 8 Waben besetzt.'; + const audio = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 1, 2, 3, 255, 0]); + + const sealedText = sealVoiceText(text, AAD); + const sealedAudio = sealVoiceBytes(audio, 'capture:c1:audio'); + + expect(sealedText).toMatch(/^k[0-9a-f]{8}:/); + expect(sealedText).not.toContain('Königin'); + expect(openVoiceText(sealedText, AAD)).toBe(text); + expect(openVoiceBytes(sealedAudio, 'capture:c1:audio').equals(audio)).toBe( + true, + ); + expect(sealedAudio.length).toBe(1 + 8 + 12 + 16 + audio.length); + expect(sealedAudio[0]).toBe(1); + }); + + it('seals the same text differently every time', () => { + expect(sealVoiceText('x', AAD)).not.toBe(sealVoiceText('x', AAD)); + }); + + it('fail with the wrong key', () => { + const sealedText = sealVoiceText('geheim', AAD); + const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD); + process.env.VOICE_ENCRYPTION_KEY = KEY_B; + + expect(() => openVoiceText(sealedText, AAD)).toThrow('unknown key id'); + expect(() => openVoiceBytes(sealedAudio, AAD)).toThrow('unknown key id'); + }); + + it('opens values sealed with the previous key', () => { + const sealedText = sealVoiceText('geheim', AAD); + const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD); + process.env.VOICE_ENCRYPTION_KEY = KEY_B; + process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = KEY_A; + + expect(openVoiceText(sealedText, AAD)).toBe('geheim'); + expect(openVoiceBytes(sealedAudio, AAD).toString()).toBe('geheim'); + expect(isVoiceSealedWithCurrentKey(sealedText)).toBe(false); + expect(isVoiceSealedWithCurrentKey(sealedAudio)).toBe(false); + expect(isVoiceSealedWithCurrentKey(sealVoiceText('neu', AAD))).toBe(true); + expect( + isVoiceSealedWithCurrentKey(sealVoiceBytes(Buffer.from('neu'), AAD)), + ).toBe(true); + }); + + it('fail when the aad names another capture', () => { + const sealedText = sealVoiceText('geheim', voiceAad('c1', 'transcript')); + const sealedAudio = sealVoiceBytes( + Buffer.from('geheim'), + voiceAad('c1', 'audio'), + ); + + expect(() => + openVoiceText(sealedText, voiceAad('c2', 'transcript')), + ).toThrow('ciphertext failed authentication'); + expect(() => + openVoiceText(sealedText, voiceAad('c1', 'suggestion')), + ).toThrow('ciphertext failed authentication'); + expect(() => openVoiceBytes(sealedAudio, voiceAad('c2', 'audio'))).toThrow( + 'ciphertext failed authentication', + ); + }); + + it('fail on changed bytes, a short value and an unknown format', () => { + const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD); + const changed = Buffer.from(sealedAudio); + changed[changed.length - 1] ^= 0xff; + + expect(() => openVoiceBytes(changed, AAD)).toThrow( + 'ciphertext failed authentication', + ); + expect(() => openVoiceBytes(sealedAudio.subarray(0, 20), AAD)).toThrow( + 'malformed ciphertext', + ); + const otherVersion = Buffer.from(sealedAudio); + otherVersion[0] = 2; + expect(() => openVoiceBytes(otherVersion, AAD)).toThrow( + 'unsupported ciphertext version', + ); + expect(() => openVoiceText('plain text', AAD)).toThrow('missing key id'); + expect(() => openVoiceText('k00000000:a.b', AAD)).toThrow('unknown key id'); + }); + + it('use the voice purpose key, not the JWT secret', () => { + const key = Buffer.from( + hkdfSync('sha256', KEY_A, 'apicore-key-ring', 'apicore-voice-v1', 32), + ); + const keyId = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`; + + expect(loadKeyRing('voice').current.id).toBe(keyId); + expect(sealVoiceText('x', AAD).startsWith(`${keyId}:`)).toBe(true); + expect( + sealVoiceBytes(Buffer.from('x'), AAD).subarray(1, 9).toString(), + ).toBe(keyId.slice(1)); + // Rotating the JWT secret changes nothing for stored voice content. + const sealed = sealVoiceText('geheim', AAD); + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + expect(openVoiceText(sealed, AAD)).toBe('geheim'); + }); + + it('names the aad per capture and kind', () => { + expect(voiceAad('abc', 'transcript')).toBe('capture:abc:transcript'); + expect(voiceAad('abc', 'suggestion')).toBe('capture:abc:suggestion'); + expect(voiceAad('abc', 'audio')).toBe('capture:abc:audio'); + }); + + it('rejects a key id that is not eight hex characters', () => { + const sealed = sealVoiceBytes(Buffer.from('geheim'), AAD); + const bad = [ + Buffer.from('ZZZZZZZZ'), + Buffer.from([0xe1, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68]), + ]; + for (const id of bad) { + const changed = Buffer.from(sealed); + id.copy(changed, 1); + expect(() => openVoiceBytes(changed, AAD)).toThrow( + 'malformed ciphertext', + ); + expect(isVoiceSealedWithCurrentKey(changed)).toBe(false); + } + }); +}); diff --git a/ApiCore-Backend/src/voice/core/voice-crypto.ts b/ApiCore-Backend/src/voice/core/voice-crypto.ts new file mode 100644 index 0000000..2a6b2a3 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-crypto.ts @@ -0,0 +1,128 @@ +import { + KEY_ID_PATTERN, + findRingKey, + gcmOpen, + gcmSeal, + loadKeyRing, +} from '../../common/crypto/key-ring'; + +/** + * Voice content (audio, transcript, suggestions) is sealed with the key ring + * of the purpose `voice` (VOICE_ENCRYPTION_KEY). Every value is bound to its + * row through the GCM associated data, so a ciphertext moved to another + * capture, or to another kind of content, no longer opens. + * + * Text: `k<8 hex>:..` (base64 parts), like the other purposes. + * Bytes: `[1 byte version][8 byte key id (ASCII hex)][12 iv][16 tag][data]`. + * Errors carry no input. + */ +export type VoiceContentKind = 'transcript' | 'suggestion' | 'audio'; + +const BYTES_VERSION = 1; +const KEY_ID_BYTES = 8; +const IV_BYTES = 12; +const TAG_BYTES = 16; +const KEY_ID_HEX = /^[0-9a-f]{8}$/; +const BYTES_HEADER = 1 + KEY_ID_BYTES + IV_BYTES + TAG_BYTES; + +/** The associated data of one kind of content of one capture. */ +export function voiceAad(captureId: string, kind: VoiceContentKind): string { + return `capture:${captureId}:${kind}`; +} + +function aadBuffer(aad: string): Buffer { + return Buffer.from(aad, 'utf8'); +} + +export function sealVoiceText(plaintext: string, aad: string): string { + const { current } = loadKeyRing('voice'); + const sealed = gcmSeal( + current.key, + Buffer.from(plaintext, 'utf8'), + aadBuffer(aad), + ); + const body = [sealed.iv, sealed.tag, sealed.ciphertext] + .map((part) => part.toString('base64')) + .join('.'); + return `${current.id}:${body}`; +} + +export function openVoiceText(stored: string, aad: string): string { + if (!KEY_ID_PATTERN.test(stored)) { + throw new Error('missing key id'); + } + const separator = stored.indexOf(':'); + const ringKey = findRingKey(loadKeyRing('voice'), stored.slice(0, separator)); + if (!ringKey) { + throw new Error('unknown key id'); + } + const parts = stored.slice(separator + 1).split('.'); + if (parts.length !== 3) { + throw new Error('malformed ciphertext'); + } + const [iv, tag, ciphertext] = parts.map((part) => + Buffer.from(part, 'base64'), + ); + return gcmOpen(ringKey.key, { iv, tag, ciphertext }, aadBuffer(aad)).toString( + 'utf8', + ); +} + +export function sealVoiceBytes(plaintext: Buffer, aad: string): Buffer { + const { current } = loadKeyRing('voice'); + const sealed = gcmSeal(current.key, plaintext, aadBuffer(aad)); + return Buffer.concat([ + Buffer.from([BYTES_VERSION]), + Buffer.from(current.id.slice(1), 'ascii'), + sealed.iv, + sealed.tag, + sealed.ciphertext, + ]); +} + +/** The 8 hex characters after the version byte, or null for anything else. */ +function storedKeyId(stored: Buffer): string | null { + const id = stored.subarray(1, 1 + KEY_ID_BYTES).toString('latin1'); + return KEY_ID_HEX.test(id) ? id : null; +} + +export function openVoiceBytes(stored: Buffer, aad: string): Buffer { + if (stored.length < BYTES_HEADER) { + throw new Error('malformed ciphertext'); + } + if (stored[0] !== BYTES_VERSION) { + throw new Error('unsupported ciphertext version'); + } + const keyId = storedKeyId(stored); + if (keyId === null) { + throw new Error('malformed ciphertext'); + } + const ringKey = findRingKey(loadKeyRing('voice'), `k${keyId}`); + if (!ringKey) { + throw new Error('unknown key id'); + } + const ivStart = 1 + KEY_ID_BYTES; + const tagStart = ivStart + IV_BYTES; + return gcmOpen( + ringKey.key, + { + iv: stored.subarray(ivStart, tagStart), + tag: stored.subarray(tagStart, BYTES_HEADER), + ciphertext: stored.subarray(BYTES_HEADER), + }, + aadBuffer(aad), + ); +} + +/** False for anything the rekey job should reseal (previous key, other formats). */ +export function isVoiceSealedWithCurrentKey(stored: string | Buffer): boolean { + const { current } = loadKeyRing('voice'); + if (typeof stored === 'string') { + return stored.startsWith(`${current.id}:`); + } + return ( + stored.length >= BYTES_HEADER && + stored[0] === BYTES_VERSION && + storedKeyId(stored) === current.id.slice(1) + ); +} diff --git a/ApiCore-Backend/src/voice/core/voice-errors.ts b/ApiCore-Backend/src/voice/core/voice-errors.ts new file mode 100644 index 0000000..40c4f0d --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-errors.ts @@ -0,0 +1,4 @@ +// One source of truth: the list lives in ../voice-error-codes.ts (created with +// the claims); the core exports it under the name the plan gives it. +export { VOICE_ERROR_CODES } from '../voice-error-codes'; +export type { VoiceErrorCode } from '../voice-error-codes'; diff --git a/ApiCore-Backend/src/voice/core/voice-text.spec.ts b/ApiCore-Backend/src/voice/core/voice-text.spec.ts new file mode 100644 index 0000000..c3b31f7 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-text.spec.ts @@ -0,0 +1,116 @@ +import { + MAX_QUOTE_LENGTH, + normalizeForQuote, + normalizeInputText, + quoteInText, + sanitizeFreeText, +} from './voice-text'; + +describe('normalizeInputText', () => { + it('applies NFKC, keeps newlines, drops other control characters and trims', () => { + expect(normalizeInputText(' ① Wabe\u0000\u0007\r\nZweite Zeile\t ')).toBe( + '1 Wabe\nZweite Zeile', + ); + }); +}); + +describe('normalizeForQuote', () => { + it('lowercases, turns non-letters into spaces and collapses them', () => { + expect(normalizeForQuote(' Königin, GESEHEN!! 8 Waben… ')).toBe( + 'königin gesehen 8 waben', + ); + }); + + it('folds compatibility characters', () => { + expect(normalizeForQuote('ABC 12')).toBe('abc 12'); + }); +}); + +describe('quoteInText', () => { + const transcript = 'Also, die Königin habe ich gesehen. Acht Waben besetzt!'; + + it('matches the quote across punctuation and case', () => { + expect(quoteInText('KÖNIGIN habe ich gesehen', transcript)).toBe(true); + expect(quoteInText('acht waben besetzt', transcript)).toBe(true); + }); + + it('does not match a quote with a changed number', () => { + expect(quoteInText('neun Waben besetzt', transcript)).toBe(false); + // "12" must not match inside "112". + expect(quoteInText('12 Waben', '112 Waben besetzt')).toBe(false); + expect(quoteInText('12 Waben', 'Es sind 12 Waben besetzt')).toBe(true); + }); + + it('rejects empty, punctuation-only and overlong quotes', () => { + expect(quoteInText('', transcript)).toBe(false); + expect(quoteInText('...', transcript)).toBe(false); + const long = 'a '.repeat(MAX_QUOTE_LENGTH); + expect(quoteInText(long, long)).toBe(false); + }); +}); + +describe('sanitizeFreeText', () => { + it('sanitizeFreeText strips control characters and angle brackets and shortens to 500', () => { + expect(sanitizeFreeText('a\u0000b', 500)).toEqual({ + text: 'abscriptc/script', + truncated: false, + }); + const result = sanitizeFreeText('x'.repeat(600), 500); + expect(result?.text).toHaveLength(500); + expect(result?.truncated).toBe(true); + }); + + it('turns line breaks into single spaces', () => { + expect(sanitizeFreeText('Zeile eins\n\nZeile\tzwei', 500)?.text).toBe( + 'Zeile eins Zeile zwei', + ); + }); + + it('returns null when nothing is left', () => { + expect(sanitizeFreeText(' \u0000<> \n', 500)).toBeNull(); + expect(sanitizeFreeText('', 500)).toBeNull(); + }); + + it('does not cut a character in half', () => { + const result = sanitizeFreeText('\u{1F41D}'.repeat(10), 4); + expect(result?.text).toBe('\u{1F41D}'.repeat(4)); + expect(result?.truncated).toBe(true); + }); +}); + +describe('decimal numbers in quotes', () => { + it('keeps a decimal number as one token', () => { + expect(quoteInText('5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(false); + expect(quoteInText('3,5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(true); + expect(quoteInText('3.5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(true); + expect(quoteInText('3,5 Waben', 'ungefaehr 3.5 Waben besetzt')).toBe(true); + expect(normalizeForQuote('Es sind 8. Waben, 5')).toBe('es sind 8 waben 5'); + }); +}); + +describe('hidden characters and limits', () => { + it('maps tabs and lone carriage returns to spaces in normalizeInputText', () => { + expect(normalizeInputText('a\tb\rc\r\nd')).toBe('a b c\nd'); + }); + + it('strips format characters and line separators', () => { + const hidden = 'a\u200Bb\u202Ec\u2028d\u2029e\uFEFFf'; + expect(normalizeInputText(hidden)).toBe('abc d ef'); + expect(sanitizeFreeText(hidden, 500)?.text).toBe('abc d ef'); + }); + + it('counts the quote length in code points', () => { + const word = 'a'.repeat(MAX_QUOTE_LENGTH); + expect(quoteInText(word, word)).toBe(true); + expect(quoteInText(word + 'a', word + 'a')).toBe(false); + const emoji = '\u{1F41D}'.repeat(MAX_QUOTE_LENGTH); + // 120 code points are 240 UTF-16 units: still within the limit. + expect(quoteInText(emoji, emoji)).toBe(false); // emoji are not letters or digits + const letters = '\u{1D400}'.repeat(MAX_QUOTE_LENGTH); + expect(quoteInText(letters, letters)).toBe(true); + }); + + it('returns null when a cut leaves nothing', () => { + expect(sanitizeFreeText('abc', 0)).toBeNull(); + }); +}); diff --git a/ApiCore-Backend/src/voice/core/voice-text.ts b/ApiCore-Backend/src/voice/core/voice-text.ts new file mode 100644 index 0000000..4e5c2c2 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-text.ts @@ -0,0 +1,84 @@ +/** A quote longer than this is not a quote of one remark (spec 6.5). */ +export const MAX_QUOTE_LENGTH = 120; + +const CONTROL_CHARACTERS = /\p{Cc}/gu; +// Format characters (bidi marks, zero-width, BOM) change how text looks, not what it says. +const FORMAT_CHARACTERS = /\p{Cf}/gu; +const LINE_BREAKS = /[\r\n\t\u2028\u2029]+/g; +// A decimal separator between digits is kept as part of the number. +const DECIMAL_SEPARATOR = /(\d)[.,](?=\d)/g; +const DECIMAL_MARK = '\uE000'; +const ANGLE_BRACKETS = /[<>]/g; +const NOT_LETTER_OR_DIGIT_OR_MARK = /[^\p{L}\p{N}\uE000]+/gu; + +/** + * NFKC; tabs, lone carriage returns and the Unicode line separators become + * spaces; other control and format characters are removed; trimmed. Line + * feeds stay. + */ +export function normalizeInputText(text: string): string { + return text + .normalize('NFKC') + .replace(/\r(?!\n)|[\t\u2028\u2029]/g, ' ') + .replace(/[^\P{Cc}\n]/gu, '') + .replace(FORMAT_CHARACTERS, '') + .trim(); +} + +/** + * The form quotes are compared in: NFKC, lower case, only letters and + * digits, single spaces. "3,5" and "3.5" stay one token ("3.5"), so a quote + * "5 Waben" is not found in "3,5 Waben". + */ +export function normalizeForQuote(text: string): string { + return text + .normalize('NFKC') + .toLowerCase() + .replace(DECIMAL_SEPARATOR, `$1${DECIMAL_MARK}`) + .replace(NOT_LETTER_OR_DIGIT_OR_MARK, ' ') + .replaceAll(DECIMAL_MARK, '.') + .trim(); +} + +/** + * Whether the quote stands in the text, whole words only ("12" is not in + * "112"). This is the barrier against values the model made up. + */ +export function quoteInText(quote: string, text: string): boolean { + if (Array.from(quote).length > MAX_QUOTE_LENGTH) { + return false; + } + const wanted = normalizeForQuote(quote); + if (wanted === '') { + return false; + } + return ` ${normalizeForQuote(text)} `.includes(` ${wanted} `); +} + +/** + * Free text from the model: line breaks become spaces, other control + * characters and angle brackets disappear, at most `max` characters + * (code points). Null if nothing is left. + */ +export function sanitizeFreeText( + text: string, + max: number, +): { text: string; truncated: boolean } | null { + const cleaned = text + .normalize('NFKC') + .replace(LINE_BREAKS, ' ') + .replace(CONTROL_CHARACTERS, '') + .replace(FORMAT_CHARACTERS, '') + .replace(ANGLE_BRACKETS, '') + .replace(/ {2,}/g, ' ') + .trim(); + if (cleaned === '') { + return null; + } + const characters = Array.from(cleaned); + if (characters.length <= max) { + return { text: cleaned, truncated: false }; + } + const cut = characters.slice(0, max).join('').trimEnd(); + return cut === '' ? null : { text: cut, truncated: true }; +} diff --git a/ApiCore-Backend/src/voice/core/voice-vocabulary.spec.ts b/ApiCore-Backend/src/voice/core/voice-vocabulary.spec.ts new file mode 100644 index 0000000..689dd68 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-vocabulary.spec.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { INSPECTION_SCALES } from './inspection-scales'; +import { VOICE_ERROR_CODES } from './voice-errors'; +import { VOICE_VOCABULARY, whisperInitialPrompt } from './voice-vocabulary'; + +const FIXTURES = join(__dirname, '..', '..', '..', 'test', 'fixtures', 'voice'); + +function fixture(name: string): unknown { + return JSON.parse(readFileSync(join(FIXTURES, name), 'utf8')) as unknown; +} + +describe('voice vocabulary', () => { + it('VOICE_VOCABULARY equals the shared fixture', () => { + expect([...VOICE_VOCABULARY]).toEqual(fixture('vocabulary.json')); + }); + + it('has no duplicates or blanks', () => { + expect(new Set(VOICE_VOCABULARY).size).toBe(VOICE_VOCABULARY.length); + for (const word of VOICE_VOCABULARY) { + expect(word.trim()).toBe(word); + expect(word.length).toBeGreaterThan(0); + } + }); + + it('whisperInitialPrompt stays below 800 characters', () => { + const prompt = whisperInitialPrompt(); + expect(prompt.length).toBeLessThan(800); + for (const word of VOICE_VOCABULARY) { + expect(prompt).toContain(word); + } + }); +}); + +describe('inspection scales', () => { + it('INSPECTION_SCALES equals the shared fixture', () => { + expect(JSON.parse(JSON.stringify(INSPECTION_SCALES))).toEqual( + fixture('inspection-scales.json'), + ); + }); + + it('describes the four 1-5 scales of the form, with the stored ends', () => { + expect(INSPECTION_SCALES.map((scale) => scale.field)).toEqual([ + 'broodPattern', + 'swarmMood', + 'gentleness', + 'combBehavior', + ]); + const swarm = INSPECTION_SCALES.find( + (scale) => scale.field === 'swarmMood', + ); + // Stored 1 is the calm end for swarm mood, unlike the other scales. + expect(swarm).toMatchObject({ storedOne: 'ruhig', storedFive: 'stark' }); + for (const scale of INSPECTION_SCALES) { + expect(scale.anchors[scale.storedOne]).toBe(1); + expect(scale.anchors[scale.storedFive]).toBe(5); + } + }); +}); + +describe('voice error codes', () => { + it('lists the fourteen fixed codes of the plan', () => { + expect([...VOICE_ERROR_CODES]).toEqual([ + 'UNSUPPORTED_AUDIO', + 'TOO_LARGE', + 'TOO_MANY_OPEN', + 'NO_SPEECH', + 'TOO_LONG', + 'TRANSCRIBE_FAILED', + 'AUDIO_EXPIRED', + 'EXTRACT_DISABLED', + 'EXTRACT_RETRY', + 'EXTRACT_FAILED', + 'DAILY_LIMIT', + 'EXTRACT_INVALID', + 'REFUSED', + 'VOICE_CAPTURE_GONE', + ]); + }); +}); diff --git a/ApiCore-Backend/src/voice/core/voice-vocabulary.ts b/ApiCore-Backend/src/voice/core/voice-vocabulary.ts new file mode 100644 index 0000000..67eb569 --- /dev/null +++ b/ApiCore-Backend/src/voice/core/voice-vocabulary.ts @@ -0,0 +1,47 @@ +/** + * Beekeeping words that speech recognition tends to mishear. One source for + * Whisper (initial prompt), the Claude prompt and the browser phrases. The + * frontend reads the same list from test/fixtures/voice/vocabulary.json; a + * test keeps both equal. + */ +export const VOICE_VOCABULARY: readonly string[] = [ + 'Königin', + 'Stifte', + 'Weiselzellen', + 'Brutwaben', + 'Futterkranz', + 'Honigraum', + 'Varroa', + 'Schwarmstimmung', + 'sanftmütig', + 'Wabensitz', + 'Drohnenbrut', + 'Brutnest', + 'Brutfläche', + 'Spielnäpfchen', + 'Schwarmzellen', + 'Absperrgitter', + 'Futterteig', + 'Ableger', + 'Jungvolk', + 'Rähmchen', + 'Mittelwand', + 'Flugloch', + 'Beute', + 'Zarge', + 'Ameisensäure', + 'Oxalsäure', + 'Windel', + 'Faulbrut', + 'Kalkbrut', + 'Nosema', + 'Wachsmotten', + 'Pollen', +]; + +/** Whisper reads about 224 tokens of prompt; 800 characters stay safely below. */ +const PROMPT_PREFIX = 'Durchsicht am Bienenvolk: '; + +export function whisperInitialPrompt(): string { + return `${PROMPT_PREFIX}${VOICE_VOCABULARY.join(', ')}.`; +} diff --git a/ApiCore-Backend/src/voice/voice-config.spec.ts b/ApiCore-Backend/src/voice/voice-config.spec.ts new file mode 100644 index 0000000..c23c734 --- /dev/null +++ b/ApiCore-Backend/src/voice/voice-config.spec.ts @@ -0,0 +1,76 @@ +import { DEFAULT_VOICE_MODEL, VOICE_MODELS, voiceConfig } from './voice-config'; + +describe('voiceConfig', () => { + it('uses the defaults for an empty environment', () => { + expect(voiceConfig({})).toEqual({ + model: 'claude-sonnet-5-5', + dailyExtractions: 60, + recordingEnabled: false, + whisperModel: 'small', + }); + expect(DEFAULT_VOICE_MODEL).toBe('claude-sonnet-5-5'); + expect(VOICE_MODELS).toEqual([ + 'claude-sonnet-5-5', + 'claude-haiku-5-5', + 'claude-opus-5-5', + ]); + }); + + it('reads every setting, trimmed', () => { + expect( + voiceConfig({ + VOICE_MODEL: ' claude-haiku-5-5 ', + VOICE_DAILY_EXTRACTIONS: '120', + VOICE_RECORDING_ENABLED: 'true', + WHISPER_MODEL: 'medium', + }), + ).toEqual({ + model: 'claude-haiku-5-5', + dailyExtractions: 120, + recordingEnabled: true, + whisperModel: 'medium', + }); + }); + + it('treats blank values as unset', () => { + expect( + voiceConfig({ VOICE_MODEL: ' ', VOICE_DAILY_EXTRACTIONS: '' }), + ).toMatchObject({ model: DEFAULT_VOICE_MODEL, dailyExtractions: 60 }); + }); + + it('voiceConfig rejects an unknown model id', () => { + expect(() => voiceConfig({ VOICE_MODEL: 'gpt-5' })).toThrow( + /VOICE_MODEL must be one of claude-sonnet-5-5, claude-haiku-5-5, claude-opus-5-5/, + ); + }); + + it.each(['0', '10001', '-1', '1.5', 'many'])( + 'rejects %s as VOICE_DAILY_EXTRACTIONS', + (value) => { + expect(() => voiceConfig({ VOICE_DAILY_EXTRACTIONS: value })).toThrow( + 'VOICE_DAILY_EXTRACTIONS must be a whole number from 1 to 10000', + ); + }, + ); + + it('rejects unknown switch and Whisper model values', () => { + expect(() => voiceConfig({ VOICE_RECORDING_ENABLED: 'yes' })).toThrow( + 'VOICE_RECORDING_ENABLED must be "true" or "false"', + ); + expect(() => voiceConfig({ WHISPER_MODEL: 'large' })).toThrow( + 'WHISPER_MODEL must be one of small, medium', + ); + }); + + it('reads VOICE_RECORDING_ENABLED case-insensitively', () => { + expect( + voiceConfig({ VOICE_RECORDING_ENABLED: 'TRUE' }).recordingEnabled, + ).toBe(true); + expect( + voiceConfig({ VOICE_RECORDING_ENABLED: ' False ' }).recordingEnabled, + ).toBe(false); + expect(() => voiceConfig({ VOICE_RECORDING_ENABLED: '1' })).toThrow( + 'must be "true" or "false"', + ); + }); +}); diff --git a/ApiCore-Backend/src/voice/voice-config.ts b/ApiCore-Backend/src/voice/voice-config.ts new file mode 100644 index 0000000..f6fcbac --- /dev/null +++ b/ApiCore-Backend/src/voice/voice-config.ts @@ -0,0 +1,99 @@ +/** + * Voice settings from the environment. Model ids live here and nowhere else + * (spec 7.1): a change of model is a change of this list and of VOICE_MODEL. + */ +export const VOICE_MODELS = [ + 'claude-sonnet-5-5', + 'claude-haiku-5-5', + 'claude-opus-5-5', +] as const; +export type VoiceModel = (typeof VOICE_MODELS)[number]; + +export const DEFAULT_VOICE_MODEL: VoiceModel = 'claude-sonnet-5-5'; + +export const WHISPER_MODELS = ['small', 'medium'] as const; +export type WhisperModel = (typeof WHISPER_MODELS)[number]; + +const DEFAULT_DAILY_EXTRACTIONS = 60; +const MIN_DAILY_EXTRACTIONS = 1; +const MAX_DAILY_EXTRACTIONS = 10000; + +export interface VoiceConfig { + model: VoiceModel; + /** Operation-wide limit of Claude calls per UTC day. */ + dailyExtractions: number; + /** Switch that shows or hides audio recording in the app (ConfigMap). */ + recordingEnabled: boolean; + whisperModel: WhisperModel; +} + +function trimmed(value: string | undefined): string | null { + const text = value?.trim(); + return text ? text : null; +} + +function oneOf( + name: string, + raw: string | undefined, + allowed: readonly T[], + fallback: T, +): T { + const value = trimmed(raw); + if (value === null) { + return fallback; + } + const match = allowed.find((each) => each === value); + if (match === undefined) { + throw new Error(`${name} must be one of ${allowed.join(', ')}`); + } + return match; +} + +function dailyExtractions(raw: string | undefined): number { + const value = trimmed(raw); + if (value === null) { + return DEFAULT_DAILY_EXTRACTIONS; + } + const number = /^\d+$/.test(value) ? Number(value) : NaN; + if ( + !Number.isInteger(number) || + number < MIN_DAILY_EXTRACTIONS || + number > MAX_DAILY_EXTRACTIONS + ) { + throw new Error( + `VOICE_DAILY_EXTRACTIONS must be a whole number from ${MIN_DAILY_EXTRACTIONS} to ${MAX_DAILY_EXTRACTIONS}`, + ); + } + return number; +} + +function recordingEnabled(raw: string | undefined): boolean { + const value = trimmed(raw)?.toLowerCase() ?? null; + if (value === null || value === 'false') { + return false; + } + if (value === 'true') { + return true; + } + throw new Error('VOICE_RECORDING_ENABLED must be "true" or "false"'); +} + +/** Invalid values fail with the variable name (never a secret): startup refuses them. */ +export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig { + return { + model: oneOf( + 'VOICE_MODEL', + env.VOICE_MODEL, + VOICE_MODELS, + DEFAULT_VOICE_MODEL, + ), + dailyExtractions: dailyExtractions(env.VOICE_DAILY_EXTRACTIONS), + recordingEnabled: recordingEnabled(env.VOICE_RECORDING_ENABLED), + whisperModel: oneOf( + 'WHISPER_MODEL', + env.WHISPER_MODEL, + WHISPER_MODELS, + 'small', + ), + }; +} diff --git a/ApiCore-Backend/test/fixtures/voice/inspection-scales.json b/ApiCore-Backend/test/fixtures/voice/inspection-scales.json new file mode 100644 index 0000000..2850c06 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/voice/inspection-scales.json @@ -0,0 +1,26 @@ +[ + { + "field": "broodPattern", + "storedOne": "lückenhaft", + "storedFive": "geschlossen", + "anchors": { "lückenhaft": 1, "mittel": 3, "geschlossen": 5 } + }, + { + "field": "swarmMood", + "storedOne": "ruhig", + "storedFive": "stark", + "anchors": { "ruhig": 1, "mittel": 3, "stark": 5 } + }, + { + "field": "gentleness", + "storedOne": "stechlustig", + "storedFive": "sehr sanft", + "anchors": { "stechlustig": 1, "normal": 3, "sehr sanft": 5 } + }, + { + "field": "combBehavior", + "storedOne": "läuft weg", + "storedFive": "sitzt ruhig", + "anchors": { "läuft weg": 1, "mittel": 3, "sitzt ruhig": 5 } + } +] diff --git a/ApiCore-Backend/test/rekey.e2e-spec.ts b/ApiCore-Backend/test/rekey.e2e-spec.ts index 03741c1..3ae1d97 100644 --- a/ApiCore-Backend/test/rekey.e2e-spec.ts +++ b/ApiCore-Backend/test/rekey.e2e-spec.ts @@ -314,6 +314,8 @@ describe('rekey job (e2e)', () => { }, }, webhookSubscription: prisma.webhookSubscription, + voiceCapture: prisma.voiceCapture, + voiceCaptureAudio: prisma.voiceCaptureAudio, } as unknown as PrismaService; const result = await new RekeyJob(racing).run([]); diff --git a/deploy/k8s/base/backend/secret.example.yaml b/deploy/k8s/base/backend/secret.example.yaml index 08b597c..1a4a80c 100644 --- a/deploy/k8s/base/backend/secret.example.yaml +++ b/deploy/k8s/base/backend/secret.example.yaml @@ -17,8 +17,8 @@ stringData: JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long" JWT_REFRESH_SECRET: "change-me-refresh-secret" # One encryption key per purpose (TOTP secrets, webhook signing secrets, - # backup files). Required in production: at least 32 characters each, all - # three different from each other and from both JWT secrets. + # backup files, voice notes). Required in production: at least 32 characters + # each, all four different from each other and from both JWT secrets. # BACKUP_ENCRYPTION_KEY (with MFA_/WEBHOOK_ENCRYPTION_KEY for MFA and webhook # secrets) is needed to restore backups on another deployment. # Rotation: old value to _PREVIOUS, new , run the rekey job, @@ -27,9 +27,13 @@ stringData: MFA_ENCRYPTION_KEY: "change-me-to-a-third-random-value-at-least-32-characters" WEBHOOK_ENCRYPTION_KEY: "change-me-to-a-fourth-random-value-at-least-32-characters" BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters" + # VOICE_ENCRYPTION_KEY seals audio, transcripts and suggestions of voice notes. + # It must exist before the T7 backend image rolls out (every role checks it). + VOICE_ENCRYPTION_KEY: "change-me-to-a-fifth-random-value-at-least-32-characters" # MFA_ENCRYPTION_KEY_PREVIOUS: "" # WEBHOOK_ENCRYPTION_KEY_PREVIOUS: "" # BACKUP_ENCRYPTION_KEY_PREVIOUS: "" + # VOICE_ENCRYPTION_KEY_PREVIOUS: "" # Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml # fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die # M365-Kalender-Synchronisation wiederverwendet - dafuer in der @@ -55,3 +59,18 @@ stringData: # type: Opaque # stringData: # TELEGRAM_BOT_TOKEN: "" + +# Anthropic-API-Schluessel fuer die Auswertung der Sprachnotizen (optional) - +# NICHT in diesem Secret, sondern im eigenen Secret apicore-anthropic, das nur +# der Worker einbindet (einzelne secretKeyRef-Variable ANTHROPIC_API_KEY mit +# optional: true, kein envFrom, kein Volume; Muster apicore-telegram). API- und +# Job-Pods lesen ihn nie. Der Inhaber legt das Secret selbst an; ohne +# Schluessel bleibt die Auswertung aus (EXTRACT_DISABLED), alles andere laeuft. +# +# apiVersion: v1 +# kind: Secret +# metadata: +# name: apicore-anthropic +# type: Opaque +# stringData: +# ANTHROPIC_API_KEY: "" diff --git a/deploy/k8s/scripts/ensure-secrets.sh b/deploy/k8s/scripts/ensure-secrets.sh index cb9f9ae..de4185c 100644 --- a/deploy/k8s/scripts/ensure-secrets.sh +++ b/deploy/k8s/scripts/ensure-secrets.sh @@ -6,10 +6,13 @@ # bash deploy/k8s/scripts/ensure-secrets.sh --dry-run # nur anzeigen # # Werte: -# - MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY: zufaellig (48 Byte, base64url). Die +# - MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY: zufaellig (48 Byte, base64url). Die # App verlangt mindestens 32 Zeichen; "dummy" wuerde den Start abbrechen. # Diese Schluessel nie wieder aendern ohne *_PREVIOUS (sonst sind MFA- -# Geheimnisse, Webhook-Secrets und Backups nicht mehr lesbar). Sichern! +# Geheimnisse, Webhook-Secrets, Backups und offene Sprachnotizen nicht mehr +# lesbar). Sichern! +# VOICE_ENCRYPTION_KEY muss vor dem Rollout des T7-Backend-Images existieren: +# die App verlangt ihn in Produktion in jeder Rolle und startet sonst nicht. # - OPEN_METEO_API_KEY: leer = freie Open-Meteo-API (ein Fantasiewert wuerde # die kostenpflichtige API mit falschem Schluessel ansprechen). # - TELEGRAM_BOT_TOKEN (Secret apicore-telegram, nur Worker): "dummy". Die App @@ -83,9 +86,14 @@ ensure_keys apicore-backend-secret \ MFA_ENCRYPTION_KEY random \ WEBHOOK_ENCRYPTION_KEY random \ BACKUP_ENCRYPTION_KEY random \ + VOICE_ENCRYPTION_KEY random \ OPEN_METEO_API_KEY empty ensure_keys apicore-telegram \ TELEGRAM_BOT_TOKEN dummy +# apicore-anthropic (ANTHROPIC_API_KEY, nur Worker, optional) legt der Inhaber +# selbst an; dieses Skript erzeugt es nicht. Ohne Schluessel bleibt die +# Auswertung der Sprachnotizen aus (EXTRACT_DISABLED), alles andere laeuft. + echo "Fertig. Laufende Pods lesen neue Werte erst nach einem Neustart."