From 6e76da2428cdfc6062b92327a850da886e47a19d Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Wed, 3 Jun 2026 08:25:21 -0400 Subject: [PATCH 01/45] Save design notes --- results-encryption-design-notes.md | 165 ++++++++++++++++++++++++++++ src/server/actions/study.actions.ts | 1 + src/server/storage.ts | 1 + 3 files changed, 167 insertions(+) create mode 100644 results-encryption-design-notes.md diff --git a/results-encryption-design-notes.md b/results-encryption-design-notes.md new file mode 100644 index 000000000..4cf19e019 --- /dev/null +++ b/results-encryption-design-notes.md @@ -0,0 +1,165 @@ +# Results Encryption for Researchers — Design Notes + +> Working synthesis from a grilling session against the codebase (management-app + trusted-output-app). +> Status: **draft for review.** Decisions locked so far are marked ✅; open items are in §8. +> **Scope for this work: Card 72 (prerequisite) → Card 71 only.** Happy path only. Cards 73 / 74 (regeneration, recovery) are explicitly out of scope here. + +--- + +## 1. The problem + +The secure enclave's output (result files + run logs) is delivered to **DO reviewers** encrypted, but the same output is currently exposed to **researchers in plaintext**. Reviewers decrypt client-side with a private key generated at signup; researchers get an unencrypted copy. + +Goal: researchers must *also* decrypt with their own key, so output is never available in plaintext on the researcher side. + +--- + +## 2. How the encryption works today (the "post office" analogy) + +| Concept (analogy) | Implementation | Where in code | +|---|---|---| +| **Results key** — a padlock whose one key both locks and unlocks | A random **AES-256-CBC** symmetric key, generated **per file** (+ a per-file random IV) | `si-encryption/job-results/writer.ts:19,22` | +| **Lock the results** | Encrypt each file's bytes with its AES key | `writer.ts:25` | +| **A PO box for each person** | The AES key is "wrapped" (encrypted) with each recipient's **RSA-OAEP-4096 public key** | `writer.ts:31-35,58-84` | +| **The PO box number** | The recipient's **fingerprint** = SHA-256 of their public key | `util/keypair.ts:105-111` | +| **The post office (directory of boxes)** | `manifest.json` — maps `fingerprint → { crypt }`, plus per-file `iv`, `path`, `bytes` | `writer.ts:39-44`; types in `job-results/types.ts` | +| **Ship it** | TOA zips `manifest.json` + encrypted bodies, POSTs to MA | TOA `src/app/api/job/encrypt-results.ts` | +| **Open the box** | Browser fetches the zip, finds its PO box by fingerprint, unwraps the AES key with its private key, decrypts | `hooks/use-decrypt-files.ts`, `job-results/reader.ts:95-109` | + +**Key types, ELI5** +- **RSA keypair (asymmetric)** — public key = a mail slot anyone can drop into; private key = the only physical key that opens the box. Mathematically bound; you can't edit one to fit the other. +- **AES key (symmetric)** — one key locks and unlocks. Fast; used for big file bodies. One per file. +- **IV (initialization vector)** — a per-file random value, **not secret**, but **required** to decrypt (AES-CBC). Must always travel with its ciphertext. +- **Fingerprint** — the nameplate on the box; SHA-256 of the public key. +- **Envelope encryption** — lock the big file once with a fast AES padlock, then put a *copy* of that AES key into a personal RSA-locked box per recipient. The manifest is the directory of boxes. + +**Where keys live** +- **Public key** → DB `user_public_key` (`public_key` bytea, `fingerprint` text, `UNIQUE(user_id)`). Schema is already **role-agnostic**. +- **Private key** → user downloads a PEM; **never stored server-side**. This is the core guarantee: *the server cannot read results.* + +--- + +## 3. The fact that reframes the work + +Approve currently stores **plaintext**. `approveStudyJobFilesAction` (`src/server/actions/study-job.actions.ts:12-45`) takes the DO's already-decrypted contents and writes them to `results/approved/` via `storeApprovedJobFile`. Researchers read those plaintext files. So this project is *"stop writing the decrypted copy, and give researchers their own PO boxes instead."* + +--- + +## 4. Architecture decisions (locked) + +### ✅ 4.1 One key per user, not per role (Card 72) +A single keypair serves a person as reviewer *and* researcher. Encryption already takes all org public keys regardless of role (`writer.ts`), so the same key works for both hats. Consequence: key handling is user-level, not role-level. + +### ✅ 4.2 Re-wrap, not re-encrypt (core mechanic) +Granting a researcher access does **not** re-encrypt file bodies. The file's existing AES key is **wrapped again** for the researcher's public key → a new PO box. The ciphertext and IV never change. +- The DO's browser already holds the raw AES key (it just decrypted to review), so it wraps that key for each researcher and uploads new boxes. **The server never sees plaintext.** +- A regenerated key cannot open old boxes — that impossibility *is* the security. (Recovery/regeneration handling = out of scope here; see §8.) + +### ✅ 4.3 Option B — decompose on ingest +When MA receives TOA's zip, it **unzips once on the server** and stores the pieces separately: +- each **encrypted body** → its own S3 object +- per-file **metadata (iv, path, bytes)** + per-recipient **wrapped keys** → Postgres + +The manifest is **plaintext metadata** (IVs/fingerprints/wrapped keys are not secret), and the server already parses it today with an empty key just to list files (`study-job.actions.ts:72-76`). So decompose-on-ingest needs **no private keys and touches no plaintext** — the zero-plaintext-on-server guarantee holds. + +Postgres is the **single source of truth for PO boxes** (both DO and researcher). The zip is reduced to a transient ingest format; the read path no longer unzips. + +**Proposed schema (per file — a zip can hold many files, so do NOT model "zip = one row"):** +``` +job_file (id, study_job_id, path, bytes, iv, blob_location) -- one row per file; IV lives here in Option B +job_file_key (job_file_id, fingerprint, crypt) -- one row per recipient-per-file = a PO box + UNIQUE(job_file_id, fingerprint) +``` + +**Read path (DO and researcher, identical):** fetch encrypted body from S3 + `iv` and the row matching *my* fingerprint from `job_file_key` → decrypt client-side. `SELECT crypt FROM job_file_key WHERE fingerprint = ? AND job_file_id IN (...)` (one box per file). + +### ✅ 4.4 Re-wrap at approve, client-side (Card 71) +On **Approve**, the DO's browser: +1. Fetches the **lab's** public keys — members of `study.submittedByOrgId` who have a key. +2. Wraps each approved file's AES key (already in memory from decrypting) to each researcher public key. +3. POSTs the new PO boxes; server inserts `job_file_key` rows in one transaction (atomic, idempotent via the unique constraint). + +### ✅ 4.5 Remove plaintext storage +Drop the `storeApprovedJobFile` plaintext write. Researchers read the encrypted body + their PO box and decrypt client-side, using the **same** path as reviewers. + +--- + +## 5. Blast radius / implementation notes + +**Reassuring:** decompose-on-ingest and re-wrap both operate only on encrypted bytes + non-secret metadata. No server-side plaintext, no server-side private keys. + +**Hidden cost of Option B — shared library change.** The current decrypt path is zip-shaped end-to-end: `ResultsReader`'s constructor takes a zip blob and reads `manifest.json` from inside it (`reader.ts:17,45-49`). Option B has no zip at read time, so we must: +- Add a **zip-free decrypt primitive** to `si-encryption`, e.g. `decryptFile(encryptedBody, iv, wrappedKeyForMyFingerprint, privateKey) → plaintext`. The guts already exist (`readFile` does `decryptKeyWithPrivateKey` + `decryptData`, `reader.ts:95-109`) — they just need to be unwelded from zip iteration. +- Have the reader **expose the raw AES key** per file (today `readFile` computes it but only returns the decrypted body) — **re-wrap needs that key**. +- Rewrite the client hook (`use-decrypt-files.ts` / `use-encrypted-files-panel.ts`) to fetch body-from-S3 + iv/crypt-from-PG instead of unzipping. + +This is a **shared-library change, not MA-only.** + +### Card 72 — researcher key generation (prerequisite) +The `user_public_key` schema is already role-agnostic; only the **gate** is enclave-scoped. To include lab-org users: +- `src/app/account/invitation/[inviteId]/create-account.action.ts:155` — `org.type === 'enclave'` → include `'lab'` +- `src/components/require-reviewer-key.tsx:17` + login check in `src/server/actions/user.actions.ts` — `isEnclaveOrg` → include lab +- `src/lib/permissions.ts:67-70` — permit `ReviewerKey` for lab orgs too +- Naming: keep the existing `ReviewerKey` / `require-reviewer-key` / `set/updateReviewerPublicKeyAction` names as-is. **Renaming is out of scope for this work** — just widen the gate. + +### Card 71 — Option B steps +1. **Ingest** (`src/app/api/job/[jobId]/results/route.ts`, ~line 52, currently calls `storeStudyEncryptedResultsFile` → `results/encrypted-results.zip`): replace store-zip-as-is with decompose → S3 bodies + `job_file`/`job_file_key` rows (including DO boxes), one transaction. +2. **Decrypt path:** new zip-free primitive + client-hook rewrite (body + iv + crypt-from-PG). +3. **Approve:** drop plaintext write; DO browser re-wraps approved files' AES keys for lab researchers and POSTs `job_file_key` inserts. +4. **Researcher view:** reuse the DO decrypt path. + +--- + +## 6. Per-file approve/reject (confirmed from code) + +Today's behavior is **asymmetric**: +- **Approve = per-file subset.** Checkbox per result file; all auto-selected on decrypt, reviewer can uncheck (`use-encrypted-files-panel.ts:39,85-86,94-101`). The action takes the chosen subset: `approveStudyJobFilesAction.params({ jobFiles: z.array(...) })` (`study-job.actions.ts:12-45`); button passes `jobFiles: decryptedResults` (`job-review-buttons.tsx:45`). +- **Reject = whole job.** `rejectStudyJobFilesAction` takes no file list — flips job to `FILES-REJECTED` (`study-job.actions.ts:47-70`). + +**Important catch:** there is **no per-file approval column.** Approval is tracked only at the **job level** (`jobStatusChange.status`). "This file is approved" is encoded *implicitly* today by the existence of a plaintext `APPROVED-*` copy — which Option B (§4.5) **deletes**. So if per-file approve is kept, approval state needs a new home: +- **(a)** The researcher `job_file_key` rows *are* the signal — re-wrap only approved files; "has a researcher PO box" = "approved & shared." No new column. (Recommended — the rows are already per-file and already being inserted.) +- **(b)** An explicit `approved` flag/status on `job_file`. Cleaner/queryable, decouples "approved" from "shared," but adds a column. + +If product chooses **all-or-nothing** approve/reject instead, the existing job-level status suffices, re-wrap covers all files, and no per-file state is needed. + +> **Open product decision (Phil):** support per-file approve/reject, or approve/reject the whole artifact set together? This decides whether (a)/(b) is needed. Re-wrap must only ever cover **approved** files — rejected outputs must never get researcher PO boxes. + +--- + +## 7. The cards (this work = 72 → 71 only) + +- ✅ **Card 72 — researcher key generation (prerequisite for 71).** Widen the key-gen gate to lab orgs; decide naming. +- ✅ **Card 71 — enable encryption for researchers.** Decompose on ingest (Option B); re-wrap approved files at approve; remove plaintext; researcher decrypts client-side. +- ⛔ **Card 73 — key re-generation flow.** Out of scope here. +- ⛔ **Card 74 — user-led re-encryption / recovery.** Out of scope here. + +--- + +## 8. Open questions + +1. **Per-file vs all-together approve/reject** (§6) — **NOTE FOR PHIL: finalize with team/PMs.** Should reviewers approve/reject result files individually (per-file subset, as approve works today) or approve/reject the whole artifact set together? This is the one open decision that affects implementation: per-file requires a new home for approval state (§6 a/b) since Option B removes the implicit plaintext-copy signal; all-together needs none. Re-wrap must only ever cover **approved** files. + +**Out of scope for this work (recorded so they aren't lost; whatever's easiest, UX/product decide later):** +- Renaming "Reviewer key" → role-neutral. +- Forced regeneration, key-loss responsibility, UX regeneration/access-split questions. +- Legacy/beta data handling, concurrent-study key association. + +--- + +## 9. Key file references + +**TOA** — `src/app/api/job/encrypt-results.ts` (encryption), `src/app/management-app-requests.ts` (fetches org public keys). + +**MA** +- `src/app/api/job/[jobId]/results/route.ts` — **ingest point** (decompose here) +- `src/app/api/job/[jobId]/keys/route.ts` — public-keys endpoint +- `src/server/storage.ts` — `storeStudyEncryptedResultsFile` (zip today), `storeApprovedJobFile` (plaintext — to remove) +- `src/server/actions/study-job.actions.ts` — `approve/rejectStudyJobFilesAction`; server-side `ResultsReader.listFiles` precedent (:72-76) +- `src/server/db/queries.ts` — `getOrgIdForJobId` (enclave org), `getOrgPublicKeys` (INNER JOIN = has-key filter); **needs a new lab-org query keyed on `submittedByOrgId`** +- `src/server/actions/user-keys.actions.ts` — public-key write path (role-agnostic) +- `src/lib/permissions.ts:67-70` — key-gen gate (enclave-only today) +- `src/components/require-reviewer-key.tsx`, `src/app/account/invitation/[inviteId]/create-account.action.ts:155`, `src/server/actions/user.actions.ts` — key-gen onboarding gates +- `src/hooks/use-decrypt-files.ts`, `src/hooks/use-encrypted-files-panel.ts`, `src/components/encrypted-files-panel.tsx` — client decrypt + per-file selection +- review views: `src/app/[orgSlug]/study/[studyId]/review/...`; researcher view: `.../study/[studyId]/view/...` + +**si-encryption** — `job-results/writer.ts` (envelope encryption), `job-results/reader.ts` (decryption — needs zip-free primitive + expose AES key), `job-results/types.ts`, `util/keypair.ts`. diff --git a/src/server/actions/study.actions.ts b/src/server/actions/study.actions.ts index 77ba1790d..38918d988 100644 --- a/src/server/actions/study.actions.ts +++ b/src/server/actions/study.actions.ts @@ -316,6 +316,7 @@ async function approveJobCode({ const info = { studyId, studyJobId: job.id, orgSlug } for (const jobFile of jobFiles) { const file = new File([jobFile.contents], jobFile.path) + // TODO Delete me in new approach, no longer storing approved job file await storeApprovedJobFile(info, file, jobFile.fileType, jobFile.sourceId) } } diff --git a/src/server/storage.ts b/src/server/storage.ts index e1ac66e90..a57dae5e1 100644 --- a/src/server/storage.ts +++ b/src/server/storage.ts @@ -48,6 +48,7 @@ export async function storeStudyEncryptedResultsFile(info: MinimalJobInfo, file: return await storeJobFile(info, `${pathForStudyJob(info)}/results/encrypted-results.zip`, file, 'ENCRYPTED-RESULT') } +// TODO No longer exists in future iteration export async function storeApprovedJobFile(info: MinimalJobInfo, file: File, fileType: FileType, sourceId: string) { return await storeJobFile(info, `${pathForStudyJob(info)}/results/approved/${file.name}`, file, fileType, sourceId) } From b39ec874474a1fcf49a6812fd7693f806973a3ca Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Wed, 3 Jun 2026 11:02:04 -0400 Subject: [PATCH 02/45] Allow for researcher to get keys now too --- .../[inviteId]/create-account.action.test.ts | 4 ++-- .../[inviteId]/create-account.action.ts | 3 ++- src/components/require-reviewer-key.test.tsx | 23 ++++++++++++------- src/components/require-reviewer-key.tsx | 6 ++--- src/lib/permissions.test.ts | 8 +++++++ src/lib/permissions.ts | 6 ++--- src/lib/types.test.ts | 12 +++++++++- src/lib/types.ts | 7 ++++++ src/server/actions/user.actions.test.ts | 8 +++++++ src/server/actions/user.actions.ts | 4 ++-- 10 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/app/account/invitation/[inviteId]/create-account.action.test.ts b/src/app/account/invitation/[inviteId]/create-account.action.test.ts index a1befdc45..cb073f14c 100644 --- a/src/app/account/invitation/[inviteId]/create-account.action.test.ts +++ b/src/app/account/invitation/[inviteId]/create-account.action.test.ts @@ -180,7 +180,7 @@ describe('Create Account Actions', () => { expect(result.needsReviewerKey).toBe(false) }) - it('onJoinTeamAccountAction returns needsReviewerKey false for lab org', async () => { + it('onJoinTeamAccountAction returns needsReviewerKey true for lab org without existing key', async () => { const { user } = await insertTestUser({ org }) const labOrg = await insertTestOrg({ slug: faker.string.alpha(10), type: 'lab' }) @@ -197,7 +197,7 @@ describe('Create Account Actions', () => { .executeTakeFirstOrThrow() const result = actionResult(await onJoinTeamAccountAction({ inviteId: invite.id })) - expect(result.needsReviewerKey).toBe(false) + expect(result.needsReviewerKey).toBe(true) }) it('onRevokeInviteAction removes invite', async () => { diff --git a/src/app/account/invitation/[inviteId]/create-account.action.ts b/src/app/account/invitation/[inviteId]/create-account.action.ts index 1b2786e6a..1d95a1bc6 100644 --- a/src/app/account/invitation/[inviteId]/create-account.action.ts +++ b/src/app/account/invitation/[inviteId]/create-account.action.ts @@ -5,6 +5,7 @@ import { updateClerkUserMetadata } from '@/server/clerk' import { getReviewerPublicKey } from '@/server/db/queries' import { onUserAcceptInvite } from '@/server/events' import { clerkClient } from '@clerk/nextjs/server' +import { orgNeedsKey } from '@/lib/types' export const onPendingUserLoginAction = new Action('onPendingUserLoginAction') .params(z.object({ inviteId: z.string() })) @@ -152,7 +153,7 @@ export const onJoinTeamAccountAction = new Action('onJoinTeamAccountAction') .where('org.id', '=', invite.orgId) .executeTakeFirstOrThrow() - const needsReviewerKey = org.type === 'enclave' && !(await getReviewerPublicKey(siUser.id)) + const needsReviewerKey = orgNeedsKey(org) && !(await getReviewerPublicKey(siUser.id)) return { ...siUser, needsReviewerKey } }) diff --git a/src/components/require-reviewer-key.test.tsx b/src/components/require-reviewer-key.test.tsx index ee3c63317..c480cbddc 100644 --- a/src/components/require-reviewer-key.test.tsx +++ b/src/components/require-reviewer-key.test.tsx @@ -1,15 +1,11 @@ import { reviewerKeyExistsAction } from '@/server/actions/user-keys.actions' +import { useSession } from '@/hooks/session' import { render, waitFor } from '@testing-library/react' import { describe, expect, it, Mock, vi } from 'vitest' import { RequireReviewerKey } from './require-reviewer-key' -vi.mock('@/hooks/session', () => ({ - useSession: () => ({ - session: { - orgs: { enclave: { type: 'enclave' } }, - }, - }), -})) +vi.mock('@/hooks/session', () => ({ useSession: vi.fn() })) + const push = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push }), @@ -19,17 +15,28 @@ vi.mock('@/server/actions/user-keys.actions', () => ({ reviewerKeyExistsAction: vi.fn(), })) +const mockSessionOrgs = (orgs: Record) => + (useSession as Mock).mockReturnValue({ session: { orgs } }) + describe('RequireReviewerKey', () => { it('redirects when key is missing', async () => { + mockSessionOrgs({ enclave: { type: 'enclave' } }) ;(reviewerKeyExistsAction as Mock).mockResolvedValue(false) render() await waitFor(() => expect(push).toHaveBeenCalledWith('/account/keys')) }) it('does nothing when key exists', async () => { - push.mockClear() + mockSessionOrgs({ enclave: { type: 'enclave' } }) ;(reviewerKeyExistsAction as Mock).mockResolvedValue(true) render() expect(push).not.toHaveBeenCalled() }) + + it('redirects lab researchers without a key', async () => { + mockSessionOrgs({ lab: { type: 'lab' } }) + ;(reviewerKeyExistsAction as Mock).mockResolvedValue(false) + render() + await waitFor(() => expect(push).toHaveBeenCalledWith('/account/keys')) + }) }) diff --git a/src/components/require-reviewer-key.tsx b/src/components/require-reviewer-key.tsx index 4b3940b5f..1e744d11b 100644 --- a/src/components/require-reviewer-key.tsx +++ b/src/components/require-reviewer-key.tsx @@ -1,6 +1,6 @@ 'use client' -import { isEnclaveOrg } from '@/lib/types' +import { orgNeedsKey } from '@/lib/types' import { actionResult } from '@/lib/utils' import { reviewerKeyExistsAction } from '@/server/actions/user-keys.actions' import { useRouter } from 'next/navigation' @@ -14,8 +14,8 @@ export const RequireReviewerKey = () => { useLayoutEffect(() => { const checkForReviewerKey = async () => { - const enclaveOrgs = Object.values(session?.orgs || {}).some(isEnclaveOrg) - if (!session || !enclaveOrgs) return + const needsKey = Object.values(session?.orgs || {}).some(orgNeedsKey) + if (!session || !needsKey) return const hasKey = actionResult(await reviewerKeyExistsAction()) diff --git a/src/lib/permissions.test.ts b/src/lib/permissions.test.ts index b6e89bb9e..926ebedf1 100644 --- a/src/lib/permissions.test.ts +++ b/src/lib/permissions.test.ts @@ -37,6 +37,10 @@ test('reviewer role', () => { expect(ability.can('invite', toRecord('User', { orgId: session.orgs.test.id }))).toBe(false) expect(ability.can('update', toRecord('User', { id: session.user.id }))).toBe(true) expect(ability.can('update', toRecord('User', { id: faker.string.uuid() }))).toBe(false) + + // enclave members hold a key to decrypt results for review + expect(ability.can('view', 'ReviewerKey')).toBe(true) + expect(ability.can('update', 'ReviewerKey')).toBe(true) }) test('researcher role', () => { @@ -51,6 +55,10 @@ test('researcher role', () => { // Researchers cannot invite users to their org (not admins) expect(ability.can('invite', toRecord('User', { orgId: session.orgs.test.id }))).toBe(false) + + // lab members also hold a key now, to decrypt approved results + expect(ability.can('view', 'ReviewerKey')).toBe(true) + expect(ability.can('update', 'ReviewerKey')).toBe(true) }) test('admin role', () => { diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 2b31b6820..b4646303b 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -1,4 +1,4 @@ -import { type UserSession, isLabOrg, isEnclaveOrg, isOrgAdmin } from './types' +import { type UserSession, isLabOrg, isEnclaveOrg, isOrgAdmin, orgNeedsKey } from './types' import { AbilityBuilder, createMongoAbility, subject } from '@casl/ability' import { AppAbility, @@ -63,8 +63,8 @@ export function defineAbilityFor(session: UserSession) { permit('view', 'Study', { submittedByOrgId: { $in: usersOrgIds } }) permit('view', 'StudyJob', { submittedByOrgId: { $in: usersOrgIds } }) - // user who belongs to any enclave orgs can view/create/update their keys - if (usersReviewerOrgIds.length) { + // users who belong to any key-holding org (enclave or lab) can view/create/update their keys + if (orgs.some(orgNeedsKey)) { permit('view', 'ReviewerKey') permit('update', 'ReviewerKey') } diff --git a/src/lib/types.test.ts b/src/lib/types.test.ts index 6426ef741..5fff19304 100644 --- a/src/lib/types.test.ts +++ b/src/lib/types.test.ts @@ -1,7 +1,17 @@ import { describe, it, expect } from 'vitest' -import { getLabOrg } from '@/lib/types' +import { getLabOrg, orgNeedsKey } from '@/lib/types' import { mockSessionWithTestData, createMockUserSession } from '@/tests/unit.helpers' +describe('orgNeedsKey helper', () => { + it('returns true for enclave orgs', () => { + expect(orgNeedsKey({ type: 'enclave' })).toBe(true) + }) + + it('returns true for lab orgs', () => { + expect(orgNeedsKey({ type: 'lab' })).toBe(true) + }) +}) + describe('getLabOrg helper', () => { it('returns null when user has only enclave orgs', async () => { const { user, org } = await mockSessionWithTestData({ orgType: 'enclave' }) diff --git a/src/lib/types.ts b/src/lib/types.ts index 2fcad26b7..fafe0df80 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -38,6 +38,13 @@ export function isLabOrg(org: { type: OrgType }): org is LabOrg { return org.type === 'lab' } +// Members of these org types hold an encryption key: enclave reviewers decrypt to +// review, lab researchers decrypt approved results. A user without a key for one of +// these orgs is gated into key generation. +export function orgNeedsKey(org: { type: OrgType }): boolean { + return isEnclaveOrg(org) || isLabOrg(org) +} + // Helper functions to get orgs from session export function getLabOrg(session: UserSession): Org | null { return Object.values(session.orgs).find(isLabOrg) || null diff --git a/src/server/actions/user.actions.test.ts b/src/server/actions/user.actions.test.ts index bab371bfa..53d54c9c6 100644 --- a/src/server/actions/user.actions.test.ts +++ b/src/server/actions/user.actions.test.ts @@ -47,6 +47,14 @@ describe('User Actions', () => { expect(result).toEqual({}) }) + test('onUserSignInAction should redirect lab researchers without a key to the key page', async () => { + const { user } = await mockSessionWithTestData({ orgType: 'lab' }) + await db.deleteFrom('userPublicKey').where('userId', '=', user.id).execute() + + const result = await onUserSignInAction() + expect(result).toEqual({ redirectToReviewerKey: true }) + }) + test('syncUserMetadataAction should sync metadata', async () => { await mockSessionWithTestData() const result = await syncUserMetadataAction() diff --git a/src/server/actions/user.actions.ts b/src/server/actions/user.actions.ts index 324f0605a..d6166ec77 100644 --- a/src/server/actions/user.actions.ts +++ b/src/server/actions/user.actions.ts @@ -5,7 +5,7 @@ import { sessionFromClerk } from '../clerk' import { getReviewerPublicKey } from '../db/queries' import { onUserLogIn, onUserResetPW, onUserRoleUpdate } from '../events' import { Action, z } from './action' -import { isEnclaveOrg } from '@/lib/types' +import { orgNeedsKey } from '@/lib/types' export const onUserSignInAction = new Action('onUserSignInAction').handler(async () => { // Force metadata sync on sign-in to ensure session has fresh data @@ -14,7 +14,7 @@ export const onUserSignInAction = new Action('onUserSignInAction').handler(async throw new Error('Failed to establish session') } onUserLogIn({ userId: session.user.id }) - if (Object.values(session.orgs).some((org) => isEnclaveOrg(org))) { + if (Object.values(session.orgs).some((org) => orgNeedsKey(org))) { const publicKey = await getReviewerPublicKey(session.user.id) if (!publicKey) { return { redirectToReviewerKey: true } From 863078eecc0ebf5bb846ed4bb5c1a1d0e4e28266 Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Wed, 3 Jun 2026 13:43:16 -0400 Subject: [PATCH 03/45] Fix tests + ci lint --- results-encryption-design-notes.md | 55 +++++++++++++------- src/hooks/use-workspace-launcher.tsx | 2 +- src/server/actions/user-keys.actions.test.ts | 19 +++---- src/server/actions/user.actions.ts | 2 +- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/results-encryption-design-notes.md b/results-encryption-design-notes.md index 4cf19e019..219d68545 100644 --- a/results-encryption-design-notes.md +++ b/results-encryption-design-notes.md @@ -10,53 +10,60 @@ The secure enclave's output (result files + run logs) is delivered to **DO reviewers** encrypted, but the same output is currently exposed to **researchers in plaintext**. Reviewers decrypt client-side with a private key generated at signup; researchers get an unencrypted copy. -Goal: researchers must *also* decrypt with their own key, so output is never available in plaintext on the researcher side. +Goal: researchers must _also_ decrypt with their own key, so output is never available in plaintext on the researcher side. --- ## 2. How the encryption works today (the "post office" analogy) -| Concept (analogy) | Implementation | Where in code | -|---|---|---| -| **Results key** — a padlock whose one key both locks and unlocks | A random **AES-256-CBC** symmetric key, generated **per file** (+ a per-file random IV) | `si-encryption/job-results/writer.ts:19,22` | -| **Lock the results** | Encrypt each file's bytes with its AES key | `writer.ts:25` | -| **A PO box for each person** | The AES key is "wrapped" (encrypted) with each recipient's **RSA-OAEP-4096 public key** | `writer.ts:31-35,58-84` | -| **The PO box number** | The recipient's **fingerprint** = SHA-256 of their public key | `util/keypair.ts:105-111` | -| **The post office (directory of boxes)** | `manifest.json` — maps `fingerprint → { crypt }`, plus per-file `iv`, `path`, `bytes` | `writer.ts:39-44`; types in `job-results/types.ts` | -| **Ship it** | TOA zips `manifest.json` + encrypted bodies, POSTs to MA | TOA `src/app/api/job/encrypt-results.ts` | -| **Open the box** | Browser fetches the zip, finds its PO box by fingerprint, unwraps the AES key with its private key, decrypts | `hooks/use-decrypt-files.ts`, `job-results/reader.ts:95-109` | +| Concept (analogy) | Implementation | Where in code | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | +| **Results key** — a padlock whose one key both locks and unlocks | A random **AES-256-CBC** symmetric key, generated **per file** (+ a per-file random IV) | `si-encryption/job-results/writer.ts:19,22` | +| **Lock the results** | Encrypt each file's bytes with its AES key | `writer.ts:25` | +| **A PO box for each person** | The AES key is "wrapped" (encrypted) with each recipient's **RSA-OAEP-4096 public key** | `writer.ts:31-35,58-84` | +| **The PO box number** | The recipient's **fingerprint** = SHA-256 of their public key | `util/keypair.ts:105-111` | +| **The post office (directory of boxes)** | `manifest.json` — maps `fingerprint → { crypt }`, plus per-file `iv`, `path`, `bytes` | `writer.ts:39-44`; types in `job-results/types.ts` | +| **Ship it** | TOA zips `manifest.json` + encrypted bodies, POSTs to MA | TOA `src/app/api/job/encrypt-results.ts` | +| **Open the box** | Browser fetches the zip, finds its PO box by fingerprint, unwraps the AES key with its private key, decrypts | `hooks/use-decrypt-files.ts`, `job-results/reader.ts:95-109` | **Key types, ELI5** + - **RSA keypair (asymmetric)** — public key = a mail slot anyone can drop into; private key = the only physical key that opens the box. Mathematically bound; you can't edit one to fit the other. - **AES key (symmetric)** — one key locks and unlocks. Fast; used for big file bodies. One per file. - **IV (initialization vector)** — a per-file random value, **not secret**, but **required** to decrypt (AES-CBC). Must always travel with its ciphertext. - **Fingerprint** — the nameplate on the box; SHA-256 of the public key. -- **Envelope encryption** — lock the big file once with a fast AES padlock, then put a *copy* of that AES key into a personal RSA-locked box per recipient. The manifest is the directory of boxes. +- **Envelope encryption** — lock the big file once with a fast AES padlock, then put a _copy_ of that AES key into a personal RSA-locked box per recipient. The manifest is the directory of boxes. **Where keys live** + - **Public key** → DB `user_public_key` (`public_key` bytea, `fingerprint` text, `UNIQUE(user_id)`). Schema is already **role-agnostic**. -- **Private key** → user downloads a PEM; **never stored server-side**. This is the core guarantee: *the server cannot read results.* +- **Private key** → user downloads a PEM; **never stored server-side**. This is the core guarantee: _the server cannot read results._ --- ## 3. The fact that reframes the work -Approve currently stores **plaintext**. `approveStudyJobFilesAction` (`src/server/actions/study-job.actions.ts:12-45`) takes the DO's already-decrypted contents and writes them to `results/approved/` via `storeApprovedJobFile`. Researchers read those plaintext files. So this project is *"stop writing the decrypted copy, and give researchers their own PO boxes instead."* +Approve currently stores **plaintext**. `approveStudyJobFilesAction` (`src/server/actions/study-job.actions.ts:12-45`) takes the DO's already-decrypted contents and writes them to `results/approved/` via `storeApprovedJobFile`. Researchers read those plaintext files. So this project is _"stop writing the decrypted copy, and give researchers their own PO boxes instead."_ --- ## 4. Architecture decisions (locked) ### ✅ 4.1 One key per user, not per role (Card 72) -A single keypair serves a person as reviewer *and* researcher. Encryption already takes all org public keys regardless of role (`writer.ts`), so the same key works for both hats. Consequence: key handling is user-level, not role-level. + +A single keypair serves a person as reviewer _and_ researcher. Encryption already takes all org public keys regardless of role (`writer.ts`), so the same key works for both hats. Consequence: key handling is user-level, not role-level. ### ✅ 4.2 Re-wrap, not re-encrypt (core mechanic) + Granting a researcher access does **not** re-encrypt file bodies. The file's existing AES key is **wrapped again** for the researcher's public key → a new PO box. The ciphertext and IV never change. + - The DO's browser already holds the raw AES key (it just decrypted to review), so it wraps that key for each researcher and uploads new boxes. **The server never sees plaintext.** -- A regenerated key cannot open old boxes — that impossibility *is* the security. (Recovery/regeneration handling = out of scope here; see §8.) +- A regenerated key cannot open old boxes — that impossibility _is_ the security. (Recovery/regeneration handling = out of scope here; see §8.) ### ✅ 4.3 Option B — decompose on ingest + When MA receives TOA's zip, it **unzips once on the server** and stores the pieces separately: + - each **encrypted body** → its own S3 object - per-file **metadata (iv, path, bytes)** + per-recipient **wrapped keys** → Postgres @@ -65,21 +72,25 @@ The manifest is **plaintext metadata** (IVs/fingerprints/wrapped keys are not se Postgres is the **single source of truth for PO boxes** (both DO and researcher). The zip is reduced to a transient ingest format; the read path no longer unzips. **Proposed schema (per file — a zip can hold many files, so do NOT model "zip = one row"):** + ``` job_file (id, study_job_id, path, bytes, iv, blob_location) -- one row per file; IV lives here in Option B job_file_key (job_file_id, fingerprint, crypt) -- one row per recipient-per-file = a PO box UNIQUE(job_file_id, fingerprint) ``` -**Read path (DO and researcher, identical):** fetch encrypted body from S3 + `iv` and the row matching *my* fingerprint from `job_file_key` → decrypt client-side. `SELECT crypt FROM job_file_key WHERE fingerprint = ? AND job_file_id IN (...)` (one box per file). +**Read path (DO and researcher, identical):** fetch encrypted body from S3 + `iv` and the row matching _my_ fingerprint from `job_file_key` → decrypt client-side. `SELECT crypt FROM job_file_key WHERE fingerprint = ? AND job_file_id IN (...)` (one box per file). ### ✅ 4.4 Re-wrap at approve, client-side (Card 71) + On **Approve**, the DO's browser: + 1. Fetches the **lab's** public keys — members of `study.submittedByOrgId` who have a key. 2. Wraps each approved file's AES key (already in memory from decrypting) to each researcher public key. 3. POSTs the new PO boxes; server inserts `job_file_key` rows in one transaction (atomic, idempotent via the unique constraint). ### ✅ 4.5 Remove plaintext storage + Drop the `storeApprovedJobFile` plaintext write. Researchers read the encrypted body + their PO box and decrypt client-side, using the **same** path as reviewers. --- @@ -89,6 +100,7 @@ Drop the `storeApprovedJobFile` plaintext write. Researchers read the encrypted **Reassuring:** decompose-on-ingest and re-wrap both operate only on encrypted bytes + non-secret metadata. No server-side plaintext, no server-side private keys. **Hidden cost of Option B — shared library change.** The current decrypt path is zip-shaped end-to-end: `ResultsReader`'s constructor takes a zip blob and reads `manifest.json` from inside it (`reader.ts:17,45-49`). Option B has no zip at read time, so we must: + - Add a **zip-free decrypt primitive** to `si-encryption`, e.g. `decryptFile(encryptedBody, iv, wrappedKeyForMyFingerprint, privateKey) → plaintext`. The guts already exist (`readFile` does `decryptKeyWithPrivateKey` + `decryptData`, `reader.ts:95-109`) — they just need to be unwelded from zip iteration. - Have the reader **expose the raw AES key** per file (today `readFile` computes it but only returns the decrypted body) — **re-wrap needs that key**. - Rewrite the client hook (`use-decrypt-files.ts` / `use-encrypted-files-panel.ts`) to fetch body-from-S3 + iv/crypt-from-PG instead of unzipping. @@ -96,13 +108,16 @@ Drop the `storeApprovedJobFile` plaintext write. Researchers read the encrypted This is a **shared-library change, not MA-only.** ### Card 72 — researcher key generation (prerequisite) + The `user_public_key` schema is already role-agnostic; only the **gate** is enclave-scoped. To include lab-org users: + - `src/app/account/invitation/[inviteId]/create-account.action.ts:155` — `org.type === 'enclave'` → include `'lab'` - `src/components/require-reviewer-key.tsx:17` + login check in `src/server/actions/user.actions.ts` — `isEnclaveOrg` → include lab - `src/lib/permissions.ts:67-70` — permit `ReviewerKey` for lab orgs too - Naming: keep the existing `ReviewerKey` / `require-reviewer-key` / `set/updateReviewerPublicKeyAction` names as-is. **Renaming is out of scope for this work** — just widen the gate. ### Card 71 — Option B steps + 1. **Ingest** (`src/app/api/job/[jobId]/results/route.ts`, ~line 52, currently calls `storeStudyEncryptedResultsFile` → `results/encrypted-results.zip`): replace store-zip-as-is with decompose → S3 bodies + `job_file`/`job_file_key` rows (including DO boxes), one transaction. 2. **Decrypt path:** new zip-free primitive + client-hook rewrite (body + iv + crypt-from-PG). 3. **Approve:** drop plaintext write; DO browser re-wraps approved files' AES keys for lab researchers and POSTs `job_file_key` inserts. @@ -113,11 +128,13 @@ The `user_public_key` schema is already role-agnostic; only the **gate** is encl ## 6. Per-file approve/reject (confirmed from code) Today's behavior is **asymmetric**: + - **Approve = per-file subset.** Checkbox per result file; all auto-selected on decrypt, reviewer can uncheck (`use-encrypted-files-panel.ts:39,85-86,94-101`). The action takes the chosen subset: `approveStudyJobFilesAction.params({ jobFiles: z.array(...) })` (`study-job.actions.ts:12-45`); button passes `jobFiles: decryptedResults` (`job-review-buttons.tsx:45`). - **Reject = whole job.** `rejectStudyJobFilesAction` takes no file list — flips job to `FILES-REJECTED` (`study-job.actions.ts:47-70`). -**Important catch:** there is **no per-file approval column.** Approval is tracked only at the **job level** (`jobStatusChange.status`). "This file is approved" is encoded *implicitly* today by the existence of a plaintext `APPROVED-*` copy — which Option B (§4.5) **deletes**. So if per-file approve is kept, approval state needs a new home: -- **(a)** The researcher `job_file_key` rows *are* the signal — re-wrap only approved files; "has a researcher PO box" = "approved & shared." No new column. (Recommended — the rows are already per-file and already being inserted.) +**Important catch:** there is **no per-file approval column.** Approval is tracked only at the **job level** (`jobStatusChange.status`). "This file is approved" is encoded _implicitly_ today by the existence of a plaintext `APPROVED-*` copy — which Option B (§4.5) **deletes**. So if per-file approve is kept, approval state needs a new home: + +- **(a)** The researcher `job_file_key` rows _are_ the signal — re-wrap only approved files; "has a researcher PO box" = "approved & shared." No new column. (Recommended — the rows are already per-file and already being inserted.) - **(b)** An explicit `approved` flag/status on `job_file`. Cleaner/queryable, decouples "approved" from "shared," but adds a column. If product chooses **all-or-nothing** approve/reject instead, the existing job-level status suffices, re-wrap covers all files, and no per-file state is needed. @@ -140,6 +157,7 @@ If product chooses **all-or-nothing** approve/reject instead, the existing job-l 1. **Per-file vs all-together approve/reject** (§6) — **NOTE FOR PHIL: finalize with team/PMs.** Should reviewers approve/reject result files individually (per-file subset, as approve works today) or approve/reject the whole artifact set together? This is the one open decision that affects implementation: per-file requires a new home for approval state (§6 a/b) since Option B removes the implicit plaintext-copy signal; all-together needs none. Re-wrap must only ever cover **approved** files. **Out of scope for this work (recorded so they aren't lost; whatever's easiest, UX/product decide later):** + - Renaming "Reviewer key" → role-neutral. - Forced regeneration, key-loss responsibility, UX regeneration/access-split questions. - Legacy/beta data handling, concurrent-study key association. @@ -151,6 +169,7 @@ If product chooses **all-or-nothing** approve/reject instead, the existing job-l **TOA** — `src/app/api/job/encrypt-results.ts` (encryption), `src/app/management-app-requests.ts` (fetches org public keys). **MA** + - `src/app/api/job/[jobId]/results/route.ts` — **ingest point** (decompose here) - `src/app/api/job/[jobId]/keys/route.ts` — public-keys endpoint - `src/server/storage.ts` — `storeStudyEncryptedResultsFile` (zip today), `storeApprovedJobFile` (plaintext — to remove) diff --git a/src/hooks/use-workspace-launcher.tsx b/src/hooks/use-workspace-launcher.tsx index 8317c1668..08704a774 100644 --- a/src/hooks/use-workspace-launcher.tsx +++ b/src/hooks/use-workspace-launcher.tsx @@ -119,7 +119,7 @@ export function useWorkspaceLauncher({ studyId, onSuccess }: UseWorkspaceLaunche } onSuccessRef.current?.() } - }, [workspaceQuery.data, workspaceQuery.error, launchComplete]) + }, [workspaceQuery.data, workspaceQuery.error, launchComplete, studyId]) const launchWorkspace = useCallback(() => { setError(null) diff --git a/src/server/actions/user-keys.actions.test.ts b/src/server/actions/user-keys.actions.test.ts index f4fb6eb20..66dfd38bb 100644 --- a/src/server/actions/user-keys.actions.test.ts +++ b/src/server/actions/user-keys.actions.test.ts @@ -6,7 +6,6 @@ import { updateReviewerPublicKeyAction, } from './user-keys.actions' import { db } from '@/database' -import logger from '@/lib/logger' vi.mock('@/server/events', async (importOriginal) => ({ ...(await importOriginal()), @@ -15,17 +14,15 @@ vi.mock('@/server/events', async (importOriginal) => ({ })) describe('User Keys Actions', () => { - it('only allows reviewer users to access the actions', async () => { - await mockSessionWithTestData({ orgType: 'lab' }) - vi.spyOn(logger, 'error').mockImplementation(() => undefined) + it('allows lab researchers to access the actions, since they now hold keys too', async () => { + const { user } = await mockSessionWithTestData({ orgType: 'lab' }) + await db.deleteFrom('userPublicKey').where('userId', '=', user.id).execute() + const publicKey = Buffer.from('lab-public-key') + const fingerprint = 'lab-fingerprint' + await db.insertInto('userPublicKey').values({ userId: user.id, publicKey, fingerprint }).execute() - try { - actionResult(await getReviewerPublicKeyAction()) - expect.fail('Expected an error to be thrown') - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toMatch(/cannot view ReviewerKey/) - } + const result = actionResult(await getReviewerPublicKeyAction()) + expect(result?.fingerprint).toEqual(fingerprint) }) it('getReviewerPublicKeyAction returns the public key for the current user', async () => { diff --git a/src/server/actions/user.actions.ts b/src/server/actions/user.actions.ts index d6166ec77..b170caa60 100644 --- a/src/server/actions/user.actions.ts +++ b/src/server/actions/user.actions.ts @@ -14,7 +14,7 @@ export const onUserSignInAction = new Action('onUserSignInAction').handler(async throw new Error('Failed to establish session') } onUserLogIn({ userId: session.user.id }) - if (Object.values(session.orgs).some((org) => orgNeedsKey(org))) { + if (Object.values(session.orgs).some(orgNeedsKey)) { const publicKey = await getReviewerPublicKey(session.user.id) if (!publicKey) { return { redirectToReviewerKey: true } From ff8fd26027f4aef8f6db4b6706204e5791230c2a Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Wed, 3 Jun 2026 16:26:27 -0400 Subject: [PATCH 04/45] Fix test --- .../invitation/[inviteId]/create-account.action.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/account/invitation/[inviteId]/create-account.action.test.ts b/src/app/account/invitation/[inviteId]/create-account.action.test.ts index 58d006123..d4fb48ca6 100644 --- a/src/app/account/invitation/[inviteId]/create-account.action.test.ts +++ b/src/app/account/invitation/[inviteId]/create-account.action.test.ts @@ -229,7 +229,10 @@ describe('Create Account Actions', () => { }) it('onJoinTeamAccountAction returns needsReviewerKey true for lab org without existing key', async () => { - const { user } = await insertTestUser({ org }) + // insertTestUser only auto-creates a key for enclave-org users, so seed this user in a + // lab org to keep them key-less and exercise the lab researcher gate. + const existingLabOrg = await insertTestOrg({ slug: faker.string.alpha(10), type: 'lab' }) + const { user } = await insertTestUser({ org: existingLabOrg }) const labOrg = await insertTestOrg({ slug: faker.string.alpha(10), type: 'lab' }) From 014af9f18010650cadde4b79c04a5045ea4e9903 Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Thu, 4 Jun 2026 10:52:12 -0400 Subject: [PATCH 05/45] Add keys for test researchers --- .../seeds/1743608138837_test_users.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/database/seeds/1743608138837_test_users.ts b/src/database/seeds/1743608138837_test_users.ts index b90632e4c..3b8bf0f10 100644 --- a/src/database/seeds/1743608138837_test_users.ts +++ b/src/database/seeds/1743608138837_test_users.ts @@ -227,4 +227,28 @@ export async function seed(db: Kysely): Promise { await db.insertInto('orgUser').values({ userId, orgId, isAdmin: membership.isAdmin }).execute() } } + + // Every test user belongs to an org that requires an encryption key (enclave or lab), so the + // RequireReviewerKey gate redirects them to key generation until one exists. Seed a placeholder + // key so e2e flows land on the real dashboard; decryption isn't exercised here, so the bytes + // only need to exist. + for (const user of TEST_USERS) { + const userId = userIdByRole.get(user.role)! + + const existing = await db + .selectFrom('userPublicKey') + .select('id') + .where('userId', '=', userId) + .executeTakeFirst() + if (existing) continue + + await db + .insertInto('userPublicKey') + .values({ + userId, + publicKey: Buffer.from(`e2e-test-public-key-${user.role}`), + fingerprint: `e2e-test-fingerprint-${user.role}`, + }) + .execute() + } } From 1b112c89be5a75547279ece0ef6bc9895128fe19 Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Thu, 4 Jun 2026 11:36:54 -0400 Subject: [PATCH 06/45] Update and compress notes --- results-encryption-design-notes.md | 138 ++++++++++++++++------------- 1 file changed, 76 insertions(+), 62 deletions(-) diff --git a/results-encryption-design-notes.md b/results-encryption-design-notes.md index 219d68545..fea04d534 100644 --- a/results-encryption-design-notes.md +++ b/results-encryption-design-notes.md @@ -1,49 +1,50 @@ # Results Encryption for Researchers — Design Notes -> Working synthesis from a grilling session against the codebase (management-app + trusted-output-app). -> Status: **draft for review.** Decisions locked so far are marked ✅; open items are in §8. -> **Scope for this work: Card 72 (prerequisite) → Card 71 only.** Happy path only. Cards 73 / 74 (regeneration, recovery) are explicitly out of scope here. +> Working synthesis from grilling session against codebase (management-app + trusted-output-app). +> Status: **draft for review.** Locked decisions marked ✅; open items in §8. +> **Scope: Card 72 (prerequisite) → Card 71 only.** Happy path only. Cards 73/74 (regeneration, recovery) explicitly out of scope. +> **Progress: Card 72 DONE (PR #764). Card 71 not started — next session.** --- ## 1. The problem -The secure enclave's output (result files + run logs) is delivered to **DO reviewers** encrypted, but the same output is currently exposed to **researchers in plaintext**. Reviewers decrypt client-side with a private key generated at signup; researchers get an unencrypted copy. +Enclave output (result files + run logs) delivered to **DO reviewers** encrypted, but same output currently exposed to **researchers in plaintext**. Reviewers decrypt client-side with private key generated at signup; researchers get unencrypted copy. -Goal: researchers must _also_ decrypt with their own key, so output is never available in plaintext on the researcher side. +Goal: researchers must _also_ decrypt with own key, so output never available plaintext on researcher side. --- -## 2. How the encryption works today (the "post office" analogy) +## 2. How encryption works today (the "post office" analogy) -| Concept (analogy) | Implementation | Where in code | -| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | -| **Results key** — a padlock whose one key both locks and unlocks | A random **AES-256-CBC** symmetric key, generated **per file** (+ a per-file random IV) | `si-encryption/job-results/writer.ts:19,22` | -| **Lock the results** | Encrypt each file's bytes with its AES key | `writer.ts:25` | -| **A PO box for each person** | The AES key is "wrapped" (encrypted) with each recipient's **RSA-OAEP-4096 public key** | `writer.ts:31-35,58-84` | -| **The PO box number** | The recipient's **fingerprint** = SHA-256 of their public key | `util/keypair.ts:105-111` | -| **The post office (directory of boxes)** | `manifest.json` — maps `fingerprint → { crypt }`, plus per-file `iv`, `path`, `bytes` | `writer.ts:39-44`; types in `job-results/types.ts` | -| **Ship it** | TOA zips `manifest.json` + encrypted bodies, POSTs to MA | TOA `src/app/api/job/encrypt-results.ts` | -| **Open the box** | Browser fetches the zip, finds its PO box by fingerprint, unwraps the AES key with its private key, decrypts | `hooks/use-decrypt-files.ts`, `job-results/reader.ts:95-109` | +| Concept (analogy) | Implementation | Where in code | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | +| **Results key** — padlock whose one key both locks and unlocks | Random **AES-256-CBC** symmetric key, generated **per file** (+ per-file random IV) | `si-encryption/job-results/writer.ts:19,22` | +| **Lock the results** | Encrypt each file's bytes with its AES key | `writer.ts:25` | +| **PO box per person** | AES key "wrapped" (encrypted) with each recipient's **RSA-OAEP-4096 public key** | `writer.ts:31-35,58-84` | +| **PO box number** | Recipient's **fingerprint** = SHA-256 of public key | `util/keypair.ts:105-111` | +| **Post office (directory of boxes)** | `manifest.json` — maps `fingerprint → { crypt }`, plus per-file `iv`, `path`, `bytes` | `writer.ts:39-44`; types in `job-results/types.ts` | +| **Ship it** | TOA zips `manifest.json` + encrypted bodies, POSTs to MA | TOA `src/app/api/job/encrypt-results.ts` | +| **Open the box** | Browser fetches zip, finds its PO box by fingerprint, unwraps AES key with private key, decrypts | `hooks/use-decrypt-files.ts`, `job-results/reader.ts:95-109` | **Key types, ELI5** -- **RSA keypair (asymmetric)** — public key = a mail slot anyone can drop into; private key = the only physical key that opens the box. Mathematically bound; you can't edit one to fit the other. +- **RSA keypair (asymmetric)** — public key = mail slot anyone can drop into; private key = only physical key that opens box. Mathematically bound; can't edit one to fit other. - **AES key (symmetric)** — one key locks and unlocks. Fast; used for big file bodies. One per file. -- **IV (initialization vector)** — a per-file random value, **not secret**, but **required** to decrypt (AES-CBC). Must always travel with its ciphertext. -- **Fingerprint** — the nameplate on the box; SHA-256 of the public key. -- **Envelope encryption** — lock the big file once with a fast AES padlock, then put a _copy_ of that AES key into a personal RSA-locked box per recipient. The manifest is the directory of boxes. +- **IV (initialization vector)** — per-file random value, **not secret**, but **required** to decrypt (AES-CBC). Must travel with ciphertext. +- **Fingerprint** — nameplate on box; SHA-256 of public key. +- **Envelope encryption** — lock big file once with fast AES padlock, then put _copy_ of that AES key into personal RSA-locked box per recipient. Manifest = directory of boxes. **Where keys live** -- **Public key** → DB `user_public_key` (`public_key` bytea, `fingerprint` text, `UNIQUE(user_id)`). Schema is already **role-agnostic**. -- **Private key** → user downloads a PEM; **never stored server-side**. This is the core guarantee: _the server cannot read results._ +- **Public key** → DB `user_public_key` (`public_key` bytea, `fingerprint` text, `UNIQUE(user_id)`). Schema already **role-agnostic**. +- **Private key** → user downloads PEM; **never stored server-side**. Core guarantee: _server cannot read results._ --- ## 3. The fact that reframes the work -Approve currently stores **plaintext**. `approveStudyJobFilesAction` (`src/server/actions/study-job.actions.ts:12-45`) takes the DO's already-decrypted contents and writes them to `results/approved/` via `storeApprovedJobFile`. Researchers read those plaintext files. So this project is _"stop writing the decrypted copy, and give researchers their own PO boxes instead."_ +Approve currently stores **plaintext**. `approveStudyJobFilesAction` (`src/server/actions/study-job.actions.ts:12-45`) takes DO's already-decrypted contents and writes them to `results/approved/` via `storeApprovedJobFile`. Researchers read those plaintext files. Project = _"stop writing decrypted copy, give researchers own PO boxes instead."_ --- @@ -51,27 +52,27 @@ Approve currently stores **plaintext**. `approveStudyJobFilesAction` (`src/serve ### ✅ 4.1 One key per user, not per role (Card 72) -A single keypair serves a person as reviewer _and_ researcher. Encryption already takes all org public keys regardless of role (`writer.ts`), so the same key works for both hats. Consequence: key handling is user-level, not role-level. +Single keypair serves person as reviewer _and_ researcher. Encryption already takes all org public keys regardless of role (`writer.ts`), so same key works for both hats. Consequence: key handling is user-level, not role-level. ### ✅ 4.2 Re-wrap, not re-encrypt (core mechanic) -Granting a researcher access does **not** re-encrypt file bodies. The file's existing AES key is **wrapped again** for the researcher's public key → a new PO box. The ciphertext and IV never change. +Granting researcher access does **not** re-encrypt file bodies. File's existing AES key **wrapped again** for researcher's public key → new PO box. Ciphertext and IV never change. -- The DO's browser already holds the raw AES key (it just decrypted to review), so it wraps that key for each researcher and uploads new boxes. **The server never sees plaintext.** -- A regenerated key cannot open old boxes — that impossibility _is_ the security. (Recovery/regeneration handling = out of scope here; see §8.) +- DO's browser already holds raw AES key (just decrypted to review), so it wraps that key for each researcher and uploads new boxes. **Server never sees plaintext.** +- Regenerated key cannot open old boxes — that impossibility _is_ the security. (Recovery/regeneration = out of scope; see §8.) ### ✅ 4.3 Option B — decompose on ingest -When MA receives TOA's zip, it **unzips once on the server** and stores the pieces separately: +When MA receives TOA's zip, **unzips once on server** and stores pieces separately: -- each **encrypted body** → its own S3 object +- each **encrypted body** → own S3 object - per-file **metadata (iv, path, bytes)** + per-recipient **wrapped keys** → Postgres -The manifest is **plaintext metadata** (IVs/fingerprints/wrapped keys are not secret), and the server already parses it today with an empty key just to list files (`study-job.actions.ts:72-76`). So decompose-on-ingest needs **no private keys and touches no plaintext** — the zero-plaintext-on-server guarantee holds. +Manifest is **plaintext metadata** (IVs/fingerprints/wrapped keys not secret), and server already parses it today with empty key to list files (`study-job.actions.ts:72-76`). Decompose-on-ingest needs **no private keys, touches no plaintext** — zero-plaintext-on-server guarantee holds. -Postgres is the **single source of truth for PO boxes** (both DO and researcher). The zip is reduced to a transient ingest format; the read path no longer unzips. +Postgres = **single source of truth for PO boxes** (DO and researcher). Zip reduced to transient ingest format; read path no longer unzips. -**Proposed schema (per file — a zip can hold many files, so do NOT model "zip = one row"):** +**Proposed schema (per file — zip can hold many files, do NOT model "zip = one row"):** ``` job_file (id, study_job_id, path, bytes, iv, blob_location) -- one row per file; IV lives here in Option B @@ -79,19 +80,19 @@ job_file_key (job_file_id, fingerprint, crypt) -- one row pe UNIQUE(job_file_id, fingerprint) ``` -**Read path (DO and researcher, identical):** fetch encrypted body from S3 + `iv` and the row matching _my_ fingerprint from `job_file_key` → decrypt client-side. `SELECT crypt FROM job_file_key WHERE fingerprint = ? AND job_file_id IN (...)` (one box per file). +**Read path (DO and researcher, identical):** fetch encrypted body from S3 + `iv` and row matching _my_ fingerprint from `job_file_key` → decrypt client-side. `SELECT crypt FROM job_file_key WHERE fingerprint = ? AND job_file_id IN (...)` (one box per file). ### ✅ 4.4 Re-wrap at approve, client-side (Card 71) -On **Approve**, the DO's browser: +On **Approve**, DO's browser: -1. Fetches the **lab's** public keys — members of `study.submittedByOrgId` who have a key. +1. Fetches **lab's** public keys — members of `study.submittedByOrgId` who have key. 2. Wraps each approved file's AES key (already in memory from decrypting) to each researcher public key. -3. POSTs the new PO boxes; server inserts `job_file_key` rows in one transaction (atomic, idempotent via the unique constraint). +3. POSTs new PO boxes; server inserts `job_file_key` rows in one transaction (atomic, idempotent via unique constraint). ### ✅ 4.5 Remove plaintext storage -Drop the `storeApprovedJobFile` plaintext write. Researchers read the encrypted body + their PO box and decrypt client-side, using the **same** path as reviewers. +Drop `storeApprovedJobFile` plaintext write. Researchers read encrypted body + their PO box and decrypt client-side, using **same** path as reviewers. --- @@ -99,64 +100,77 @@ Drop the `storeApprovedJobFile` plaintext write. Researchers read the encrypted **Reassuring:** decompose-on-ingest and re-wrap both operate only on encrypted bytes + non-secret metadata. No server-side plaintext, no server-side private keys. -**Hidden cost of Option B — shared library change.** The current decrypt path is zip-shaped end-to-end: `ResultsReader`'s constructor takes a zip blob and reads `manifest.json` from inside it (`reader.ts:17,45-49`). Option B has no zip at read time, so we must: +**Hidden cost of Option B — shared library change.** Current decrypt path is zip-shaped end-to-end: `ResultsReader`'s constructor takes zip blob and reads `manifest.json` from inside it (`reader.ts:17,45-49`). Option B has no zip at read time, so must: -- Add a **zip-free decrypt primitive** to `si-encryption`, e.g. `decryptFile(encryptedBody, iv, wrappedKeyForMyFingerprint, privateKey) → plaintext`. The guts already exist (`readFile` does `decryptKeyWithPrivateKey` + `decryptData`, `reader.ts:95-109`) — they just need to be unwelded from zip iteration. -- Have the reader **expose the raw AES key** per file (today `readFile` computes it but only returns the decrypted body) — **re-wrap needs that key**. -- Rewrite the client hook (`use-decrypt-files.ts` / `use-encrypted-files-panel.ts`) to fetch body-from-S3 + iv/crypt-from-PG instead of unzipping. +- Add **zip-free decrypt primitive** to `si-encryption`, e.g. `decryptFile(encryptedBody, iv, wrappedKeyForMyFingerprint, privateKey) → plaintext`. Guts already exist (`readFile` does `decryptKeyWithPrivateKey` + `decryptData`, `reader.ts:95-109`) — just need unwelding from zip iteration. +- Have reader **expose raw AES key** per file (today `readFile` computes it but returns only decrypted body) — **re-wrap needs that key**. +- Rewrite client hook (`use-decrypt-files.ts` / `use-encrypted-files-panel.ts`) to fetch body-from-S3 + iv/crypt-from-PG instead of unzipping. -This is a **shared-library change, not MA-only.** +**Shared-library change, not MA-only.** -### Card 72 — researcher key generation (prerequisite) +### Card 72 — researcher key generation (prerequisite) — ✅ DONE (PR #764) -The `user_public_key` schema is already role-agnostic; only the **gate** is enclave-scoped. To include lab-org users: +`user_public_key` schema already role-agnostic; only **gate** was enclave-scoped. Widened to include lab-org users. -- `src/app/account/invitation/[inviteId]/create-account.action.ts:155` — `org.type === 'enclave'` → include `'lab'` -- `src/components/require-reviewer-key.tsx:17` + login check in `src/server/actions/user.actions.ts` — `isEnclaveOrg` → include lab -- `src/lib/permissions.ts:67-70` — permit `ReviewerKey` for lab orgs too -- Naming: keep the existing `ReviewerKey` / `require-reviewer-key` / `set/updateReviewerPublicKeyAction` names as-is. **Renaming is out of scope for this work** — just widen the gate. +**What shipped:** + +- New predicate `orgNeedsKey(org)` in `src/lib/types.ts` = `isEnclaveOrg(org) || isLabOrg(org)`. One named concept, four call-sites — chosen over inlining `|| isLabOrg` (drift risk) and over coupling to reviewer-only concept (`usersReviewerOrgIds` stays enclave-only for approve/reject/review). +- Four gates flipped `isEnclaveOrg` → `orgNeedsKey`: + - `src/lib/permissions.ts:67` — `if (orgs.some(orgNeedsKey))` permits view/update `ReviewerKey` + - `src/components/require-reviewer-key.tsx:17` — redirect guard + - `src/server/actions/user.actions.ts:17` — sign-in redirect + - `src/app/account/invitation/[inviteId]/create-account.action.ts:155` — invite `needsReviewerKey` +- Names kept (`ReviewerKey` / `require-reviewer-key` / `set/updateReviewerPublicKeyAction`) — rename still out of scope. +- Researcher now: generates keypair client-side at `/account/keys`, downloads PEM (server never stores private key), public key + fingerprint → `userPublicKey`. Reuses reviewer path entirely; zero new storage code. + +**Test fallout fixed (same PR):** + +- `user-keys.actions.test.ts` — flipped stale "lab user `cannot view ReviewerKey`" assertion to assert access. +- `create-account.action.test.ts` — lab "needsReviewerKey true" case must seed user in **lab** org; `insertTestUser` auto-creates key only for **enclave**-org users (`tests/unit.helpers.tsx:215`), so enclave-seeded user already had key and broke assertion. +- e2e seed `src/database/seeds/1743608138837_test_users.ts` — seed placeholder `userPublicKey` for all test users. `RequireReviewerKey` now gates lab researchers too; key-less seeded users were redirected off dashboard, detaching `new-study` button mid-click → all study-flow specs timed out. +- Unrelated pre-existing lint warning fixed to unblock CI: `studyId` added to `useEffect` dep array in `src/hooks/use-workspace-launcher.tsx:122`. ### Card 71 — Option B steps 1. **Ingest** (`src/app/api/job/[jobId]/results/route.ts`, ~line 52, currently calls `storeStudyEncryptedResultsFile` → `results/encrypted-results.zip`): replace store-zip-as-is with decompose → S3 bodies + `job_file`/`job_file_key` rows (including DO boxes), one transaction. 2. **Decrypt path:** new zip-free primitive + client-hook rewrite (body + iv + crypt-from-PG). 3. **Approve:** drop plaintext write; DO browser re-wraps approved files' AES keys for lab researchers and POSTs `job_file_key` inserts. -4. **Researcher view:** reuse the DO decrypt path. +4. **Researcher view:** reuse DO decrypt path. --- ## 6. Per-file approve/reject (confirmed from code) -Today's behavior is **asymmetric**: +Today's behavior **asymmetric**: -- **Approve = per-file subset.** Checkbox per result file; all auto-selected on decrypt, reviewer can uncheck (`use-encrypted-files-panel.ts:39,85-86,94-101`). The action takes the chosen subset: `approveStudyJobFilesAction.params({ jobFiles: z.array(...) })` (`study-job.actions.ts:12-45`); button passes `jobFiles: decryptedResults` (`job-review-buttons.tsx:45`). +- **Approve = per-file subset.** Checkbox per result file; all auto-selected on decrypt, reviewer can uncheck (`use-encrypted-files-panel.ts:39,85-86,94-101`). Action takes chosen subset: `approveStudyJobFilesAction.params({ jobFiles: z.array(...) })` (`study-job.actions.ts:12-45`); button passes `jobFiles: decryptedResults` (`job-review-buttons.tsx:45`). - **Reject = whole job.** `rejectStudyJobFilesAction` takes no file list — flips job to `FILES-REJECTED` (`study-job.actions.ts:47-70`). -**Important catch:** there is **no per-file approval column.** Approval is tracked only at the **job level** (`jobStatusChange.status`). "This file is approved" is encoded _implicitly_ today by the existence of a plaintext `APPROVED-*` copy — which Option B (§4.5) **deletes**. So if per-file approve is kept, approval state needs a new home: +**Important catch:** **no per-file approval column.** Approval tracked only at **job level** (`jobStatusChange.status`). "This file is approved" encoded _implicitly_ today by existence of plaintext `APPROVED-*` copy — which Option B (§4.5) **deletes**. So if per-file approve kept, approval state needs new home: -- **(a)** The researcher `job_file_key` rows _are_ the signal — re-wrap only approved files; "has a researcher PO box" = "approved & shared." No new column. (Recommended — the rows are already per-file and already being inserted.) -- **(b)** An explicit `approved` flag/status on `job_file`. Cleaner/queryable, decouples "approved" from "shared," but adds a column. +- **(a)** Researcher `job_file_key` rows _are_ the signal — re-wrap only approved files; "has researcher PO box" = "approved & shared." No new column. (Recommended — rows already per-file and already being inserted.) +- **(b)** Explicit `approved` flag/status on `job_file`. Cleaner/queryable, decouples "approved" from "shared," but adds column. -If product chooses **all-or-nothing** approve/reject instead, the existing job-level status suffices, re-wrap covers all files, and no per-file state is needed. +If product chooses **all-or-nothing** approve/reject, existing job-level status suffices, re-wrap covers all files, no per-file state needed. -> **Open product decision (Phil):** support per-file approve/reject, or approve/reject the whole artifact set together? This decides whether (a)/(b) is needed. Re-wrap must only ever cover **approved** files — rejected outputs must never get researcher PO boxes. +> **Open product decision (Phil):** support per-file approve/reject, or approve/reject whole artifact set together? Decides whether (a)/(b) needed. Re-wrap must only ever cover **approved** files — rejected outputs must never get researcher PO boxes. --- ## 7. The cards (this work = 72 → 71 only) -- ✅ **Card 72 — researcher key generation (prerequisite for 71).** Widen the key-gen gate to lab orgs; decide naming. -- ✅ **Card 71 — enable encryption for researchers.** Decompose on ingest (Option B); re-wrap approved files at approve; remove plaintext; researcher decrypts client-side. -- ⛔ **Card 73 — key re-generation flow.** Out of scope here. -- ⛔ **Card 74 — user-led re-encryption / recovery.** Out of scope here. +- ✅ **Card 72 — researcher key generation (prerequisite for 71).** DONE — PR #764. Gate widened to lab orgs via `orgNeedsKey`; names kept. +- ⬜ **Card 71 — enable encryption for researchers.** NOT STARTED (next session). Decompose on ingest (Option B); re-wrap approved files at approve; remove plaintext; researcher decrypts client-side. +- ⛔ **Card 73 — key re-generation flow.** Out of scope. +- ⛔ **Card 74 — user-led re-encryption / recovery.** Out of scope. --- ## 8. Open questions -1. **Per-file vs all-together approve/reject** (§6) — **NOTE FOR PHIL: finalize with team/PMs.** Should reviewers approve/reject result files individually (per-file subset, as approve works today) or approve/reject the whole artifact set together? This is the one open decision that affects implementation: per-file requires a new home for approval state (§6 a/b) since Option B removes the implicit plaintext-copy signal; all-together needs none. Re-wrap must only ever cover **approved** files. +1. **Per-file vs all-together approve/reject** (§6) — **NOTE FOR PHIL: finalize with team/PMs.** Should reviewers approve/reject result files individually (per-file subset, as approve works today) or approve/reject whole artifact set together? One open decision affecting implementation: per-file requires new home for approval state (§6 a/b) since Option B removes implicit plaintext-copy signal; all-together needs none. Re-wrap must only ever cover **approved** files. -**Out of scope for this work (recorded so they aren't lost; whatever's easiest, UX/product decide later):** +**Out of scope (recorded so not lost; UX/product decide later):** - Renaming "Reviewer key" → role-neutral. - Forced regeneration, key-loss responsibility, UX regeneration/access-split questions. @@ -174,7 +188,7 @@ If product chooses **all-or-nothing** approve/reject instead, the existing job-l - `src/app/api/job/[jobId]/keys/route.ts` — public-keys endpoint - `src/server/storage.ts` — `storeStudyEncryptedResultsFile` (zip today), `storeApprovedJobFile` (plaintext — to remove) - `src/server/actions/study-job.actions.ts` — `approve/rejectStudyJobFilesAction`; server-side `ResultsReader.listFiles` precedent (:72-76) -- `src/server/db/queries.ts` — `getOrgIdForJobId` (enclave org), `getOrgPublicKeys` (INNER JOIN = has-key filter); **needs a new lab-org query keyed on `submittedByOrgId`** +- `src/server/db/queries.ts` — `getOrgIdForJobId` (enclave org), `getOrgPublicKeys` (INNER JOIN = has-key filter); **needs new lab-org query keyed on `submittedByOrgId`** - `src/server/actions/user-keys.actions.ts` — public-key write path (role-agnostic) - `src/lib/permissions.ts:67-70` — key-gen gate (enclave-only today) - `src/components/require-reviewer-key.tsx`, `src/app/account/invitation/[inviteId]/create-account.action.ts:155`, `src/server/actions/user.actions.ts` — key-gen onboarding gates From f4ded752195cfefcff13a06e83b6ee8263bb944c Mon Sep 17 00:00:00 2001 From: Chris Bendel Date: Thu, 4 Jun 2026 15:24:06 -0400 Subject: [PATCH 07/45] Test CI --- results-encryption-design-notes.md | 87 ++++++++++++++++--- .../[studyId]/review/job-review-buttons.tsx | 6 +- .../[studyId]/review/study-results.test.tsx | 32 ++++--- .../[studyId]/review/study-review-buttons.tsx | 10 ++- src/components/encrypted-files-panel.test.tsx | 19 ++-- src/components/job-results.tsx | 62 ++++++++++--- src/hooks/use-decrypt-files.ts | 6 +- src/hooks/use-encrypted-files-panel.ts | 64 +++++++------- src/lib/re-encrypt-results.ts | 29 +++++++ .../actions/results-reencryption.test.ts | 66 ++++++++++++++ src/server/actions/study-job.actions.test.ts | 3 +- src/server/actions/study-job.actions.ts | 48 ++++++++-- src/server/actions/study.actions.ts | 3 +- src/server/db/queries.ts | 26 ++++++ src/server/storage.ts | 3 +- 15 files changed, 374 insertions(+), 90 deletions(-) create mode 100644 src/lib/re-encrypt-results.ts create mode 100644 src/server/actions/results-reencryption.test.ts diff --git a/results-encryption-design-notes.md b/results-encryption-design-notes.md index fea04d534..e1978b21d 100644 --- a/results-encryption-design-notes.md +++ b/results-encryption-design-notes.md @@ -3,7 +3,14 @@ > Working synthesis from grilling session against codebase (management-app + trusted-output-app). > Status: **draft for review.** Locked decisions marked ✅; open items in §8. > **Scope: Card 72 (prerequisite) → Card 71 only.** Happy path only. Cards 73/74 (regeneration, recovery) explicitly out of scope. -> **Progress: Card 72 DONE (PR #764). Card 71 not started — next session.** +> **All phases land on one branch/PR (`researcher-encrypted-results`).** Not split per-card. +> **Progress: Card 72 code complete on branch (not yet merged to main). Card 71 in progress (re-encrypt approach).** +> **Testing: local e2e unreliable — rely on CI for e2e coverage. Unit tests still run/pass locally.** +> +> ## ⚠️ APPROACH PIVOT (supersedes §3–§5, §10 below) +> The detailed "Proposed implementation" (decompose the post office into Postgres + **re-wrap** PO boxes for researchers — §4.2, §4.3, Option B) was **built and then reverted**. We pivoted to **re-encrypt at approve** (Phil + NS in the living doc). See **§11** for the chosen design. Sections §3–§5/§10 are kept for rationale/history but are NOT the implementation. +> +> **Why pivot:** re-wrap forced encryption-library changes + new schema (`study_job_file_key`, decompose-on-ingest) + a full read-path rewrite. Re-encrypt needs **zero lib changes, no schema** — the DO browser already holds plaintext at review time, so at approve it re-encrypts the approved files for reviewers+researchers using the existing `ResultsWriter`. It also matches the living doc's own Card 74 recovery mechanic ("remove old lock, put new lock" = re-encrypt), so it's the better foundation for the documented future work. The one thing re-wrap/boxes does better — granular **revocation** ("user leaves the lab") — is noted in code at the approve site as a future consideration. --- @@ -72,15 +79,22 @@ Manifest is **plaintext metadata** (IVs/fingerprints/wrapped keys not secret), a Postgres = **single source of truth for PO boxes** (DO and researcher). Zip reduced to transient ingest format; read path no longer unzips. -**Proposed schema (per file — zip can hold many files, do NOT model "zip = one row"):** +**Schema (DECIDED: extend existing `study_job_file`, do NOT add a new `job_file` table).** `study_job_file` already models one-row-per-file with `source_id` linkage; reuse it to avoid duplicating the file concept and migrating references. ``` -job_file (id, study_job_id, path, bytes, iv, blob_location) -- one row per file; IV lives here in Option B -job_file_key (job_file_id, fingerprint, crypt) -- one row per recipient-per-file = a PO box - UNIQUE(job_file_id, fingerprint) +study_job_file -- EXISTING (1750186286777). Add nullable: iv text + path = S3 object key of the body (existing invariant across all 12 consumers) + name = logical/inner filename + (NO blob_location — would be redundant with path; dropped after consumer audit) +study_job_file_key (id, study_job_file_id → study_job_file.id, fingerprint, crypt, created_at) + UNIQUE(study_job_file_id, fingerprint) -- one row per recipient-per-file = a PO box ``` -**Read path (DO and researcher, identical):** fetch encrypted body from S3 + `iv` and row matching _my_ fingerprint from `job_file_key` → decrypt client-side. `SELECT crypt FROM job_file_key WHERE fingerprint = ? AND job_file_id IN (...)` (one box per file). +**Model inversion:** today 1 `study_job_file` row = 1 zip holding many files (client gets whole blob + `ResultsReader.listFiles()`). Option B: **1 row = 1 decomposed file** (`path`=S3 key of body, `iv` on row, wrapped keys in `study_job_file_key`). + +**Coupling:** ingest (writes decomposed rows) and `fetchEncryptedJobFilesAction` + `use-decrypt-files`/`use-encrypted-files-panel` share the row contract. They must change **together** — ingest cannot land alone without breaking the review read path. + +**Read path (DO and researcher, identical):** fetch encrypted body from S3 (`path`) + `iv` and row matching _my_ fingerprint from `study_job_file_key` → `decryptFile({encryptedBody, iv, crypt, privateKey})`. `SELECT crypt FROM study_job_file_key WHERE fingerprint = ? AND study_job_file_id IN (...)` (one box per file). ### ✅ 4.4 Re-wrap at approve, client-side (Card 71) @@ -132,10 +146,29 @@ Drop `storeApprovedJobFile` plaintext write. Researchers read encrypted body + t ### Card 71 — Option B steps -1. **Ingest** (`src/app/api/job/[jobId]/results/route.ts`, ~line 52, currently calls `storeStudyEncryptedResultsFile` → `results/encrypted-results.zip`): replace store-zip-as-is with decompose → S3 bodies + `job_file`/`job_file_key` rows (including DO boxes), one transaction. -2. **Decrypt path:** new zip-free primitive + client-hook rewrite (body + iv + crypt-from-PG). -3. **Approve:** drop plaintext write; DO browser re-wraps approved files' AES keys for lab researchers and POSTs `job_file_key` inserts. -4. **Researcher view:** reuse DO decrypt path. +0. ✅ **Migration** (MA): extend `study_job_file` (`iv` text, `bytes` integer — both nullable; NO blob_location, redundant with `path`) + new `study_job_file_key(study_job_file_id, fingerprint, crypt, created_at)` UNIQUE(study_job_file_id, fingerprint). `1780000000000_study_job_file_keys.ts`. Types hand-stubbed in `src/database/types.ts` (CI regenerates — local DB/docker down). +0b. ✅ **si-encryption primitives** (encryption repo): added zip-free `job-results/crypto.ts` (`unwrapAesKey` exposes raw AES bytes, `wrapAesKey` re-wrap, `decryptFileBody`, `decryptFile`) + `job-results/decompose.ts` (`decomposeResultsZip`). Reader/writer refactored to call primitives (no behavior change). 9/9 tests incl. re-wrap round-trip. **Needs push + MA `package.json` hash bump to consume.** (currently local `link:../encryption` override in `pnpm-workspace.yaml`.) +1. ✅ **Ingest** (`storage.ts`): `storeDecomposedEncryptedFiles(info, file, fileType, subdir)` — `decomposeResultsZip` → per-file S3 bodies + `study_job_file`(+iv,bytes) + `study_job_file_key`(DO boxes) rows, one txn. **Results AND logs decomposed** (logs share ResultsWriter format; `results/encrypted/` vs `results/encrypted-logs/`). `storeStudyEncryptedResultsFiles` + `storeStudyEncryptedLogFile` both delegate. Route + `encrypt-and-store-log.ts` callers unchanged (same signatures). +2. ✅ **Lab-pubkey query** `getLabPublicKeysForJob(jobId)` keyed on `study.submittedByOrgId` (queries.ts). +3. ✅ **Decrypt/read path:** `fetchEncryptedJobFilesAction` → per-file shape `{studyJobFileId, fileType, path, iv, crypt(=my box), encryptedBody}` (resolves session fingerprint via `getReviewerPublicKey`, joins `study_job_file_key`). `getStudyJobInfo`/`latestJobForStudyQuery` selects gained `iv`/`bytes`/`id`. `use-decrypt-files` uses `decryptFile`, drops `ResultsReader`, carries `rawAesKey`+`sourceId` on `JobFileInfo`. `use-encrypted-files-panel` rewritten to per-file/option-(a) model (approved=has researcher box via new `fetchSharedFileIdsAction`/`getSharedFileIdsForJob`; one row per `job.files` entry). +4. ✅ **Approve re-wrap:** `approveStudyJobFilesAction` now takes `sharedFiles:[{studyJobFileId, boxes:[{fingerprint,crypt}]}]`, inserts `study_job_file_key` (idempotent `onConflict doNothing`), server-validates fingerprints ∈ lab keys, no plaintext write. `job-review-buttons` fetches lab pubkeys (`fetchLabPublicKeysAction`) + `wrapAesKey` client-side per selected file. **Server never receives `rawAesKey`.** Old `fetchApprovedJobFilesAction` removed. +5. ✅ **Researcher view:** `components/job-results.tsx` (rendered in `study/[studyId]/view/`) now fetches encrypted files + prompts for the researcher's key + decrypts (same `useDecryptFiles` path) instead of reading plaintext APPROVED-* files. + +**Source compiles clean (`tsc`).** REMAINING: +- **Tests** (~15 files): mocks still use the old `EncryptedJobFile` `{blob, metadata}` shape + `fetchApprovedJobFilesAction`; test `job.files` mocks need `id`/`bytes`. New unit tests for ingest decompose, re-wrap approve, shared-file signal, researcher decrypt view. +- **Tangential cleanup:** `study.actions.ts:320` still writes plaintext via `storeApprovedJobFile` in the *proposal/code approval* path (pre-existing "delete me" TODO). Separate from results encryption; evaluate whether it ever carries result files before removing. `storeApprovedJobFile` now used ONLY there. +- **Cross-repo:** push encryption changes, bump MA `package.json` hash, remove `link:../encryption` override. + +## 10. Approval-signal model change (discovered while rewriting panel) — NEEDS DECISION + +Today "approved" is encoded by the existence of an `APPROVED-RESULT` plaintext `study_job_file` row (written by `storeApprovedJobFile`). Option B (§4.5) **deletes that write**, and the new approve only inserts **researcher key boxes**. ⇒ **No `APPROVED-*` rows are created anymore.** Consequences: +- `fetchApprovedJobFilesAction` (filters `APPROVED-RESULT`) returns empty → dead. +- Panel `use-encrypted-files-panel` state model (locked / decrypted / **approved** / not-shared) loses its "approved" + "not-shared" signal. +- There is no plaintext approved copy to view — researcher & post-approval DO both **decrypt the encrypted body** via the same path. + +**This forces §6 option (a):** "file has a researcher PO box" = "approved & shared." No new `approved` column. Panel must derive approved/withheld from `study_job_file_key` rows whose fingerprint ∈ lab-org fingerprints (a new query). Per-file selection (already present) drives which files get boxes. **Decision-independent of per-file-vs-all** (boxes are per-file regardless). + +Remaining UI plane to build on this model: panel rewrite (iterate per-file `job.files` rows, approved=has-researcher-box), approve action rewrite (insert boxes, no plaintext), approved-file viewing (decrypt, no plaintext copy), lab-pubkey fetch action for client wrap. --- @@ -154,6 +187,8 @@ Today's behavior **asymmetric**: If product chooses **all-or-nothing** approve/reject, existing job-level status suffices, re-wrap covers all files, no per-file state needed. > **Open product decision (Phil):** support per-file approve/reject, or approve/reject whole artifact set together? Decides whether (a)/(b) needed. Re-wrap must only ever cover **approved** files — rejected outputs must never get researcher PO boxes. +> +> **NON-BLOCKING for build.** Per-file is the superset: `approveStudyJobFilesAction` already takes a `jobFiles` subset today, so building for per-file matches current behavior; all-or-nothing falls out as "pass all files". Same re-wrap plumbing either way. Only option (b)'s explicit `approved` column is decision-dependent, and that's a cheap **additive** migration later. Everything upstream of approve (schema core, ingest/DO boxes, si-encryption primitive, lab-pubkey query, decrypt rewrite, researcher view) is fully decision-independent. → Build the spine now; defer only approve input-set semantics. --- @@ -196,3 +231,35 @@ If product chooses **all-or-nothing** approve/reject, existing job-level status - review views: `src/app/[orgSlug]/study/[studyId]/review/...`; researcher view: `.../study/[studyId]/view/...` **si-encryption** — `job-results/writer.ts` (envelope encryption), `job-results/reader.ts` (decryption — needs zip-free primitive + expose AES key), `job-results/types.ts`, `util/keypair.ts`. + +--- + +## 11. CHOSEN DESIGN — re-encrypt at approve (Card 71) + +**Zero encryption-lib changes. No schema changes.** Reuses existing `ResultsWriter`/`ResultsReader`. + +**Unchanged:** TOA ingest (stores `encrypted-results.zip` as-is), the DO **review** read path (`fetchEncryptedJobFilesAction` + `use-decrypt-files` + panel decrypt of the original zip with the reviewer key). Card 72 key-gen (researchers already generate keys). + +**Changes — the approve write + approved-file viewing become encrypted:** + +1. **Approve (client, `job-review-buttons`):** the DO browser already holds the decrypted plaintext from review. For each selected approved file it re-encrypts with the **existing `ResultsWriter([...reviewerKeys, ...researcherKeys])`** → an encrypted zip, and sends those to the server. **Plaintext never leaves the browser** (improvement over today, where approve POSTs plaintext). + - Recipients = enclave org keys (reviewers, so DO can still view post-approval) + lab org keys (researchers). New `fetchResultsRecipientKeysAction({jobId})`. +2. **Approve (server, `approveStudyJobFilesAction`):** stores the re-encrypted zips via `storeApprovedJobFile` (now ciphertext, not plaintext). Sets `FILES-APPROVED` + `reviewerId`. No plaintext write. + - **Revocation note (future / Card 74):** re-encrypt cannot cleanly *revoke* a recipient who has left the lab (they may have decrypted already, and the box model is needed for true per-recipient removal). A code comment flags this at the approve site. Out of scope now. +3. **Approved viewing (`fetchApprovedJobFilesAction` → blob + metadata; researcher `components/job-results.tsx` + DO post-approval):** returns the encrypted approved zips; the client decrypts with the viewer's key via the **same** `use-decrypt-files` path. Researchers decrypt with their key; reviewers with theirs (both are recipients). + +**Kept from the reverted build:** `getLabPublicKeysForJob(jobId)` query (lab keys keyed on `study.submittedByOrgId`) — re-added; needed for recipient assembly. + +**Per-file / all-or-nothing (§6):** unaffected — re-encrypt only the selected files. Approval signal stays "APPROVED-* row exists" (unchanged from today), just encrypted content. + +### Status (verified) + +- **Both** results-approve paths re-encrypt now (plaintext fully removed): `approveStudyJobFilesAction` (JobReviewButtons) **and** `approveStudyProposalAction`→`approveJobCode` (StudyReviewButtons / redesign). Shared client helper `src/lib/re-encrypt-results.ts`. +- `tsc` 0 errors, eslint clean. +- Unit tests **run in the `mgmnt-app` docker container** (host can't reach pg; port not published). Green: `encrypted-files-panel`, `study-job.actions`, `study-results`, `study-review-buttons`, `study.actions`, `study-results-redesign`. Run one-file-at-a-time with `NODE_OPTIONS=--max-old-space-size=4096` — running several suites together OOMs the container (exit 137). +- Behavioral test change: approved files now require decryption to view, so panel/study-results tests assert approved **rows by name** (decrypt covered by the dedicated decrypt tests) rather than plaintext download links. +- Revocation limitation (Card 74) noted in code at `approveStudyJobFilesAction` + `approveJobCode`. + +- **Round-trip test added:** `src/server/actions/results-reencryption.test.ts` — real crypto, no mocks. Enclave reviewer (test PEM key) + lab researcher (distinct generated key) on a cross-org study; calls the real `reEncryptApprovedFiles` and asserts both the researcher's own key AND the reviewer's key decrypt the re-encrypted output back to plaintext. (Gotcha: pass `researcherId` to `insertTestStudyJobData` so it doesn't seed a fake-key enclave user that breaks `ResultsWriter`.) + +Cross-repo: nothing — encryption lib untouched. diff --git a/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx b/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx index 1713c08c3..bbdf03897 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx @@ -8,6 +8,7 @@ import { Routes } from '@/lib/routes' import { JobFileInfo, MinimalJobInfo } from '@/lib/types' import { approveStudyJobFilesAction, rejectStudyJobFilesAction } from '@/server/actions/study-job.actions' import type { LatestJobForStudy } from '@/server/db/queries' +import { reEncryptApprovedFiles } from '@/lib/re-encrypt-results' import { Button, Group, Text, useMantineTheme } from '@mantine/core' import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react/dist/ssr' import dayjs from 'dayjs' @@ -42,7 +43,10 @@ export const JobReviewButtons = ({ } if (status === 'FILES-APPROVED') { - await approveStudyJobFilesAction({ orgSlug, jobInfo, jobFiles: decryptedResults }) + // Re-encrypt approved files for reviewers + researchers client-side; only + // ciphertext is sent to the server. + const jobFiles = await reEncryptApprovedFiles(job.studyId, decryptedResults) + await approveStudyJobFilesAction({ orgSlug, jobInfo, jobFiles }) } if (status === 'FILES-REJECTED') { diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx index 8d135b3b0..9512a62e1 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx @@ -14,13 +14,15 @@ import { latestJobForStudy } from '@/server/db/queries' import { ResultsWriter } from 'si-encryption/job-results/writer' import { fingerprintKeyData, pemToArrayBuffer } from 'si-encryption/util' import { type FileType, type StudyJobStatus, type StudyStatus } from '@/database/types' -import { type JobFile } from '@/lib/types' -const mockedApprovedJobFiles: JobFile[] = [ +// Approved files are now re-encrypted (same shape as encrypted files); content is +// viewed by decrypting, so the mock carries metadata, not plaintext contents. +const mockedApprovedJobFiles = [ { - contents: new TextEncoder().encode('title\nhello world').buffer as ArrayBuffer, - path: 'approved.csv', + blob: new Blob(), + sourceId: 'approved-1', fileType: 'APPROVED-RESULT' as FileType, + metadata: [{ path: 'approved.csv', bytes: 17 }], }, ] @@ -76,11 +78,12 @@ describe('View Study Results', () => { const job = await latestJobForStudy(study.id) renderWithProviders() + // Approved files render once per file in the unified table (not duplicated). Content + // is encrypted now — the row lists by name; downloading requires decryption. await waitFor(() => { - expect(screen.getByTestId('download-link')).toBeDefined() + expect(screen.getByText('approved.csv')).toBeDefined() }) - // approved files render once per file in the unified table — not duplicated by the legacy JobResults section - expect(screen.getAllByTestId('download-link')).toHaveLength(1) + expect(screen.getAllByText('approved.csv')).toHaveLength(1) }) it('does not duplicate rows when multiple APPROVED-RESULT files share a fileType', async () => { @@ -110,14 +113,16 @@ describe('View Study Results', () => { vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([ { - contents: new TextEncoder().encode('a').buffer as ArrayBuffer, - path: 'results.csv', + blob: new Blob(), + sourceId: 'approved-1', fileType: 'APPROVED-RESULT' as FileType, + metadata: [{ path: 'results.csv', bytes: 1 }], }, { - contents: new TextEncoder().encode('b').buffer as ArrayBuffer, - path: 'results2.csv', + blob: new Blob(), + sourceId: 'approved-2', fileType: 'APPROVED-RESULT' as FileType, + metadata: [{ path: 'results2.csv', bytes: 1 }], }, ]) @@ -125,10 +130,9 @@ describe('View Study Results', () => { renderWithProviders() await waitFor(() => { - expect(screen.getAllByTestId('download-link')).toHaveLength(2) + expect(screen.getAllByText('results.csv')).toHaveLength(1) + expect(screen.getAllByText('results2.csv')).toHaveLength(1) }) - expect(screen.getAllByText('results.csv')).toHaveLength(1) - expect(screen.getAllByText('results2.csv')).toHaveLength(1) }) it('renders the form to unlock results', async () => { diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx index 3792e0069..487592106 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx @@ -9,6 +9,7 @@ import { rejectStudyProposalAction, type SelectedStudy, } from '@/server/actions/study.actions' +import { reEncryptApprovedFiles } from '@/lib/re-encrypt-results' import { Button, Group, Stack } from '@mantine/core' import { useRouter } from 'next/navigation' import { FC, useState } from 'react' @@ -32,13 +33,18 @@ export const StudyReviewButtons: FC<{ study: SelectedStudy; approvedFiles?: JobF isSuccess, variables: pendingStatus, } = useMutation({ - mutationFn: (status: StudyStatus) => { + mutationFn: async (status: StudyStatus) => { if (status === 'APPROVED') { + // Re-encrypt approved result files for reviewers + researchers client-side; + // the server stores only ciphertext (no plaintext approved copies). + const jobFiles = approvedFiles?.length + ? await reEncryptApprovedFiles(study.id, approvedFiles) + : undefined return approveStudyProposalAction({ orgSlug, studyId: study.id, useTestImage, - jobFiles: approvedFiles, + jobFiles, }) } return rejectStudyProposalAction({ orgSlug, studyId: study.id }) diff --git a/src/components/encrypted-files-panel.test.tsx b/src/components/encrypted-files-panel.test.tsx index d8f76cae8..f9be587ac 100644 --- a/src/components/encrypted-files-panel.test.tsx +++ b/src/components/encrypted-files-panel.test.tsx @@ -436,20 +436,22 @@ describe('EncryptedFilesPanel', () => { ]) vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([ - { contents: new ArrayBuffer(10), path: 'first.csv', fileType: 'APPROVED-RESULT' }, + { + blob: new Blob(), + sourceId: 'approved-1', + fileType: 'APPROVED-RESULT', + metadata: [{ path: 'first.csv', bytes: 1024 }], + }, ]) const latestJob = await latestJobForStudy(study.id) renderWithProviders() + // Shared (green) and withheld (red X) states render from metadata without decrypting. await waitFor(() => { expect(screen.getByText('first.csv')).toBeDefined() expect(screen.getByLabelText('second.csv not shared with researcher')).toBeDefined() }) - - // Withheld file has no View/Download — only the shared file does - expect(screen.getAllByRole('button', { name: 'View' })).toHaveLength(1) - expect(screen.getAllByTestId('download-link')).toHaveLength(1) }) it('shows a red "not shared" X (not a lock icon) for an entire log type withheld after approval', async () => { @@ -492,7 +494,12 @@ describe('EncryptedFilesPanel', () => { ]) vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([ - { contents: new ArrayBuffer(10), path: 'first.csv', fileType: 'APPROVED-RESULT' }, + { + blob: new Blob(), + sourceId: 'approved-1', + fileType: 'APPROVED-RESULT', + metadata: [{ path: 'first.csv', bytes: 1024 }], + }, ]) const latestJob = await latestJobForStudy(study.id) diff --git a/src/components/job-results.tsx b/src/components/job-results.tsx index 18e3e8bbd..000f253d5 100644 --- a/src/components/job-results.tsx +++ b/src/components/job-results.tsx @@ -1,14 +1,15 @@ 'use client' -import React, { FC, useMemo } from 'react' -import { Anchor, Group, LoadingOverlay, Stack, Text, useMantineTheme } from '@mantine/core' +import React, { FC, useMemo, useState } from 'react' +import { Anchor, Button, Group, LoadingOverlay, Stack, Text, Textarea, useMantineTheme } from '@mantine/core' import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr' import { useQuery } from '@/common' import { ErrorAlert } from '@/components/errors' import { DownloadBlobLink } from '@/components/download-blob-link' import { isApprovedLogType, logLabel } from '@/lib/file-type-helpers' +import { useDecryptFiles, type EncryptedJobFile } from '@/hooks/use-decrypt-files' import { fetchApprovedJobFilesAction } from '@/server/actions/study-job.actions' -import { JobFile } from '@/lib/types' +import { JobFile, JobFileInfo } from '@/lib/types' import { LatestJobForStudy } from '@/server/db/queries' const ViewResultsLink: FC<{ content: ArrayBuffer }> = ({ content }) => { @@ -30,44 +31,79 @@ const ViewResultsLink: FC<{ content: ArrayBuffer }> = ({ content }) => { ) } +// Researcher-facing view of shared results. There is no plaintext copy: the +// researcher decrypts the re-encrypted approved files with their own key (they +// are a recipient of the re-encryption done at approve time). export const JobResults: FC<{ job: LatestJobForStudy }> = ({ job }) => { + const [decryptedFiles, setDecryptedFiles] = useState() + const { data: approvedFiles, isLoading, isError, error, } = useQuery({ - queryKey: ['job-results', job.id], + queryKey: ['approved-files', job.id], queryFn: async () => await fetchApprovedJobFilesAction({ studyJobId: job.id }), }) + const { + decrypt, + isPending: isDecrypting, + form, + } = useDecryptFiles({ + encryptedFiles: approvedFiles as EncryptedJobFile[] | undefined, + onSuccess: setDecryptedFiles, + }) + const { resultsFiles, logFiles } = useMemo(() => { - const res: JobFile[] = [] - const logs: JobFile[] = [] + const res: JobFileInfo[] = [] + const logs: JobFileInfo[] = [] - approvedFiles?.forEach((f) => { + decryptedFiles?.forEach((f) => { if (f.fileType === 'APPROVED-RESULT') res.push(f) else if (isApprovedLogType(f.fileType)) logs.push(f) }) return { resultsFiles: res, logFiles: logs } - }, [approvedFiles]) + }, [decryptedFiles]) if (isError) { return } - if (isLoading || !approvedFiles) { + if (isLoading) { return } + if (!decryptedFiles) { + return ( +
decrypt(values.privateKey))}> + +