feat: Schlüssel und gemeinsamer Kern für Sprachnotizen (T7 L2 Tasks 5+6)
Task 5: Schlüsselbund-Zweck voice (VOICE_ENCRYPTION_KEY), AES-256-GCM- Hilfen für Text und Bytes mit AAD capture:<id>:<art>, voice-config (Modell, Tageslimit, Aufnahme, Whisper-Grösse) und anthropic-config (nie geloggt, nur worker/all), Startprüfung verlangt den Schlüssel in Produktion in jeder Rolle, rekey versiegelt offene Sprachnotizen neu (Audio einzeln gelesen, CAS mit Status), Secret-Vorlagen und ensure-secrets.sh ergänzt. Task 6: Text-Normalisierung und Bereinigung (Steuer- und Formatzeichen, Codepoint-Kürzung), Zitatprüfung mit Dezimalzahlen als ein Wort (Ruling 7), Felderliste passend zu CreateInspectionDto, versiegeltes Transkript mit Grenzen, Fachwörter und Skalen mit Paritätstests. Rollout: Ein Image mit diesem Stand startet in Produktion nur mit VOICE_ENCRYPTION_KEY in apicore-backend-secret (ARCHITECTURE Annahme 22). Gates: Backend tsc, eslint, 922 Unit-Tests, e2e 100 Suites / 1004 Tests. Refs #27 #28 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2a4dc868d9
commit
9795f98006
27 files changed
+2212
-31
No files matched your search
@@ -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<string, unknown>;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
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<Promise<UpdateResult>, [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<Promise<UpdateResult>, [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<void, unknown[]>;
|
||||
@@ -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<T>(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<typeof fakeDb> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,29 @@ import {
|
||||
sealWebhookSecret,
|
||||
webhookSecretNeedsRekey,
|
||||
} from '../webhooks/webhook-secret';
|
||||
import { VoiceCaptureStatus } from '../../generated/prisma';
|
||||
import {
|
||||
isVoiceSealedWithCurrentKey,
|
||||
openVoiceBytes,
|
||||
openVoiceText,
|
||||
sealVoiceBytes,
|
||||
sealVoiceText,
|
||||
voiceAad,
|
||||
} from '../voice/core/voice-crypto';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
|
||||
/** `rekey --dry-run`: open and count every secret, write none. */
|
||||
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
||||
|
||||
/** What one run did with the secrets of one purpose (mfa, webhook). */
|
||||
/** Voice content of these states is already deleted (or about to be) and is not resealed. */
|
||||
const FINISHED_VOICE_STATUSES: VoiceCaptureStatus[] = [
|
||||
VoiceCaptureStatus.CONFIRMED,
|
||||
VoiceCaptureStatus.DISCARDED,
|
||||
VoiceCaptureStatus.EXPIRED,
|
||||
];
|
||||
|
||||
/** What one run did with the secrets of one purpose (mfa, webhook, voice). */
|
||||
export interface RekeyCounts {
|
||||
/** Rows with a stored secret. */
|
||||
total: number;
|
||||
@@ -51,6 +67,13 @@ interface SecretColumn {
|
||||
/** How log lines name a row: "<entity> <id>". */
|
||||
entity: string;
|
||||
read(): Promise<StoredSecret[]>;
|
||||
/**
|
||||
* Instead of read(): the ids first, then one row at a time with readOne(),
|
||||
* so large values (audio) are never held all at once. readOne gives null
|
||||
* for a row that is gone or no longer eligible.
|
||||
*/
|
||||
readIds?(): Promise<string[]>;
|
||||
readOne?(id: string): Promise<StoredSecret | null>;
|
||||
/**
|
||||
* Writes `next` only while the row still holds `previous` - one UPDATE
|
||||
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
||||
@@ -59,8 +82,9 @@ interface SecretColumn {
|
||||
swap(id: string, previous: string, next: string): Promise<boolean>;
|
||||
needsRekey(stored: string): boolean;
|
||||
isPlaintext(stored: string): boolean;
|
||||
open(stored: string): string;
|
||||
seal(plaintext: string): string;
|
||||
/** `id` binds voice content to its row (associated data); the other purposes ignore it. */
|
||||
open(stored: string, id: string): string;
|
||||
seal(plaintext: string, id: string): string;
|
||||
}
|
||||
|
||||
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
||||
@@ -87,6 +111,20 @@ function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
|
||||
};
|
||||
}
|
||||
|
||||
/** Sums the counts of columns that share one purpose (voice has three). */
|
||||
function sumCounts(all: readonly RekeyCounts[]): RekeyCounts {
|
||||
const sum = (key: keyof RekeyCounts): number =>
|
||||
all.reduce((total, each) => total + each[key], 0);
|
||||
return {
|
||||
total: sum('total'),
|
||||
rekeyed: sum('rekeyed'),
|
||||
alreadyCurrent: sum('alreadyCurrent'),
|
||||
plaintextSealed: sum('plaintextSealed'),
|
||||
changedMeanwhile: sum('changedMeanwhile'),
|
||||
failed: sum('failed'),
|
||||
};
|
||||
}
|
||||
|
||||
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
||||
function toJobResult(
|
||||
dryRun: boolean,
|
||||
@@ -112,7 +150,8 @@ function describeResult(result: JobResult): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reseals every stored MFA (TOTP) and webhook signing secret with the
|
||||
* Reseals every stored MFA (TOTP) and webhook signing secret, and the
|
||||
* content of open voice notes (transcript, suggestion, audio), with the
|
||||
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
||||
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
||||
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
||||
@@ -135,15 +174,30 @@ export class RekeyJob implements Job {
|
||||
|
||||
async run(args: readonly string[]): Promise<JobResult> {
|
||||
const dryRun = isDryRun(args);
|
||||
const columns = [this.mfaColumn(), this.webhookColumn()];
|
||||
const columns = [
|
||||
this.mfaColumn(),
|
||||
this.webhookColumn(),
|
||||
...this.voiceColumns(),
|
||||
];
|
||||
// A key that is set but unusable fails the run before any row is read.
|
||||
for (const column of columns) {
|
||||
loadKeyRing(column.purpose);
|
||||
for (const purpose of new Set(columns.map((column) => column.purpose))) {
|
||||
loadKeyRing(purpose);
|
||||
}
|
||||
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||
const perColumn: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||
for (const column of columns) {
|
||||
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||
perColumn.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||
}
|
||||
const counts = (['mfa', 'webhook', 'voice'] as const).map(
|
||||
(purpose) =>
|
||||
[
|
||||
purpose,
|
||||
sumCounts(
|
||||
perColumn
|
||||
.filter(([each]) => each === purpose)
|
||||
.map(([, each]) => each),
|
||||
),
|
||||
] as const,
|
||||
);
|
||||
const result = toJobResult(dryRun, counts);
|
||||
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
||||
if (failed > 0) {
|
||||
@@ -158,9 +212,17 @@ export class RekeyJob implements Job {
|
||||
column: SecretColumn,
|
||||
dryRun: boolean,
|
||||
): Promise<RekeyCounts> {
|
||||
const rows = await column.read();
|
||||
const outcomes: RowOutcome[] = [];
|
||||
for (const row of rows) {
|
||||
if (column.readIds && column.readOne) {
|
||||
for (const id of await column.readIds()) {
|
||||
const row = await column.readOne(id);
|
||||
if (row !== null) {
|
||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||
}
|
||||
}
|
||||
return countOutcomes(outcomes);
|
||||
}
|
||||
for (const row of await column.read()) {
|
||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||
}
|
||||
return countOutcomes(outcomes);
|
||||
@@ -185,13 +247,13 @@ export class RekeyJob implements Job {
|
||||
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
||||
try {
|
||||
// Opened in a dry-run too: it reports what a real run could not reseal.
|
||||
const plaintext = column.open(row.stored);
|
||||
const plaintext = column.open(row.stored, row.id);
|
||||
if (dryRun) {
|
||||
return outcome;
|
||||
}
|
||||
const resealed = column.seal(plaintext);
|
||||
const resealed = column.seal(plaintext, row.id);
|
||||
// The swap replaces the only copy of the old value.
|
||||
if (column.open(resealed) !== plaintext) {
|
||||
if (column.open(resealed, row.id) !== plaintext) {
|
||||
throw new Error('resealed secret does not open to the same value');
|
||||
}
|
||||
if (await column.swap(row.id, row.stored, resealed)) {
|
||||
@@ -262,4 +324,96 @@ export class RekeyJob implements Job {
|
||||
seal: sealWebhookSecret,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript, suggestion and audio of open voice notes. Audio is handled
|
||||
* as base64 text so it shares the row logic; it is read only for open
|
||||
* notes (at most a few small files).
|
||||
*/
|
||||
private voiceColumns(): SecretColumn[] {
|
||||
const openStatus = { notIn: FINISHED_VOICE_STATUSES };
|
||||
const textColumn = (
|
||||
field: 'transcriptEnc' | 'suggestionEnc',
|
||||
kind: 'transcript' | 'suggestion',
|
||||
): SecretColumn => ({
|
||||
purpose: 'voice',
|
||||
entity: 'VoiceCapture',
|
||||
read: async () => {
|
||||
const rows = await this.prisma.voiceCapture.findMany({
|
||||
where: { status: openStatus, [field]: { not: null } },
|
||||
select: { id: true, [field]: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.flatMap((row) => {
|
||||
const stored = row[field];
|
||||
return typeof stored === 'string' ? [{ id: row.id, stored }] : [];
|
||||
});
|
||||
},
|
||||
swap: async (id, previous, next) => {
|
||||
const { count } = await this.prisma.voiceCapture.updateMany({
|
||||
where: { id, status: openStatus, [field]: previous },
|
||||
data: { [field]: next },
|
||||
});
|
||||
return count === 1;
|
||||
},
|
||||
needsRekey: (stored) => !isVoiceSealedWithCurrentKey(stored),
|
||||
isPlaintext: () => false,
|
||||
open: (stored, id) => openVoiceText(stored, voiceAad(id, kind)),
|
||||
seal: (plaintext, id) => sealVoiceText(plaintext, voiceAad(id, kind)),
|
||||
});
|
||||
const audioColumn: SecretColumn = {
|
||||
purpose: 'voice',
|
||||
entity: 'VoiceCapture',
|
||||
read: () => Promise.resolve([]),
|
||||
readIds: async () => {
|
||||
const rows = await this.prisma.voiceCaptureAudio.findMany({
|
||||
where: { capture: { status: openStatus } },
|
||||
select: { captureId: true },
|
||||
orderBy: { captureId: 'asc' },
|
||||
});
|
||||
return rows.map((row) => row.captureId);
|
||||
},
|
||||
readOne: async (id) => {
|
||||
const row = await this.prisma.voiceCaptureAudio.findFirst({
|
||||
where: { captureId: id, capture: { status: openStatus } },
|
||||
select: { captureId: true, dataEnc: true },
|
||||
});
|
||||
return row
|
||||
? {
|
||||
id: row.captureId,
|
||||
stored: Buffer.from(row.dataEnc).toString('base64'),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
swap: async (id, previous, next) => {
|
||||
const { count } = await this.prisma.voiceCaptureAudio.updateMany({
|
||||
where: {
|
||||
captureId: id,
|
||||
dataEnc: Buffer.from(previous, 'base64'),
|
||||
capture: { status: openStatus },
|
||||
},
|
||||
data: { dataEnc: Buffer.from(next, 'base64') },
|
||||
});
|
||||
return count === 1;
|
||||
},
|
||||
needsRekey: (stored) =>
|
||||
!isVoiceSealedWithCurrentKey(Buffer.from(stored, 'base64')),
|
||||
isPlaintext: () => false,
|
||||
open: (stored, id) =>
|
||||
openVoiceBytes(
|
||||
Buffer.from(stored, 'base64'),
|
||||
voiceAad(id, 'audio'),
|
||||
).toString('base64'),
|
||||
seal: (plaintext, id) =>
|
||||
sealVoiceBytes(
|
||||
Buffer.from(plaintext, 'base64'),
|
||||
voiceAad(id, 'audio'),
|
||||
).toString('base64'),
|
||||
};
|
||||
return [
|
||||
textColumn('transcriptEnc', 'transcript'),
|
||||
textColumn('suggestionEnc', 'suggestion'),
|
||||
audioColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user