diff --git a/.cursor/rules/graphify.mdc b/.cursor/rules/graphify.mdc new file mode 100644 index 000000000..867031495 --- /dev/null +++ b/.cursor/rules/graphify.mdc @@ -0,0 +1,21 @@ +--- +description: graphify knowledge graph context +alwaysApply: true +--- + +This project can build a Graphify knowledge graph under `graphify-out/` (generated locally; not committed). Scope/exclusions: `.graphifyignore` and `code/.graphifyignore`. + +**When exploring architecture, prefer Graphify if a local graph exists:** +- `graphify query ""` — scoped subgraph for codebase or architecture questions +- `graphify path "" ""` — dependency path between two symbols +- `graphify explain ""` — nodes related to a concept + +If `graphify-out/graph.json` is missing, regenerate with AST-only Graphify against `code/` (respect `.graphifyignore`; do not ingest PDFs), then query. Treat Graphify edges as hints — always source-verify before concluding. + +Only use Read/Grep/Glob directly when: +1. Graphify has already oriented you and you need specific lines +2. Graphify is unavailable and regeneration is not practical + +- If `graphify-out/wiki/index.md` exists, navigate it instead of reading raw files +- Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review when query/path/explain are insufficient +- After substantial code changes, run `graphify update .` locally to refresh the graph (AST-only) diff --git a/.github/workflows/webmcp-ci.yml b/.github/workflows/webmcp-ci.yml index 72b347d15..c15d3c7cd 100644 --- a/.github/workflows/webmcp-ci.yml +++ b/.github/workflows/webmcp-ci.yml @@ -20,79 +20,93 @@ jobs: webmcp-release-gate: name: "WebMCP Gate (build + unit tests)" runs-on: ubuntu-latest + defaults: + run: + working-directory: code steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 with: - node-version: 20 - cache: npm + version: 10.28.2 - - name: Install root dependencies - run: npm ci + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: code/pnpm-lock.yaml - - name: Install extension dependencies - run: npm ci - working-directory: code/apps/extension-chromium + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build extension - run: npm run build - working-directory: code/apps/extension-chromium + run: pnpm --filter @optimandoai/extension-chromium run build - name: Run WebMCP unit + sender-gate tests - run: npm run test:webmcp:ci - working-directory: code + run: pnpm run test:webmcp:ci quarantine-monitor: name: "Quarantine (pre-existing failures, non-blocking)" runs-on: ubuntu-latest continue-on-error: true + defaults: + run: + working-directory: code steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 with: - node-version: 20 - cache: npm + version: 10.28.2 - - name: Install root dependencies - run: npm ci + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: code/pnpm-lock.yaml - - name: Install extension dependencies - run: npm ci - working-directory: code/apps/extension-chromium + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Run quarantined tests (informational) - run: npm run test:quarantine || true - working-directory: code + run: pnpm run test:quarantine || true webmcp-e2e-smoke: name: "WebMCP E2E Smoke (opt-in)" runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_e2e == true }} + defaults: + run: + working-directory: code steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 with: - node-version: 20 - cache: npm + version: 10.28.2 - - name: Install root dependencies - run: npm ci + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: code/pnpm-lock.yaml - - name: Install extension dependencies - run: npm ci - working-directory: code/apps/extension-chromium + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + run: pnpm exec playwright install --with-deps chromium working-directory: code/apps/extension-chromium - name: Build extension - run: npm run build - working-directory: code/apps/extension-chromium + run: pnpm --filter @optimandoai/extension-chromium run build - name: Run WebMCP E2E smoke - run: npm run test:e2e:webmcp + run: pnpm run test:e2e:webmcp working-directory: code/apps/extension-chromium diff --git a/.gitignore b/.gitignore index 15c294348..2597eeca8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,18 @@ code/packages/ingestion-core/src/*.js # Chromium extension Vite outDir (versioned per release, e.g. build55647) code/apps/extension-chromium/build*/ + +# graphify generated outputs + caches (regenerate with `graphify` / graphifyy) +# Keep hand-authored: .graphifyignore, code/.graphifyignore, .cursor/rules/graphify.mdc +graphify-out/ +code/graphify-out/ + +# Cursor tooling artefacts (agent state, dumps). The Graphify↔Cursor +# integration is hand-authored and must stay visible: the always-on rule in +# .cursor/rules/ and, when used, .cursor/mcp.json. +.cursor/* +!.cursor/rules/ +!.cursor/mcp.json + +# Accidental vitest JSON dumps (not source) +code/phase2-before-native.json diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 000000000..be9b31c08 --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,34 @@ +# Initial graphify pass: codebase AST only. +# PDFs and documentation are excluded; add selectively later with /graphify --update. +*.pdf +**/*.pdf +*.md +**/*.md +*.mdx +**/*.mdx +*.txt +**/*.txt +*.html +**/*.html +*.rst +**/*.rst +*.qmd +**/*.qmd +*.yaml +**/*.yaml +*.yml +**/*.yml +*.png +**/*.png +*.jpg +**/*.jpg +*.jpeg +**/*.jpeg +*.webp +**/*.webp +*.gif +**/*.gif +*.svg +**/*.svg +**/THIRD_PARTY_LICENSES/** +**/docs/** diff --git a/code/.gitignore b/code/.gitignore index 6800ac712..88f28afca 100644 --- a/code/.gitignore +++ b/code/.gitignore @@ -338,3 +338,7 @@ apps/extension-chromium/build179/ apps/extension-chromium/build180/ apps/extension-chromium/build181/ apps/extension-chromium/build182/ + +# Accidental vitest JSON dumps (not source) +phase2-before-native.json +*-before-native.json diff --git a/code/.graphifyignore b/code/.graphifyignore new file mode 100644 index 000000000..be9b31c08 --- /dev/null +++ b/code/.graphifyignore @@ -0,0 +1,34 @@ +# Initial graphify pass: codebase AST only. +# PDFs and documentation are excluded; add selectively later with /graphify --update. +*.pdf +**/*.pdf +*.md +**/*.md +*.mdx +**/*.mdx +*.txt +**/*.txt +*.html +**/*.html +*.rst +**/*.rst +*.qmd +**/*.qmd +*.yaml +**/*.yaml +*.yml +**/*.yml +*.png +**/*.png +*.jpg +**/*.jpg +*.jpeg +**/*.jpeg +*.webp +**/*.webp +*.gif +**/*.gif +*.svg +**/*.svg +**/THIRD_PARTY_LICENSES/** +**/docs/** diff --git a/code/apps/electron-vite-project/electron-builder.config.cjs b/code/apps/electron-vite-project/electron-builder.config.cjs index 8b77ca0b4..717c1fd19 100644 --- a/code/apps/electron-vite-project/electron-builder.config.cjs +++ b/code/apps/electron-vite-project/electron-builder.config.cjs @@ -7,10 +7,10 @@ const appDir = __dirname /** * Parsed by scripts/kill-wr-desk.cjs — must contain a line matching: - * return 'C:\\build-output\\build044' + * return 'C:\\build-output\\build007' */ function windowsOutputDirMarker() { - return 'C:\\build-output\\build044' + return 'C:\\build-output\\build007' } const workspaceRoot = path.resolve(appDir, '../..') diff --git a/code/apps/electron-vite-project/electron/handshakeAcceptSafeOpts.ts b/code/apps/electron-vite-project/electron/handshakeAcceptSafeOpts.ts index 8ad2416ba..079e72182 100644 --- a/code/apps/electron-vite-project/electron/handshakeAcceptSafeOpts.ts +++ b/code/apps/electron-vite-project/electron/handshakeAcceptSafeOpts.ts @@ -1,8 +1,8 @@ /** * Pure builder for `handshake:accept` IPC options — shared with preload tests. * Forwards an explicit allowlist only (no pass-through of arbitrary objects). - * Internal vs normal and X25519 requirements are decided in main using persisted - * `record.handshake_type` — this module does not use `device_role` as proof of internal. + * Internal vs normal and X25519 requirements are decided in main using the persisted + * `record.same_principal` flag — this module does not use `device_role` as proof of internal. */ const MAX_B64 = 8192 diff --git a/code/apps/electron-vite-project/electron/main.ts b/code/apps/electron-vite-project/electron/main.ts index e5aaaac9d..b0da1bf0f 100644 --- a/code/apps/electron-vite-project/electron/main.ts +++ b/code/apps/electron-vite-project/electron/main.ts @@ -2812,6 +2812,13 @@ app.whenReady().then(async () => { console.error('[MAIN] registerInboxHandlers stack:', inboxRegErr.stack) } } + try { + const { registerArt50Ipc } = await import('./main/aiProvenance/art50Ipc') + registerArt50Ipc() + console.log('[MAIN] Art. 50 IPC handlers registered') + } catch (art50Err) { + console.error('[MAIN] registerArt50Ipc failed:', art50Err) + } try { registerEmailHandlers(getInboxDb) console.log('[MAIN] Email Gateway IPC handlers registered') @@ -3731,9 +3738,9 @@ app.whenReady().then(async () => { } // Before (split contract): isInternalAccept = contextOpts?.device_role === 'host' || 'sandbox' // — could misclassify when device_role is missing/filtered. - // After: same source of truth as handleHandshakeRPC handshake.accept (record.handshake_type). + // After: same source of truth as handleHandshakeRPC handshake.accept (record.same_principal). // contextOpts.device_role remains for internal pairing/UX; it is not the X25519 guard signal. - const isInternalAccept = acceptRecord.handshake_type === 'internal' + const isInternalAccept = acceptRecord.same_principal === true const co = contextOpts const trimmedSenderX25519 = (typeof co?.senderX25519PublicKeyB64 === 'string' ? co.senderX25519PublicKeyB64.trim() : '') || @@ -3749,7 +3756,7 @@ app.whenReady().then(async () => { logNormalAcceptX25519BindingFailure({ handshake_id: id, local_role: acceptRecord.local_role ?? null, - handshake_type: acceptRecord.handshake_type ?? null, + same_principal: acceptRecord.same_principal === true, params: { senderX25519PublicKeyB64: co?.senderX25519PublicKeyB64, key_agreement: co?.key_agreement, @@ -3939,7 +3946,7 @@ app.whenReady().then(async () => { console.log('[HANDSHAKE:FORCE_REVOKE] record found:', record ? `state=${record.state}` : 'null') if (!record) return { success: false, error: `Handshake ${id} not found in database` } const session = getCurrentSession() - await revokeHandshake(db, id, 'local-user', session?.wrdesk_user_id, session ?? undefined, async () => getAccessToken() ?? null) + await revokeHandshake(db, id, 'local-user', session?.wrdesk_user_id) console.log('[HANDSHAKE:FORCE_REVOKE] revoke completed for id:', id) return { success: true } } catch (err: any) { @@ -4111,7 +4118,11 @@ app.whenReady().then(async () => { return { success: false, error: 'No LLM model installed. Install a model in LLM Settings first.' } } const response = await localLlmManager.chat(modelId, [{ role: 'user', content: prompt || '' }]) - return { success: true, answer: response?.content ?? '' } + return { + success: true, + answer: response?.content ?? '', + provenance: response?.provenance ?? null, + } } catch (err: any) { console.error('[MAIN] handshake:generateDraft error:', err?.message) return { success: false, error: err?.message ?? 'Draft generation failed' } @@ -4688,14 +4699,24 @@ app.whenReady().then(async () => { { role: 'system' as const, content: system }, { role: 'user' as const, content: userPrompt }, ] + const provenanceOut: { value?: import('../../../packages/shared/src/aiProvenance').AiProvenance } = {} const answer = await ragSbxGen.runOllamaGenerateChatWithSandboxRouting(provider as any, ragMessages, { model: params.model, stream: !!doStream, send: doStream ? send : undefined, ragParams: sandboxRagRoutingParams(), contentTask: ragContentTask, + provenanceOut, + }) + const ragProv = provenanceOut.value + return toIPC({ + success: true, + answer, + sources, + streamed: doStream, + resultType: 'context_answer', + ...(ragProv !== undefined ? { provenance: ragProv } : {}), }) - return toIPC({ success: true, answer, sources, streamed: doStream, resultType: 'context_answer' }) } catch (err: unknown) { const ir = mapInferenceRoutingError(err) if (ir) return ir @@ -4938,6 +4959,7 @@ app.whenReady().then(async () => { { role: 'system' as const, content: systemPrompt }, { role: 'user' as const, content: userPrompt }, ] + const provenanceOut: { value?: import('../../../packages/shared/src/aiProvenance').AiProvenance } = {} try { answer = await ragSbxGen.runOllamaGenerateChatWithSandboxRouting(provider as any, messages, { model: params.model, @@ -4945,6 +4967,7 @@ app.whenReady().then(async () => { send: doStream ? send : undefined, ragParams: sandboxRagRoutingParams(), contentTask: ragContentTask, + provenanceOut, }) } catch (err: unknown) { const ir = mapInferenceRoutingError(err) @@ -4990,12 +5013,14 @@ app.whenReady().then(async () => { checkAILatency(total_ms) if (capsuleId && normalizedQuery) setCached(db, capsuleId, normalizedQuery, { answer, sources }) + const chatWithContextProv = provenanceOut.value return toIPC({ success: true, answer: doStream ? undefined : answer, sources, governanceNote: governanceNote ?? undefined, streamed: doStream, + ...(chatWithContextProv !== undefined ? { provenance: chatWithContextProv } : {}), ...(hybridResult.contextRetrieval && { contextRetrieval: hybridResult.contextRetrieval }), ...(debug && { latency: buildLatencyDebugPayload({ @@ -5058,13 +5083,16 @@ app.whenReady().then(async () => { { role: 'system' as const, content: params.systemPrompt }, { role: 'user' as const, content: params.userPrompt }, ] + const provenanceOut: { value?: import('../../../packages/shared/src/aiProvenance').AiProvenance } = {} const answer = await provider.generateChat(messages, { model: params.model, stream: doStream, send: doStream ? send : undefined, + provenanceOut, ...(typeof params.temperature === 'number' ? { temperature: params.temperature } : {}), }) - return toIPC({ success: true, answer, contextBlocks: [], sources: [] }) + const chatDirectProvenance = provenanceOut.value + return toIPC({ success: true, answer, contextBlocks: [], sources: [], ...(chatDirectProvenance !== undefined ? { provenance: chatDirectProvenance } : {}) }) } catch (err: any) { console.error('[chatDirect] error:', err) return toIPC({ success: false, error: 'model_execution_failed', message: err?.message ?? 'Unknown error' }) @@ -5073,7 +5101,7 @@ app.whenReady().then(async () => { // email:listAccounts is registered by registerEmailHandlers() — do not duplicate here - ipcMain.handle('handshake:initiate', async (_e, receiverEmail: string, fromAccountId: string, contextOpts?: { skipVaultContext?: boolean; message?: string; context_blocks?: any[]; profile_ids?: string[]; profile_items?: any[]; policy_selections?: { cloud_ai?: boolean; internal_ai?: boolean }; handshake_type?: 'internal' | 'standard'; device_name?: string; device_role?: 'host' | 'sandbox'; counterparty_device_id?: string; counterparty_device_role?: 'host' | 'sandbox'; counterparty_computer_name?: string; counterparty_pairing_code?: string }) => { + ipcMain.handle('handshake:initiate', async (_e, receiverEmail: string, fromAccountId: string, contextOpts?: { skipVaultContext?: boolean; message?: string; context_blocks?: any[]; profile_ids?: string[]; profile_items?: any[]; policy_selections?: { cloud_ai?: boolean; internal_ai?: boolean }; profile_id?: string; device_name?: string; device_role?: 'host' | 'sandbox'; counterparty_device_id?: string; counterparty_device_role?: 'host' | 'sandbox'; counterparty_computer_name?: string; counterparty_pairing_code?: string }) => { try { const db = await getHandshakeDb() return await handleHandshakeRPC('handshake.initiate', { @@ -5086,7 +5114,7 @@ app.whenReady().then(async () => { ...(contextOpts?.profile_ids?.length ? { profile_ids: contextOpts.profile_ids } : {}), ...(contextOpts?.profile_items?.length ? { profile_items: contextOpts.profile_items } : {}), ...(contextOpts?.policy_selections ? { policy_selections: contextOpts.policy_selections } : {}), - handshake_type: contextOpts?.handshake_type, + profile_id: contextOpts?.profile_id, device_name: contextOpts?.device_name, device_role: contextOpts?.device_role, ...(contextOpts?.counterparty_device_id ? { counterparty_device_id: contextOpts.counterparty_device_id } : {}), @@ -5103,7 +5131,7 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('handshake:buildForDownload', async (_e, receiverEmail: string, contextOpts?: { skipVaultContext?: boolean; message?: string; context_blocks?: any[]; profile_ids?: string[]; profile_items?: any[]; policy_selections?: { cloud_ai?: boolean; internal_ai?: boolean }; handshake_type?: 'internal' | 'standard'; device_name?: string; device_role?: 'host' | 'sandbox'; counterparty_device_id?: string; counterparty_device_role?: 'host' | 'sandbox'; counterparty_computer_name?: string; counterparty_pairing_code?: string }) => { + ipcMain.handle('handshake:buildForDownload', async (_e, receiverEmail: string, contextOpts?: { skipVaultContext?: boolean; message?: string; context_blocks?: any[]; profile_ids?: string[]; profile_items?: any[]; policy_selections?: { cloud_ai?: boolean; internal_ai?: boolean }; profile_id?: string; device_name?: string; device_role?: 'host' | 'sandbox'; counterparty_device_id?: string; counterparty_device_role?: 'host' | 'sandbox'; counterparty_computer_name?: string; counterparty_pairing_code?: string }) => { try { const db = await getHandshakeDb() if (!db) { @@ -5119,7 +5147,7 @@ app.whenReady().then(async () => { ...(contextOpts?.profile_ids?.length ? { profile_ids: contextOpts.profile_ids } : {}), ...(contextOpts?.profile_items?.length ? { profile_items: contextOpts.profile_items } : {}), ...(contextOpts?.policy_selections ? { policy_selections: contextOpts.policy_selections } : {}), - handshake_type: contextOpts?.handshake_type, + profile_id: contextOpts?.profile_id, device_name: contextOpts?.device_name, device_role: contextOpts?.device_role, ...(contextOpts?.counterparty_device_id ? { counterparty_device_id: contextOpts.counterparty_device_id } : {}), @@ -6991,13 +7019,17 @@ async function runDeviceKeyMigration( /** * Dispatch a chat request to a cloud LLM provider. * Reuses the same API patterns as handshake/aiProviders.ts. + * Returns content + AiProvenance (logged once here; no downstream re-log needed). */ async function dispatchCloudChat( provider: string, modelId: string, messages: Array<{ role: string; content: string }>, apiKey: string - ): Promise { + ) { + const { attachAndLogProvenance } = await import('./main/aiProvenance/attachProvenance') + const { extractUpstreamMarking } = await import('../../../packages/shared/src/aiProvenance/generate') + switch (provider) { case 'openai': { const model = modelId || 'gpt-4o-mini' @@ -7011,7 +7043,8 @@ async function runDeviceKeyMigration( throw new Error(`OpenAI ${res.status}: ${errText}`) } const data: any = await res.json() - return data.choices?.[0]?.message?.content ?? 'No response from OpenAI.' + const content: string = data.choices?.[0]?.message?.content ?? 'No response from OpenAI.' + return attachAndLogProvenance(content, { model_id: model, provider: 'cloud:openai', upstream_marking: extractUpstreamMarking(data) }) } case 'anthropic': { @@ -7040,7 +7073,8 @@ async function runDeviceKeyMigration( throw new Error(`Anthropic ${res.status}: ${errText}`) } const data: any = await res.json() - return data.content?.[0]?.text ?? 'No response from Anthropic.' + const content: string = data.content?.[0]?.text ?? 'No response from Anthropic.' + return attachAndLogProvenance(content, { model_id: model, provider: 'cloud:anthropic', upstream_marking: extractUpstreamMarking(data) }) } case 'gemini': { @@ -7064,7 +7098,8 @@ async function runDeviceKeyMigration( throw new Error(`Gemini ${res.status}: ${errText}`) } const data: any = await res.json() - return data.candidates?.[0]?.content?.parts?.[0]?.text ?? 'No response from Gemini.' + const content: string = data.candidates?.[0]?.content?.parts?.[0]?.text ?? 'No response from Gemini.' + return attachAndLogProvenance(content, { model_id: model, provider: 'cloud:gemini', upstream_marking: extractUpstreamMarking(data) }) } case 'grok': { @@ -7079,7 +7114,8 @@ async function runDeviceKeyMigration( throw new Error(`xAI/Grok ${res.status}: ${errText}`) } const data: any = await res.json() - return data.choices?.[0]?.message?.content ?? 'No response from Grok.' + const content: string = data.choices?.[0]?.message?.content ?? 'No response from Grok.' + return attachAndLogProvenance(content, { model_id: model, provider: 'cloud:grok', upstream_marking: extractUpstreamMarking(data) }) } default: @@ -7564,8 +7600,8 @@ async function runDeviceKeyMigration( }) httpApp.post('/api/wrchat/smart-summary', async (_req, res) => { try { - const summary = await watchdogService.runSmartSummary() - res.json({ ok: true, summary }) + const summaryResult = await watchdogService.runSmartSummary() + res.json({ ok: true, summary: summaryResult.text, provenance: summaryResult.provenance ?? undefined }) } catch (error: any) { if (error?.message === 'Capture pipeline busy') { res.status(429).json({ ok: false, error: error.message }) @@ -7575,7 +7611,23 @@ async function runDeviceKeyMigration( res.status(500).json({ ok: false, error: error?.message || 'smart summary failed' }) } }) - httpApp.post('/api/wrchat/watchdog/continuous', async (req, res) => { + httpApp.post('/api/art50/editorial-responsibility', async (req, res) => { + try { + const { isAiProvenance, markEditorialResponsible } = await import('../../../packages/shared/src/aiProvenance') + const { logEditorialResponsibility } = await import('./main/aiProvenance/provenanceLog') + const raw = req.body?.provenance ?? req.body + if (!isAiProvenance(raw)) { + res.status(400).json({ ok: false, error: 'invalid_provenance' }) + return + } + const next = markEditorialResponsible(raw) + logEditorialResponsibility(next) + res.json({ ok: true, provenance: next }) + } catch (e: any) { + res.status(500).json({ ok: false, error: e?.message || 'editorial_log_failed' }) + } + }) +httpApp.post('/api/wrchat/watchdog/continuous', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? (req.body as { enabled?: unknown }) : {} if (typeof body.enabled !== 'boolean') { @@ -10180,7 +10232,7 @@ async function runDeviceKeyMigration( const ledger = hostAiEffectiveRole.getHostAiLedgerRoleSummaryFromDb(db, inst, String(om.mode)) const internalRows = db != null - ? listHandshakeRecords(db as any, { state: HandshakeState.ACTIVE, handshake_type: 'internal' }) + ? listHandshakeRecords(db as any, { state: HandshakeState.ACTIVE, same_principal: true }) : [] const rec = db && handshake_id ? getHandshakeRecord(db, handshake_id) : null const peerHostForHandshake = rec ? peerCoordinationDeviceId(rec) : null @@ -10279,7 +10331,7 @@ async function runDeviceKeyMigration( timeoutMs: timeout_ms, }) if (r.ok) { - res.json({ ok: true, data: { content: r.output, model: r.model } }) + res.json({ ok: true, data: { content: r.output, model: r.model, ...(r.provenance !== undefined ? { provenance: r.provenance } : {}) } }) } else { res.json({ ok: false, error: r.message, code: r.code }) } @@ -10355,8 +10407,8 @@ async function runDeviceKeyMigration( // Cloud provider dispatch: when provider + apiKey are present, call the cloud API directly if (provider && apiKey) { console.log('[HTTP-LLM] Cloud dispatch:', provider, modelId) - const cloudContent = await dispatchCloudChat(provider, modelId, messages, apiKey) - res.json({ ok: true, data: { content: cloudContent } }) + const cloudResult = await dispatchCloudChat(provider, modelId, messages, apiKey) + res.json({ ok: true, data: { content: cloudResult.content, provenance: cloudResult.provenance } }) return } @@ -10396,6 +10448,7 @@ async function runDeviceKeyMigration( data: { ...response, content: response.content, + ...(response.provenance ? { provenance: response.provenance } : {}), ...(modelFallback ? { modelFallback } : {}), }, }) diff --git a/code/apps/electron-vite-project/electron/main/__tests__/invariants.test.ts b/code/apps/electron-vite-project/electron/main/__tests__/invariants.test.ts index faf6b23c0..be0e8a989 100644 --- a/code/apps/electron-vite-project/electron/main/__tests__/invariants.test.ts +++ b/code/apps/electron-vite-project/electron/main/__tests__/invariants.test.ts @@ -177,11 +177,21 @@ describe('System Invariants', () => { expect(decision.target).not.toBe('handshake_pipeline') }) - // Invariant 4: Authorization gate always invoked before tool execution - test('4: authorizeToolInvocation is called before any tool execution', async () => { - const auditEntries: any[] = [] - const activeRow = makeHandshakeRow() - const db = makeMockDb({ 'hs-001': activeRow }, auditEntries) + // Invariant 4: Authorization gate always invoked before tool execution. + // Phase 5 (V4): the gate requires a fresh, tapped, Intent-Hash-bound + // consent record — an ACTIVE handshake alone executes nothing. + test('4: authorizeToolInvocation (per-tap consent) is called before any tool execution', async () => { + const Database = (await import('better-sqlite3')).default + const { migrateHandshakeTables, insertHandshakeRecord } = await import('../handshake/db') + const { buildActiveHandshakeRecord } = await import('../handshake/__tests__/helpers') + const { prepareExecutionConsent, confirmExecutionConsent } = await import('../execution/executionConsent') + const { setEvidenceDbProvider } = await import('../handshake/evidenceChain') + + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + insertHandshakeRecord(db, buildActiveHandshakeRecord()) + setEvidenceDbProvider(() => db) let toolExecuted = false registerTool('read-context', async () => { @@ -189,28 +199,36 @@ describe('System Invariants', () => { return { data: 'result' } }) - await executeToolRequest(db, { - request_id: 'req-001', - handshake_id: 'hs-001', - tool_name: 'read-context', - parameters: {}, - requested_at: new Date().toISOString(), - origin: 'local_ui', - }) + try { + const req = { + request_id: 'req-001', + handshake_id: 'hs-001', + tool_name: 'read-context', + parameters: {}, + requested_at: new Date().toISOString(), + origin: 'local_ui' as const, + } - expect(toolExecuted).toBe(true) - - // Audit entries should include at least one authorization record - // (inserted by authorizeToolInvocation) PLUS one execution audit record. - // The authorization audit is from authorizeToolInvocation, and the - // execution audit is from executeToolRequest's step 5. - const authAuditEntries = auditEntries.filter(e => - e.sql.includes('INSERT') && ( - JSON.stringify(e.args).includes('TOOL_AUTHORIZED') || - JSON.stringify(e.args).includes('TOOL_EXECUTION_SUCCESS') - ), - ) - expect(authAuditEntries.length).toBeGreaterThanOrEqual(2) + // Without a consent tap: refused, no execution. + const refused = await executeToolRequest(db, req) + expect(refused.success).toBe(false) + expect(toolExecuted).toBe(false) + + // With a tapped consent: executes. + const prep = prepareExecutionConsent(db, { ...req, scope_id: undefined, purpose_id: undefined }) + confirmExecutionConsent(db, prep.consent_id, 'local-user-001') + const allowed = await executeToolRequest(db, { ...req, consent_ref: prep.consent_id }) + expect(allowed.success).toBe(true) + expect(toolExecuted).toBe(true) + + const actions = (db.prepare(`SELECT action FROM audit_log WHERE handshake_id = 'hs-001'`).all() as Array<{ action: string }>) + .map((r) => r.action) + expect(actions).toContain('TOOL_AUTHORIZED') + expect(actions).toContain('TOOL_EXECUTION_SUCCESS') + } finally { + setEvidenceDbProvider(null) + db.close() + } }) // Invariant 5: Validator is the sole ValidatedCapsule factory (static scan) diff --git a/code/apps/electron-vite-project/electron/main/aiProvenance/art50Ipc.ts b/code/apps/electron-vite-project/electron/main/aiProvenance/art50Ipc.ts new file mode 100644 index 000000000..fb313a997 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/aiProvenance/art50Ipc.ts @@ -0,0 +1,30 @@ +/** + * Renderer → main Art. 50 IPC (editorial logging). Generation stays in attachProvenance. + */ + +import { ipcMain } from 'electron' +import { + isAiProvenance, + markEditorialResponsible, + type AiProvenance, +} from '../../../../../packages/shared/src/aiProvenance' +import { logEditorialResponsibility } from './provenanceLog' + +let registered = false + +export function registerArt50Ipc(): void { + if (registered) return + registered = true + + ipcMain.handle( + 'art50:logEditorialResponsibility', + async (_e, raw: unknown): Promise<{ ok: true; provenance: AiProvenance } | { ok: false; error: string }> => { + if (!isAiProvenance(raw)) { + return { ok: false, error: 'invalid_provenance' } + } + const next = markEditorialResponsible(raw) + logEditorialResponsibility(next) + return { ok: true, provenance: next } + }, + ) +} diff --git a/code/apps/electron-vite-project/electron/main/aiProvenance/attachProvenance.ts b/code/apps/electron-vite-project/electron/main/aiProvenance/attachProvenance.ts new file mode 100644 index 000000000..e46e68fe5 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/aiProvenance/attachProvenance.ts @@ -0,0 +1,28 @@ +/** + * Main-process helper: attach AiProvenance + append to generation log. + */ + +import { + createProvenance, + finalizeAiText, + type AiTextWithProvenance, + type CreateProvenanceInput, +} from '../../../../../packages/shared/src/aiProvenance/generate' +import { logGeneration } from './provenanceLog' + +export function attachAndLogProvenance( + content: string, + input: CreateProvenanceInput, +): AiTextWithProvenance { + const result = finalizeAiText(content, input) + logGeneration(result.provenance) + return result +} + +export function provenanceOnly(content: string, input: CreateProvenanceInput) { + const p = createProvenance(content, input) + logGeneration(p) + return p +} + +export { createProvenance, finalizeAiText, logGeneration } diff --git a/code/apps/electron-vite-project/electron/main/aiProvenance/provenanceLog.ts b/code/apps/electron-vite-project/electron/main/aiProvenance/provenanceLog.ts new file mode 100644 index 000000000..dfa6febd7 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/aiProvenance/provenanceLog.ts @@ -0,0 +1,48 @@ +/** + * Append-only Art. 50 generation provenance log (JSONL under userData). + * Not stored in handshake-ledger / email-accounts / orchestrator-mode files. + */ + +import { app } from 'electron' +import fs from 'node:fs' +import path from 'node:path' +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' + +const LOG_DIR = 'ai-provenance' +const LOG_FILE = 'generations.jsonl' + +function logPath(): string { + return path.join(app.getPath('userData'), LOG_DIR, LOG_FILE) +} + +export type ProvenanceLogRecord = AiProvenance & { + logged_at: string + event?: 'generation' | 'editorial_responsibility' | 'human_edit' +} + +/** Append one generation (or related) event. Failures are swallowed — never block inference. */ +export function logGeneration( + p: AiProvenance, + event: ProvenanceLogRecord['event'] = 'generation', +): void { + try { + const dir = path.join(app.getPath('userData'), LOG_DIR) + fs.mkdirSync(dir, { recursive: true }) + const record: ProvenanceLogRecord = { + ...p, + logged_at: new Date().toISOString(), + event, + } + fs.appendFileSync(logPath(), `${JSON.stringify(record)}\n`, 'utf8') + } catch (e) { + console.warn( + '[art50-prov] logGeneration failed:', + e instanceof Error ? e.message : String(e), + ) + } +} + +/** Convenience: create is caller's job; this only logs. */ +export function logEditorialResponsibility(p: AiProvenance): void { + logGeneration(p, 'editorial_responsibility') +} diff --git a/code/apps/electron-vite-project/electron/main/depackaging-microvm/depackageModel.ts b/code/apps/electron-vite-project/electron/main/depackaging-microvm/depackageModel.ts index 39fd5de3d..eadd63595 100644 --- a/code/apps/electron-vite-project/electron/main/depackaging-microvm/depackageModel.ts +++ b/code/apps/electron-vite-project/electron/main/depackaging-microvm/depackageModel.ts @@ -12,7 +12,11 @@ * runtime dependency on `emailDepackage`, so there is no import cycle. */ -import type { DisplayEnvelope, ThreadingHints } from './displayEnvelope' +import type { + ChannelAuthenticationMaterial, + DisplayEnvelope, + ThreadingHints, +} from './displayEnvelope' // ── Typed failure taxonomy (INV-7) ────────────────────────────────────────── @@ -89,4 +93,10 @@ export interface ParseOut { * IMAP threading + MOVE relocation never depend on an orchestrator header parse. */ threadingHints: ThreadingHints + /** + * Channel-authentication material for the CPR [IX.3.1], collected in-guest for + * the same reason as the envelope above. Absent means the producer had nothing + * to read — which the CPR records as `unverifiable`, never as a pass. + */ + channelAuthentication?: ChannelAuthenticationMaterial } diff --git a/code/apps/electron-vite-project/electron/main/depackaging-microvm/displayEnvelope.ts b/code/apps/electron-vite-project/electron/main/depackaging-microvm/displayEnvelope.ts index 904846967..2b5317d22 100644 --- a/code/apps/electron-vite-project/electron/main/depackaging-microvm/displayEnvelope.ts +++ b/code/apps/electron-vite-project/electron/main/depackaging-microvm/displayEnvelope.ts @@ -43,8 +43,71 @@ export const ENVELOPE_CAPS = { MAX_RECIPIENTS: 256, MAX_MSGID_LEN: 998, MAX_REFERENCES: 64, + /** Channel-authentication material: bounded like every other header read. */ + MAX_AUTH_RESULTS: 8, + MAX_AUTH_RESULT_LEN: 2048, } as const +/** + * Channel-authentication material for the Channel Provenance Record + * [IX.3.1]. Collected here because header handling belongs in-guest; the + * verdicts themselves are computed by the single shared evaluator + * (`evaluateChannelAuthentication`) so there is exactly one implementation. + * + * These values are consumed and discarded at evaluation. Nothing from them is + * copied into the CPR — the record carries typed verdicts only. + */ +export interface ChannelAuthenticationMaterial { + /** `Authentication-Results` values in header order, count- and length-capped. */ + readonly authenticationResults: readonly string[] + /** RFC5322.From domain, for alignment. */ + readonly fromDomain?: string +} + +/** + * Collect every `Authentication-Results` header. `parseHeaders` keeps only the + * first occurrence of a name, which is wrong here: a message that traversed + * several relays carries one per hop and the receiving gateway's is not + * necessarily first, so this reads the header block directly. + */ +export function channelAuthenticationFromHeaderBlock( + headerBlock: string, + envelope: DisplayEnvelope, +): ChannelAuthenticationMaterial { + const results: string[] = [] + const unfolded = (headerBlock ?? '').replace(/\r?\n[ \t]+/g, ' ') + for (const line of unfolded.split(/\r?\n/)) { + const idx = line.indexOf(':') + if (idx <= 0) continue + if (line.slice(0, idx).trim().toLowerCase() !== 'authentication-results') continue + const value = line.slice(idx + 1).trim() + if (value === '') continue + results.push(value.slice(0, ENVELOPE_CAPS.MAX_AUTH_RESULT_LEN)) + if (results.length >= ENVELOPE_CAPS.MAX_AUTH_RESULTS) break + } + return channelAuthenticationMaterial(results, envelope) +} + +/** + * Same shape from an already-extracted list — the provider-structured-json path, + * where the provider hands us named header values rather than a header block. + */ +export function channelAuthenticationMaterial( + authenticationResults: readonly string[], + envelope: DisplayEnvelope, +): ChannelAuthenticationMaterial { + const fromEmail = envelope.from?.email + const at = typeof fromEmail === 'string' ? fromEmail.lastIndexOf('@') : -1 + const fromDomain = at >= 0 ? fromEmail!.slice(at + 1).trim().toLowerCase() : undefined + return { + authenticationResults: authenticationResults + .filter((v) => typeof v === 'string' && v.trim() !== '') + .slice(0, ENVELOPE_CAPS.MAX_AUTH_RESULTS) + .map((v) => v.slice(0, ENVELOPE_CAPS.MAX_AUTH_RESULT_LEN)), + ...(fromDomain ? { fromDomain } : {}), + } +} + /** * B2.2 threading keys, derived IN-GUEST (header handling never in the orchestrator * flag-on). IMAP has no native thread id, so flag-on it threads / relocates (MOVE) diff --git a/code/apps/electron-vite-project/electron/main/depackaging-microvm/emailDepackage.ts b/code/apps/electron-vite-project/electron/main/depackaging-microvm/emailDepackage.ts index 749b7833b..a65ef150e 100644 --- a/code/apps/electron-vite-project/electron/main/depackaging-microvm/emailDepackage.ts +++ b/code/apps/electron-vite-project/electron/main/depackaging-microvm/emailDepackage.ts @@ -41,12 +41,24 @@ import { type ParseOut, } from './depackageModel' import { walkProviderStructured } from './providerStructuredWalker' -import { buildEnvelopeFromHeaders, threadingFromHeaders, type DisplayEnvelope, type ThreadingHints } from './displayEnvelope' +import { + buildEnvelopeFromHeaders, + channelAuthenticationFromHeaderBlock, + threadingFromHeaders, + type ChannelAuthenticationMaterial, + type DisplayEnvelope, + type ThreadingHints, +} from './displayEnvelope' // Re-exported for back-compat with existing importers. export { DepackageFailure } from './depackageModel' export type { DepackageFailureCode, DepackageLimits, Leaf, ParseOut } from './depackageModel' -export type { DisplayEnvelope, EnvelopeAddress, ThreadingHints } from './displayEnvelope' +export type { + ChannelAuthenticationMaterial, + DisplayEnvelope, + EnvelopeAddress, + ThreadingHints, +} from './displayEnvelope' // ── Custody + opaque channels ──────────────────────────────────────────────── @@ -76,9 +88,9 @@ export interface OpaquePackage { // ── Typed result union ─────────────────────────────────────────────────────── export type DepackageEmailResult = - | { readonly ok: true; readonly type: 'plain'; readonly safeText: SafeTextV1; readonly artifacts: readonly SealedArtifact[]; readonly displayEnvelope: DisplayEnvelope; readonly threadingHints: ThreadingHints } - | { readonly ok: true; readonly type: 'beap-carrier'; readonly packages: readonly OpaquePackage[]; readonly carrierSafeText?: SafeTextV1; readonly artifacts: readonly SealedArtifact[]; readonly displayEnvelope: DisplayEnvelope; readonly threadingHints: ThreadingHints } - | { readonly ok: true; readonly type: 'mixed'; readonly packages: readonly OpaquePackage[]; readonly safeText: SafeTextV1; readonly artifacts: readonly SealedArtifact[]; readonly displayEnvelope: DisplayEnvelope; readonly threadingHints: ThreadingHints } + | { readonly ok: true; readonly type: 'plain'; readonly safeText: SafeTextV1; readonly artifacts: readonly SealedArtifact[]; readonly displayEnvelope: DisplayEnvelope; readonly threadingHints: ThreadingHints; readonly channelAuthentication?: ChannelAuthenticationMaterial } + | { readonly ok: true; readonly type: 'beap-carrier'; readonly packages: readonly OpaquePackage[]; readonly carrierSafeText?: SafeTextV1; readonly artifacts: readonly SealedArtifact[]; readonly displayEnvelope: DisplayEnvelope; readonly threadingHints: ThreadingHints; readonly channelAuthentication?: ChannelAuthenticationMaterial } + | { readonly ok: true; readonly type: 'mixed'; readonly packages: readonly OpaquePackage[]; readonly safeText: SafeTextV1; readonly artifacts: readonly SealedArtifact[]; readonly displayEnvelope: DisplayEnvelope; readonly threadingHints: ThreadingHints; readonly channelAuthentication?: ChannelAuthenticationMaterial } | { readonly ok: false; readonly code: DepackageFailureCode; readonly message: string } // ── Bounded MIME parse (recursive, fail-closed) ────────────────────────────── @@ -206,6 +218,9 @@ function hardenedParse(input: Buffer, limits?: DepackageLimits): ParseOut { leaves: [], displayEnvelope, threadingHints: threadingFromHeaders(headers), + // CPR material [IX.3.1]: read from the header BLOCK, not the collapsed map — + // a forwarded message carries one `Authentication-Results` per hop. + channelAuthentication: channelAuthenticationFromHeaderBlock(headerBlock, displayEnvelope), } parseEntity(body, headers, out, 0, maxInput) return out @@ -404,6 +419,7 @@ function buildResultFromParse(parsed: ParseOut, sandboxPubB64: string): Depackag const displayEnvelope = parsed.displayEnvelope const threadingHints = parsed.threadingHints + const channelAuthentication = parsed.channelAuthentication // Carrier packages travel in the opaque channel and must NOT be sealed; // everything else (HTML, attachments) is custody-sealed. Leaves consumed as @@ -420,7 +436,7 @@ function buildResultFromParse(parsed: ParseOut, sandboxPubB64: string): Depackag plainTextBodyRaw: bodyText, attachmentBlobIds: artifacts.map((a) => a.blob_id), }) - return { ok: true, type: 'plain', safeText: rawSafeText, artifacts, displayEnvelope, threadingHints } + return { ok: true, type: 'plain', safeText: rawSafeText, artifacts, displayEnvelope, threadingHints, channelAuthentication } } const artifacts = sealArtifacts(sealLeaves, sandboxPubB64) @@ -430,14 +446,14 @@ function buildResultFromParse(parsed: ParseOut, sandboxPubB64: string): Depackag plainTextBodyRaw: bodyText, attachmentBlobIds: artifacts.map((a) => a.blob_id), }) - return { ok: true, type: 'mixed', packages, safeText: rawSafeText, artifacts, displayEnvelope, threadingHints } + return { ok: true, type: 'mixed', packages, safeText: rawSafeText, artifacts, displayEnvelope, threadingHints, channelAuthentication } } const rawCarrierSafeText = constructSafeText({ subjectRaw: parsed.subject, plainTextBodyRaw: '', attachmentBlobIds: artifacts.map((a) => a.blob_id), }) - return { ok: true, type: 'beap-carrier', packages, carrierSafeText: rawCarrierSafeText, artifacts, displayEnvelope, threadingHints } + return { ok: true, type: 'beap-carrier', packages, carrierSafeText: rawCarrierSafeText, artifacts, displayEnvelope, threadingHints, channelAuthentication } } function toFailureResult(err: unknown): DepackageEmailResult { diff --git a/code/apps/electron-vite-project/electron/main/depackaging-microvm/providerStructuredWalker.ts b/code/apps/electron-vite-project/electron/main/depackaging-microvm/providerStructuredWalker.ts index 09500ec24..69d2a84f2 100644 --- a/code/apps/electron-vite-project/electron/main/depackaging-microvm/providerStructuredWalker.ts +++ b/code/apps/electron-vite-project/electron/main/depackaging-microvm/providerStructuredWalker.ts @@ -27,7 +27,25 @@ import { type Leaf, type ParseOut, } from './depackageModel' -import { buildEnvelopeFromFields, threadingFromProvider, type RawProviderAddress, type RawProviderEnvelopeFields } from './displayEnvelope' +import { buildEnvelopeFromFields, channelAuthenticationMaterial, threadingFromProvider, type RawProviderAddress, type RawProviderEnvelopeFields } from './displayEnvelope' + +/** + * Graph exposes raw headers as `internetMessageHeaders: [{name, value}]` only + * when the message was fetched with that field. Absent means we have nothing to + * evaluate, which the CPR records as `unverifiable` — never as a pass. + */ +function graphAuthenticationResults(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const out: string[] = [] + for (const entry of value) { + if (typeof entry !== 'object' || entry === null) continue + const header = entry as Record + if (typeof header.name !== 'string' || typeof header.value !== 'string') continue + if (header.name.trim().toLowerCase() !== 'authentication-results') continue + out.push(header.value) + } + return out +} // ── C4 structural guards over untrusted JSON ───────────────────────────────── @@ -147,6 +165,11 @@ const outlookAdapter: ProviderStructuredAdapter = { threadingHints: threadingFromProvider({ messageId: typeof obj.internetMessageId === 'string' ? obj.internetMessageId : undefined, }), + // CPR material [IX.3.1]; empty when Graph did not return raw headers. + channelAuthentication: channelAuthenticationMaterial( + graphAuthenticationResults(obj.internetMessageHeaders), + displayEnvelope, + ), } const body = obj.body diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/b4P2PRelayMigration.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/b4P2PRelayMigration.test.ts index f136ddb6d..aa0491224 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/b4P2PRelayMigration.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/b4P2PRelayMigration.test.ts @@ -24,6 +24,14 @@ import { createRequire } from 'module' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { randomUUID, createHash, createHmac } from 'crypto' + +// The vault capability gate (outer-vault/SSO active) is exercised by its own +// tests (capabilityBroker.test.ts); allow it here so the P2P path reaches the +// ingress admission filter and the inbox/quarantine writes under test. +vi.mock('../../vault/capabilityBroker', () => ({ + canPerform: () => ({ allowed: true, reasonCode: 'ok', userMessage: '', retryStrategy: 'transient' }), +})) + import { bindKeyProvider, unbindKeyProvider, @@ -52,9 +60,23 @@ function makeEmptyDb() { return new Database(':memory:') } -function makeTestDb() { +/** + * Phase 1 [VII.2.7]: the ingress admission filter is the first inbox stage; + * message deliveries need an ACTIVE relationship row to be admitted. + */ +async function seedActiveHandshake(db: any, hsId: string) { + const { insertHandshakeRecord } = await import('../../handshake/db') + const { buildActiveHandshakeRecord } = await import('../../handshake/__tests__/helpers') + insertHandshakeRecord(db, buildActiveHandshakeRecord({ handshake_id: hsId, relationship_id: `rel-${hsId}` })) +} + +async function makeTestDb() { if (!Database) return null const db = new Database(':memory:') + // Real handshake schema (handshakes + audit_log) so the ingress admission + // filter (Phase 1, [VII.2.7]) can resolve relationships and log blocks. + const { migrateHandshakeTables } = await import('../../handshake/db') + migrateHandshakeTables(db) db.exec(` CREATE TABLE IF NOT EXISTS inbox_messages ( id TEXT PRIMARY KEY, @@ -121,12 +143,16 @@ function buildValidSealForRowId(canonicalJson: string, rowId: string): { seal: s } function setupSealGate() { - bindKeyProvider(() => TEST_DEK) + // Bind both slots: the non-confidential inbox write seals with the OUTER + // provider (computeSeal(..., 'outer')); confidential/quarantine use inner. + bindKeyProvider(() => TEST_DEK, 'inner') + bindKeyProvider(() => TEST_DEK, 'outer') clearTamperingEvents() } function teardownSealGate() { - unbindKeyProvider() + unbindKeyProvider('inner') + unbindKeyProvider('outer') } // ───────────────────────────────────────────────────────────────────────────── @@ -166,8 +192,8 @@ function makeQBeapPackage(handshakeId: string): string { describe.skipIf(!Database)('B-4 §1 — processBeapPackageInline', () => { let db: ReturnType> - beforeEach(() => { - db = makeTestDb()! + beforeEach(async () => { + db = (await makeTestDb())! setupSealGate() }) @@ -178,6 +204,7 @@ describe.skipIf(!Database)('B-4 §1 — processBeapPackageInline', () => { it('§1.1 valid pBEAP with known handshake → sealed inbox row (outcome: inbox)', async () => { const handshakeId = randomUUID() + await seedActiveHandshake(db, handshakeId) const { processBeapPackageInline } = await import('../beapEmailIngestion') @@ -214,54 +241,70 @@ describe.skipIf(!Database)('B-4 §1 — processBeapPackageInline', () => { validateSpy.mockRestore() }) - it('§1.2 pBEAP with unknown/no handshake → sealed quarantine row (outcome: quarantine)', async () => { + it('§1.2 pBEAP with unknown relationship → blocked pre-visibility by ingress admission [VII.2.7]', async () => { const unknownHandshakeId = 'unknown-handshake-' + randomUUID() const { processBeapPackageInline } = await import('../beapEmailIngestion') - // First call: inbox validator rejects (unknown handshake / ARTEFACT_UNKNOWN_KEY). - // Second call: quarantine validator approves with a valid seal (writeP2PQuarantineRow - // calls validatorOrchestrator.validate once more and requires ok: true). + // Phase 1: an unknown relationship dies at the ingress admission filter — + // no inbox row, no quarantine row, no validator invocation. Only an + // audit_log record remains. const orchestratorMod = await import('../../validator-process/orchestrator') - let callCount = 0 - vi.spyOn(orchestratorMod.validatorOrchestrator, 'validate').mockImplementation(async (args: any) => { - callCount++ - if (callCount === 1) { - return { - outcome: { - ok: false, - sealed_quarantine: { - rejection_reason: 'ARTEFACT_UNKNOWN_KEY', - validated_at: new Date().toISOString(), - validator_version: 'b4-test', - }, - }, - } as any - } - // Quarantine write call — build a valid seal - const rowId = String(args.target_row_id ?? '') - const canonicalJson = typeof args.plaintext_or_encrypted?.content === 'string' - ? args.plaintext_or_encrypted.content : '{}' - const { seal, seal_input_json } = buildValidSealForRowId(canonicalJson, rowId) - return { - outcome: { - ok: true, - sealed: { seal, seal_input_json, canonical_json: canonicalJson, validated_at: new Date().toISOString(), validator_version: 'b4-test' }, - }, - } as any - }) + const validateSpy = vi.spyOn(orchestratorMod.validatorOrchestrator, 'validate') const pkg = makePBeapPackage(unknownHandshakeId) const result = await processBeapPackageInline(db, pkg, unknownHandshakeId, { sourceType: 'p2p', }) - expect(['quarantine', 'inbox']).toContain(result.outcome) + expect(result.outcome).toBe('error') + expect(result.reasonCode).toBe('ingress_admission_unknown_relationship') + expect(result.retryable).toBe(false) + expect(validateSpy).not.toHaveBeenCalled() + + // Pre-visibility: nothing reached a user-visible surface. + expect(db.prepare('SELECT COUNT(*) AS c FROM inbox_messages').get()).toMatchObject({ c: 0 }) + expect(db.prepare('SELECT COUNT(*) AS c FROM quarantine_messages').get()).toMatchObject({ c: 0 }) + + // Logged record exists. + const audit = db.prepare( + "SELECT * FROM audit_log WHERE action = 'INGRESS_ADMISSION_BLOCKED' AND handshake_id = ?", + ).get(unknownHandshakeId) as any + expect(audit).toBeTruthy() + expect(audit.reason_code).toBe('unknown_relationship') vi.restoreAllMocks() }) + it('§1.2b pBEAP for a REVOKED relationship → blocked pre-visibility [VII.2.7]', async () => { + const handshakeId = randomUUID() + const { insertHandshakeRecord } = await import('../../handshake/db') + const { buildActiveHandshakeRecord } = await import('../../handshake/__tests__/helpers') + const { HandshakeState } = await import('../../handshake/types') + insertHandshakeRecord(db, buildActiveHandshakeRecord({ + handshake_id: handshakeId, + relationship_id: `rel-${handshakeId}`, + state: HandshakeState.REVOKED, + })) + + const { processBeapPackageInline } = await import('../beapEmailIngestion') + const result = await processBeapPackageInline(db, makePBeapPackage(handshakeId), handshakeId, { + sourceType: 'p2p', + }) + + expect(result.outcome).toBe('error') + expect(result.reasonCode).toBe('ingress_admission_relationship_revoked') + expect(db.prepare('SELECT COUNT(*) AS c FROM inbox_messages').get()).toMatchObject({ c: 0 }) + expect(db.prepare('SELECT COUNT(*) AS c FROM quarantine_messages').get()).toMatchObject({ c: 0 }) + const audit = db.prepare( + "SELECT * FROM audit_log WHERE action = 'INGRESS_ADMISSION_BLOCKED' AND handshake_id = ?", + ).get(handshakeId) as any + expect(audit).toBeTruthy() + expect(audit.reason_code).toBe('relationship_revoked') + }) + it('§1.3 corrupted / non-JSON bytes → quarantine with parse error, not thrown to caller', async () => { const { processBeapPackageInline } = await import('../beapEmailIngestion') + await seedActiveHandshake(db, '__corrupt__') // Corrupted input: no first call succeeds since there's no canonical JSON to validate. // The code goes directly to writeP2PQuarantineRow which calls validate once (ok: true required). @@ -296,8 +339,8 @@ describe.skipIf(!Database)('B-4 §1 — processBeapPackageInline', () => { describe.skipIf(!Database)('B-4 §2 — processSandboxQuarantineReceive', () => { let db: ReturnType> - beforeEach(() => { - db = makeTestDb()! + beforeEach(async () => { + db = (await makeTestDb())! setupSealGate() }) diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/b9OutboundCloneIntegrity.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/b9OutboundCloneIntegrity.test.ts index 387674bbc..75dcc26ff 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/b9OutboundCloneIntegrity.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/b9OutboundCloneIntegrity.test.ts @@ -5,14 +5,14 @@ * * §1 — Source read uses sealedQuery (Decision B) * §1.1 Valid sealed row → prepare succeeds (seal passes verification) - * §1.2 Tampered row (content hash mismatch) → MESSAGE_NOT_FOUND + * §1.2 Tampered row (content hash mismatch) → SOURCE_UNVERIFIABLE * (sealedQuery filters the row before content extraction) - * §1.3 Row with missing seal → MESSAGE_NOT_FOUND (reject mode filters) - * §1.4 Row missing from DB entirely → MESSAGE_NOT_FOUND + * §1.3 Row with missing seal → SOURCE_UNVERIFIABLE (reject mode filters) + * §1.4 Row missing from DB entirely → MESSAGE_NOT_FOUND (genuinely absent) * * §2 — No DB writes on the outbound path (Decision C / D) * §2.1 Successful prepare produces zero DB writes - * §2.2 Failed prepare (MESSAGE_NOT_FOUND) produces zero DB writes + * §2.2 Failed prepare (SOURCE_UNVERIFIABLE) produces zero DB writes * * §3 — Failure-path matrix (Decision A / E) * §3.1 Tampered row: quarantine row unchanged after clone attempt @@ -27,6 +27,7 @@ import { createSealedStorageTestContext, type SealedStorageTestContext } from 't import { prepareBeapInboxSandboxClone } from '../beapInboxClonePrepare' import type { HandshakeRecord, SSOSession } from '../../handshake/types' import { HandshakeState } from '../../handshake/types' +import { getInstanceId } from '../../orchestrator/orchestratorModeStore' import type { InternalSandboxListEntry } from '../../handshake/internalSandboxesApi' // ── Mock external dependencies ──────────────────────────────────────────────── @@ -64,11 +65,14 @@ function makeHandshakeRecord(id: string): HandshakeRecord { return { handshake_id: id, state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, relationship_id: 'rel-b9', local_role: 'initiator', initiator_device_role: 'host', acceptor_device_role: 'sandbox', + // Host/sandbox roles are derived from coordination device ids, not local_role. + initiator_coordination_device_id: getInstanceId(), + acceptor_coordination_device_id: 'dev-sandbox-peer', internal_coordination_identity_complete: true, p2p_endpoint: 'p2p://sandbox-b9', local_x25519_public_key_b64: 'bG9jYWx4MjU1MTk=', @@ -163,7 +167,7 @@ describe('B-9 §1 — source read uses sealedQuery (Decision B)', () => { } }) - it('§1.2 tampered row (content hash mismatch) → MESSAGE_NOT_FOUND; no data extracted', () => { + it('§1.2 tampered row (content hash mismatch) → SOURCE_UNVERIFIABLE; no data extracted', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -194,14 +198,15 @@ describe('B-9 §1 — source read uses sealedQuery (Decision B)', () => { const r = prepareBeapInboxSandboxClone(ctx.db as any, makeSession(), msgId, hsId, 'tag') - // sealedQuery filters the tampered row → MESSAGE_NOT_FOUND, not the tampered content. + // sealedQuery filters the tampered row → SOURCE_UNVERIFIABLE (present, unverifiable), + // not MESSAGE_NOT_FOUND and not the tampered content. expect(r.ok).toBe(false) if (!r.ok) { - expect(r.code).toBe('MESSAGE_NOT_FOUND') + expect(r.code).toBe('SOURCE_UNVERIFIABLE') } }) - it('§1.3 row with missing seal → MESSAGE_NOT_FOUND (reject mode)', () => { + it('§1.3 row with missing seal → SOURCE_UNVERIFIABLE (reject mode)', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -226,7 +231,7 @@ describe('B-9 §1 — source read uses sealedQuery (Decision B)', () => { // In reject mode, rows with missing seals are filtered out. expect(r.ok).toBe(false) if (!r.ok) { - expect(r.code).toBe('MESSAGE_NOT_FOUND') + expect(r.code).toBe('SOURCE_UNVERIFIABLE') } }) @@ -295,7 +300,7 @@ describe('B-9 §2 — no DB writes on the outbound prepare path (Decisions C / D expect(after).toEqual(before) }) - it('§2.2 failed prepare (MESSAGE_NOT_FOUND) writes nothing to inbox_messages', () => { + it('§2.2 failed prepare (SOURCE_UNVERIFIABLE) writes nothing to inbox_messages', () => { if (!ctx.db) return // No mock setup — just verify no write on failure diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepare.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepare.test.ts index cba53bcd4..fbccfe3e8 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepare.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepare.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { P2P_BEAP_INBOX_ACCOUNT_ID, type InternalSandboxListEntry } from '../../handshake/internalSandboxesApi' import { HandshakeState, type HandshakeRecord, type SSOSession } from '../../handshake/types' +import { getInstanceId } from '../../orchestrator/orchestratorModeStore' import { prepareBeapInboxSandboxClone } from '../beapInboxClonePrepare' const { listAvailableInternalSandboxes, getHandshakeRecord } = vi.hoisted(() => ({ @@ -46,11 +47,14 @@ function makeHandshakeRecord(id: string): HandshakeRecord { return { handshake_id: id, state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, relationship_id: 'rel-1', local_role: 'initiator', initiator_device_role: 'host', acceptor_device_role: 'sandbox', + // Host/sandbox roles are derived from coordination device ids, not local_role. + initiator_coordination_device_id: getInstanceId(), + acceptor_coordination_device_id: 'dev-sandbox-peer', internal_coordination_identity_complete: true, p2p_endpoint: 'p2p://sandbox-target', local_x25519_public_key_b64: 'bG9jYWx4MjU1MTk=', diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepareSealGate.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepareSealGate.test.ts index 5af99c1ea..7574e3c55 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepareSealGate.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/beapInboxClonePrepareSealGate.test.ts @@ -10,6 +10,7 @@ import { computeSeal, } from '../../sealed-storage/index' import { deriveLedgerSealKey } from '../../sealed-storage/ledgerSealKey' +import { getInstanceId } from '../../orchestrator/orchestratorModeStore' import { prepareBeapInboxSandboxClone } from '../beapInboxClonePrepare' import type { InternalSandboxListEntry } from '../../handshake/internalSandboxesApi' import { HandshakeState, type HandshakeRecord, type SSOSession } from '../../handshake/types' @@ -52,11 +53,14 @@ function makeHandshakeRecord(id: string): HandshakeRecord { return { handshake_id: id, state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, relationship_id: 'rel-gate', local_role: 'initiator', initiator_device_role: 'host', acceptor_device_role: 'sandbox', + // Host/sandbox roles are derived from coordination device ids, not local_role. + initiator_coordination_device_id: getInstanceId(), + acceptor_coordination_device_id: 'dev-sandbox-peer', internal_coordination_identity_complete: true, p2p_endpoint: 'http://127.0.0.1:51249/beap/ingest', local_x25519_public_key_b64: 'bG9jYWx4MjU1MTk=', @@ -176,7 +180,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { } }) - it('direct_beap sandbox-clone-of-plain vmk row + outer-only → prepare succeeds (trusted read)', () => { + it('direct_beap sandbox-clone-of-plain ledger row + outer-only → prepare succeeds', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -192,7 +196,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { sandbox_clone: true, }, }) - const s = ctx.buildValidSealForRowId(msgId, dep) + const s = computeSeal(dep, msgId, 'outer') ctx.db .prepare( `INSERT INTO inbox_messages @@ -202,7 +206,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { VALUES (?, 'direct_beap', 'hs-orig', 'Clone plain', 'cloned from newsletter', ?, 0, 'news@example.com', 'acc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', NULL, - ?, ?, 'vmk')`, + ?, ?, 'ledger')`, ) .run(msgId, dep, s.seal, s.seal_input_json) @@ -214,7 +218,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { } }) - it('native direct_beap vmk row + depackaged body + outer-only → prepare succeeds (list-boundary trusted read)', () => { + it('native direct_beap ledger row + depackaged body + outer-only → prepare succeeds (list boundary)', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -226,7 +230,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { body: { text: 'native beap visible in inbox' }, format: 'beap_qbeap_decrypted', }) - const s = ctx.buildValidSealForRowId(msgId, dep) + const s = computeSeal(dep, msgId, 'outer') ctx.db .prepare( `INSERT INTO inbox_messages @@ -235,7 +239,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { seal, seal_input_json, seal_key_source) VALUES (?, 'direct_beap', 'hs-orig', 'Native', 'native beap visible in inbox', ?, 0, 'peer@test', 'acc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', - ?, ?, 'vmk')`, + ?, ?, 'ledger')`, ) .run(msgId, dep, s.seal, s.seal_input_json) @@ -247,7 +251,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { } }) - it('native direct_beap vmk row without depackaged content + outer-only → MESSAGE_NOT_FOUND', () => { + it('native direct_beap vmk row without depackaged content + outer-only → SOURCE_NO_CANONICAL_CONTENT', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -278,11 +282,19 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { expect(r.ok).toBe(false) if (!r.ok) { - expect(r.code).toBe('MESSAGE_NOT_FOUND') + expect(r.code).toBe('SOURCE_NO_CANONICAL_CONTENT') } }) - it('email_plain vmk row + body_text only (no depackaged_json) + outer-only → prepare succeeds', () => { + // A row with no canonical plaintext has nothing the seal can bind, so `sealedQuery` + // cannot verify it and clone prepare refuses. Production writes NULL + // `depackaged_json` only for rows that genuinely have no plaintext yet + // (`beap_qbeap_pending_main`, main-process decode errors) — there is nothing to + // clone. Pinned so the refusal cannot be relaxed into a body_text fallback. + // + // Taxonomy: absence of content outranks unverifiability. You cannot verify + // what is not there, and "no content yet" is the actionable thing to say. + it('email_plain ledger row + body_text only (no depackaged_json) + outer-only → SOURCE_NO_CANONICAL_CONTENT', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -290,7 +302,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { getHandshakeRecord.mockReturnValue(makeHandshakeRecord(entry.handshake_id)) const msgId = randomUUID() - const s = ctx.buildValidSealForRowId(msgId, '{}') + const s = computeSeal('', msgId, 'outer') ctx.db .prepare( `INSERT INTO inbox_messages @@ -300,19 +312,19 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { VALUES (?, 'email_plain', 'hs-orig', 'Plain body only', 'Hello from IMAP', NULL, 0, 'news@example.com', 'acc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', 'plain_email_no_validation_required', - ?, ?, 'vmk')`, + ?, ?, 'ledger')`, ) .run(msgId, s.seal, s.seal_input_json) const r = prepareBeapInboxSandboxClone(ctx.db as any, makeSession(), msgId, entry.handshake_id, 'tag') - expect(r.ok).toBe(true) - if (r.ok) { - expect(r.encrypted_text).toContain('Hello from IMAP') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.code).toBe('SOURCE_NO_CANONICAL_CONTENT') } }) - it('email_plain vmk row + outer-only + conformant validation → prepare succeeds', () => { + it('email_plain ledger row + outer-only + conformant validation → prepare succeeds', () => { if (!ctx.db) return const entry = makeEligibleEntry() @@ -326,7 +338,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { body: { text: 'depackaged email body' }, format: 'email_plain', }) - const s = ctx.buildValidSealForRowId(msgId, canonical) + const s = computeSeal(canonical, msgId, 'outer') ctx.db .prepare( `INSERT INTO inbox_messages @@ -336,7 +348,7 @@ describe('prepareBeapInboxSandboxClone — seal provider routing', () => { VALUES (?, 'email_plain', 'hs-orig', 'XING newsletter', 'depackaged email body', ?, 0, 'news@xing.com', 'acc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', 'plain_email_no_validation_required', - ?, ?, 'vmk')`, + ?, ?, 'ledger')`, ) .run(msgId, canonical, s.seal, s.seal_input_json) diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/channelProvenanceAnalysisInput.guard.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/channelProvenanceAnalysisInput.guard.test.ts new file mode 100644 index 000000000..49943fb1b --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/channelProvenanceAnalysisInput.guard.test.ts @@ -0,0 +1,157 @@ +/** + * Build item 13 (2C) — CPR as a declared, typed input to local scam analysis. + * + * Guards the one-directional layering: the record informs the analysis; the analysis + * never suppresses, softens, precedes, or replaces the §IX.3.1 rule-8 alert. Source + * walking backs the prompt-contract assertions so a refactor that re-routes the wiring + * fails here rather than silently at runtime. + */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + CHANNEL_PROVENANCE_ANALYSIS_PROMPT_SECTION, + SCAM_WATCHDOG_PROMPT_SECTION, + buildChannelProvenanceAnalysisBlock, + buildScamWatchdogUserContext, + channelProvenanceAnalysisInput, +} from '../scamWatchdog' + +const here = dirname(fileURLToPath(import.meta.url)) +const emailDir = join(here, '..') + +const METADATA = JSON.stringify({ + format: 'beap_message', + channel_provenance: { + marking_scheme: 'optirando-cpr/1', + spf: { verdict: 'fail', aligned: false }, + dkim: { verdict: 'none', aligned: false }, + dmarc: { verdict: 'unverifiable', aligned: false }, + channel_pass: false, + authenticated_sender_domain: null, + }, +}) + +describe('CPR → analysis: typed decode', () => { + it('projects verdicts, aggregate, and authenticated domain from depackaged_metadata', () => { + expect(channelProvenanceAnalysisInput(METADATA)).toEqual({ + spf: 'fail', + dkim: 'none', + dmarc: 'unverifiable', + channelPass: false, + authenticatedSenderDomain: null, + }) + }) + + it('fail-closed on absent or malformed records', () => { + expect(channelProvenanceAnalysisInput(null)).toBeNull() + expect(channelProvenanceAnalysisInput('not json')).toBeNull() + expect(channelProvenanceAnalysisInput({ channel_provenance: {} })).toBeNull() + expect( + channelProvenanceAnalysisInput({ + channel_provenance: { spf: { verdict: 'maybe' }, dkim: { verdict: 'none' }, dmarc: { verdict: 'none' } }, + }), + ).toBeNull() + }) + + it('carries no raw evaluation material into the projection', () => { + const withRaw = JSON.parse(METADATA) as Record + ;(withRaw.channel_provenance as Record).authentication_results = [ + 'mx.test; dkim=none', + ] + const projected = channelProvenanceAnalysisInput(withRaw) + expect(projected).not.toBeNull() + expect(Object.keys(projected!).sort()).toEqual([ + 'authenticatedSenderDomain', + 'channelPass', + 'dkim', + 'dmarc', + 'spf', + ]) + }) +}) + +describe('CPR → analysis: prompt block', () => { + it('renders the typed verdicts as evidence, not as a verdict of the model', () => { + const block = buildChannelProvenanceAnalysisBlock(channelProvenanceAnalysisInput(METADATA)) + expect(block).toMatch(/Channel Provenance Record/) + expect(block).toMatch(/SPF: fail/) + expect(block).toMatch(/DKIM: none/) + expect(block).toMatch(/DMARC: unverifiable/) + expect(block).toMatch(/evidence only/i) + }) + + it('states absence explicitly rather than staying silent', () => { + const block = buildChannelProvenanceAnalysisBlock(null) + expect(block).toMatch(/not available/i) + expect(block).toMatch(/do NOT infer that it was authenticated/i) + }) + + it('is appended to the scam-watchdog user context', () => { + const ctx = buildScamWatchdogUserContext( + 'see http://1.2.3.4/login', + channelProvenanceAnalysisInput(METADATA), + ) + expect(ctx).toMatch(/TEXT ONLY/) + expect(ctx).toMatch(/Channel Provenance Record/) + expect(ctx.indexOf('Link strings')).toBeLessThan(ctx.indexOf('Channel Provenance Record')) + }) + + it('performs no network access while building the block', () => { + const prev = globalThis.fetch + const spy = () => { + throw new Error('network access from analysis prompt builder') + } + // @ts-expect-error override for assertion + globalThis.fetch = spy + try { + buildScamWatchdogUserContext('http://a.test/x', channelProvenanceAnalysisInput(METADATA)) + } finally { + globalThis.fetch = prev + } + }) +}) + +describe('one-directional layering', () => { + it('the prompt forbids the analysis from clearing, replacing, or restating the alert', () => { + const s = CHANNEL_PROVENANCE_ANALYSIS_PROMPT_SECTION + expect(s).toMatch(/NEVER a finding on its own/i) + expect(s).toMatch(/NEVER clears, softens, or outweighs/i) + expect(s).toMatch(/do NOT restate it/i) + expect(s).toMatch(/do NOT claim to replace it/i) + expect(SCAM_WATCHDOG_PROMPT_SECTION).toContain(CHANNEL_PROVENANCE_ANALYSIS_PROMPT_SECTION) + }) + + it('the analysis module never imports or references the alert component', () => { + const src = readFileSync(join(emailDir, 'scamWatchdog.ts'), 'utf8') + expect(src).not.toMatch(/shared-beap-ui/) + expect(src).not.toMatch(/ChannelProvenanceAlert/) + expect(src).not.toMatch(/channelAlertRequired/) + }) + + it('the alert component never reads scam-analysis output', () => { + const alert = readFileSync( + join(emailDir, '../../../../../packages/shared-beap-ui/src/ChannelProvenanceAlert.tsx'), + 'utf8', + ) + expect(alert).not.toMatch(/scam/i) + expect(alert).not.toMatch(/ai_analysis_json/) + expect(alert).not.toMatch(/aiClassification/) + }) + + it('both analysis handlers read the CPR from the row and pass it to the prompt builder', () => { + const ipc = readFileSync(join(emailDir, 'ipc.ts'), 'utf8') + const selects = ipc.match( + /SELECT from_address, from_name, subject, body_text, received_at, source_type, handshake_id, depackaged_json, depackaged_metadata, beap_package_json FROM inbox_messages WHERE id = \?/g, + ) + expect(selects, 'both analyze handlers must SELECT depackaged_metadata').toHaveLength(2) + + const calls = ipc.match(/buildScamWatchdogUserContext\(body, cpr[A-Za-z]+\)/g) + expect(calls, 'both analyze handlers must pass the CPR into the prompt').toHaveLength(2) + expect(ipc.match(/channelProvenanceAnalysisInput\(row\.depackaged_metadata\)/g)).toHaveLength(2) + + // The analysis result must not be written back into the alert's source of truth. + expect(ipc).not.toMatch(/channel_provenance\s*[:=]\s*(parsed|analysis|scam)/) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/channelProvenancePersistence.regression.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/channelProvenancePersistence.regression.test.ts new file mode 100644 index 000000000..9a9655b01 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/channelProvenancePersistence.regression.test.ts @@ -0,0 +1,436 @@ +/** + * Regression — a Channel Provenance Record [IX.3.1] is produced for EVERY + * message on BOTH ingest paths, persisted to + * `inbox_messages.depackaged_metadata` beside `pbeap_trust`, bound into the + * seal, and retained in the append-only evidence log [IX.11]. + * + * • flag OFF — messageRouter.detectAndRouteMessage (inline) + * • flag ON — routeViaDepackageSeam (guest-collected material) for plain + * mail, carrier mail, and the quarantine outcome + * + * "Every message" is the load-bearing claim: a message with no CPR would be + * indistinguishable from one that skipped the producer, so the absence of + * authentication material must still yield a record — an `unverifiable` one. + * + * Run under Electron's Node ABI when available: `pnpm test:native-db `. + */ + +import { createRequire } from 'module' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createHash, createHmac } from 'crypto' +import { + bindKeyProvider, + unbindKeyProvider, + clearTamperingEvents, + getTamperingEvents, + sealedQuery, +} from '../../sealed-storage' +import { readChannelProvenanceMetadata } from '@repo/ingestion-core' +import { + setEvidenceDbProvider, + listEvidenceRecords, + verifyEvidenceChain, + LOCAL_EVIDENCE_CHAIN, +} from '../../handshake/evidenceChain' + +const require = createRequire(import.meta.url) +let Database: typeof import('better-sqlite3').default | null = null +try { + const D = require('better-sqlite3') as typeof import('better-sqlite3').default + const d = new D(':memory:') + d.close() + Database = D +} catch { + Database = null +} + +const TEST_DEK = Buffer.from('00'.repeat(32), 'hex') + +const h = vi.hoisted(() => ({ + SANDBOX_PUB: 'e06Qm75//kTEZaIgA31gjuNYl9Me+XLwf3SJLLD3PxM=', + sandboxState: { list: [{ handshake_id: 'hs-1', sandbox_keying_complete: true }] }, +})) +const SESSION = { sessionId: 'test-session', userId: 'test-user' } as any + +vi.mock('../gateway', () => ({ emailGateway: { getProviderSync: () => 'gmail' } })) +vi.mock('../../handshake/internalSandboxesApi', () => ({ + listAvailableInternalSandboxes: () => ({ success: true, sandboxes: h.sandboxState.list }), + isEligibleActiveInternalHostSandboxRecord: () => true, +})) +vi.mock('../../handshake/db', () => ({ + getHandshakeRecord: () => ({ peer_x25519_public_key_b64: h.SANDBOX_PUB }), +})) +vi.mock('../../quarantine-blob-storage/index', () => ({ + writeQuarantineBlob: () => ({ + storage_id: 'blob-' + Math.random().toString(16).slice(2), + blob_sha256: 'a'.repeat(64), + blob_size_bytes: 123, + }), +})) +vi.mock('../attachmentBlobCrypto', () => ({ + writeEncryptedAttachmentFile: vi.fn(() => ({ storagePath: '/tmp/m.bin', encryptionKeyStored: 'k', ivB64: 'i', tagB64: 't' })), +})) +vi.mock('../pdf-extractor', () => ({ + extractPdfText: vi.fn(async () => ({ text: '', status: 'skipped' })), + isPdfFile: () => false, + resolveInboxPdfExtractionStatus: () => ({ status: 'skipped', error: null }), +})) + +import { detectAndRouteMessage } from '../messageRouter' + +function createTestDb(): import('better-sqlite3').Database { + const db = new Database!(':memory:') + db.exec(` + CREATE TABLE inbox_messages ( + id TEXT PRIMARY KEY, + source_type TEXT NOT NULL CHECK(source_type IN ('direct_beap','email_beap','email_plain')), + handshake_id TEXT, account_id TEXT, email_message_id TEXT, + from_address TEXT, from_name TEXT, to_addresses TEXT, cc_addresses TEXT, + subject TEXT, body_text TEXT, body_html TEXT, beap_package_json TEXT, + depackaged_json TEXT, depackaged_metadata TEXT, + has_attachments INTEGER DEFAULT 0, attachment_count INTEGER DEFAULT 0, + received_at TEXT NOT NULL, ingested_at TEXT NOT NULL, + imap_remote_mailbox TEXT, imap_rfc_message_id TEXT, + validated_at TEXT, validator_version TEXT, validation_reason TEXT, + seal TEXT, seal_input_json TEXT, seal_key_source TEXT + ); + CREATE TABLE inbox_attachments ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, filename TEXT NOT NULL, + content_type TEXT, size_bytes INTEGER, content_id TEXT, storage_path TEXT, + extracted_text TEXT, text_extraction_status TEXT, text_extraction_error TEXT, + content_sha256 TEXT, extracted_text_sha256 TEXT, encryption_key TEXT, + encryption_iv TEXT, encryption_tag TEXT, storage_encrypted INTEGER DEFAULT 0, + page_count INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE quarantine_messages ( + id TEXT PRIMARY KEY, transport_sender TEXT, transport_received_at TEXT, + transport_folder TEXT, blob_size_bytes INTEGER, blob_storage_id TEXT, + blob_sha256 TEXT, rejection_reason TEXT, paired_sandbox_handshake_id TEXT, + seal TEXT, seal_input_json TEXT, cloned_to_sandbox_at TEXT + ); + `) + return db +} + +function eml(headers: string[], body: string): Buffer { + return Buffer.from([...headers, '', body].join('\r\n'), 'utf8') +} + +/** Gateway verdict for a message that authenticates: aligned DKIM + DMARC. */ +const AUTHENTICATED = 'mx.wr.test; dkim=pass header.d=publisher.test header.b=SIGBYTES; dmarc=pass header.from=publisher.test' +/** Forwarding breaks SPF but not DKIM — the D5 DKIM-only pass. */ +const FORWARDED = 'mx.wr.test; spf=fail smtp.mailfrom=list.example.net; dkim=pass header.d=publisher.test' + +const PBEAP_PKG = JSON.stringify({ + header: { encoding: 'pBEAP' }, + metadata: {}, + payload: Buffer.from(JSON.stringify({ capsule_type: 'initiate', schema_version: 1 }), 'utf8').toString('base64'), +}) + +describe.skipIf(!Database)('Channel Provenance Record — produced, persisted, evidenced', () => { + let db: import('better-sqlite3').Database + let evidenceDb: import('better-sqlite3').Database + + beforeEach(async () => { + db = createTestDb() + evidenceDb = new Database!(':memory:') + setEvidenceDbProvider(() => evidenceDb) + bindKeyProvider(() => TEST_DEK, 'inner') + bindKeyProvider(() => TEST_DEK, 'outer') + clearTamperingEvents() + h.sandboxState.list = [{ handshake_id: 'hs-1', sandbox_keying_complete: true }] + const orchMod = await import('../../validator-process/orchestrator') + vi.spyOn(orchMod.validatorOrchestrator, 'validate').mockImplementation(async (args: any) => { + const rowId = String(args.target_row_id ?? 'row') + const canonicalJson = args.plaintext_or_encrypted?.content ?? '{}' + const contentSha256 = createHash('sha256').update(canonicalJson, 'utf8').digest('hex') + const seal_input_json = JSON.stringify({ content_sha256: contentSha256, row_id: rowId }) + const seal = createHmac('sha256', TEST_DEK).update(seal_input_json, 'utf8').digest('base64') + return { + outcome: { + ok: true, + sealed: { seal, seal_input_json, canonical_json: canonicalJson, validated_at: new Date().toISOString(), validator_version: 'cpr-test' }, + }, + } as any + }) + }) + + afterEach(() => { + setEvidenceDbProvider(null) + unbindKeyProvider('inner') + unbindKeyProvider('outer') + vi.restoreAllMocks() + db?.close() + evidenceDb?.close() + delete process.env.WRDESK_ROLE + delete process.env.WRDESK_SEAM_DEPACKAGE_CUTOVER + }) + + function readCpr(rowId: string) { + const row = db.prepare('SELECT * FROM inbox_messages WHERE id = ?').get(rowId) as any + expect(row, 'inbox row must exist').toBeTruthy() + expect(row.depackaged_metadata, 'depackaged_metadata must carry the CPR').toBeTruthy() + const cpr = readChannelProvenanceMetadata(row.depackaged_metadata) + expect(cpr, 'the CPR must decode fail-closed').toBeTruthy() + return { row, cpr: cpr! } + } + + function evidence() { + return listEvidenceRecords(evidenceDb, LOCAL_EVIDENCE_CHAIN) + .filter((r) => r.record_type === 'ber') + .map((r) => JSON.parse(r.payload_json)) + .filter((p) => p.kind === 'channel_provenance') + } + + // ── flag OFF: the inline path ─────────────────────────────────────────────── + + describe('flag OFF — inline path', () => { + beforeEach(() => { + delete process.env.WRDESK_SEAM_DEPACKAGE_CUTOVER + }) + + it('plain mail with no authentication material gets an unverifiable CPR', async () => { + const raw: any = { + messageId: 'off-1', from: { address: 'sender@publisher.test' }, to: [], + subject: 's', text: 'body', date: new Date().toISOString(), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + expect(res.type).toBe('plain') + const { cpr } = readCpr(res.inboxMessageId) + expect(cpr.dkim.verdict).toBe('unverifiable') + expect(cpr.dmarc.verdict).toBe('unverifiable') + expect(cpr.channel_pass).toBe(false) + expect(cpr.authenticated_sender_domain).toBeNull() + expect(cpr.discovery_record).toBe('not_evaluated') + expect(cpr.content_sha256).toMatch(/^[0-9a-f]{64}$/) + }) + + it('gateway-authenticated mail gets a passing CPR with the authenticated domain', async () => { + const raw: any = { + messageId: 'off-2', from: { address: 'sender@publisher.test' }, to: [], + subject: 's', text: 'body', date: new Date().toISOString(), + headers: { authenticationResults: [AUTHENTICATED] }, + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + const { cpr } = readCpr(res.inboxMessageId) + expect(cpr.dkim).toEqual({ verdict: 'pass', aligned: true }) + expect(cpr.channel_pass).toBe(true) + expect(cpr.authenticated_sender_domain).toBe('publisher.test') + }) + + it('D5: forwarding breaks SPF but DKIM-only still passes', async () => { + const raw: any = { + messageId: 'off-3', from: { address: 'sender@publisher.test' }, to: [], + subject: 's', text: 'body', date: new Date().toISOString(), + headers: { authenticationResults: [FORWARDED] }, + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + const { cpr } = readCpr(res.inboxMessageId) + expect(cpr.spf.verdict).toBe('fail') + expect(cpr.dkim).toEqual({ verdict: 'pass', aligned: true }) + expect(cpr.channel_pass).toBe(true) + }) + + it('the persisted record carries no raw header material', async () => { + const raw: any = { + messageId: 'off-4', from: { address: 'sender@publisher.test' }, to: [], + subject: 's', text: 'body', date: new Date().toISOString(), + headers: { authenticationResults: [AUTHENTICATED] }, + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + const { row } = readCpr(res.inboxMessageId) + expect(row.depackaged_metadata).not.toContain('SIGBYTES') + expect(row.depackaged_metadata).not.toContain('header.b') + expect(row.depackaged_metadata).not.toContain('mx.wr.test') + expect(row.depackaged_metadata).not.toContain('dkim=pass') + }) + }) + + // ── flag ON: the guest seam ───────────────────────────────────────────────── + + describe('flag ON — depackage seam', () => { + beforeEach(() => { + process.env.WRDESK_ROLE = 'sandbox' + process.env.WRDESK_SEAM_DEPACKAGE_CUTOVER = '1' + }) + + it('plain mail: the guest collects the header, the host records the verdict', async () => { + const raw: any = { + messageId: 'on-1', from: { address: 'ignored@provider.test' }, to: [], subject: 'ignored', + date: new Date().toISOString(), + rawRfc822: eml( + [ + 'Subject: Guest Subject', + 'From: Publisher ', + `Authentication-Results: ${AUTHENTICATED}`, + 'Content-Type: text/plain', + ], + 'hello', + ), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + expect(res.type).toBe('plain') + const { cpr } = readCpr(res.inboxMessageId) + expect(cpr.dkim).toEqual({ verdict: 'pass', aligned: true }) + expect(cpr.channel_pass).toBe(true) + expect(cpr.authenticated_sender_domain).toBe('publisher.test') + }) + + it('plain mail with no Authentication-Results is unverifiable, never a pass', async () => { + const raw: any = { + messageId: 'on-2', from: { address: 'a@b.test' }, to: [], subject: 's', + date: new Date().toISOString(), + rawRfc822: eml(['Subject: s', 'From: a@b.test', 'Content-Type: text/plain'], 'hello'), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + const { cpr } = readCpr(res.inboxMessageId) + expect(cpr.dkim.verdict).toBe('unverifiable') + expect(cpr.channel_pass).toBe(false) + }) + + it('every Authentication-Results hop is read, not just the first', async () => { + const raw: any = { + messageId: 'on-3', from: { address: 'sender@publisher.test' }, to: [], subject: 's', + date: new Date().toISOString(), + rawRfc822: eml( + [ + 'Subject: s', + 'From: sender@publisher.test', + 'Authentication-Results: relay-a; spf=none', + 'Authentication-Results: mx.wr.test; dkim=pass header.d=publisher.test', + 'Content-Type: text/plain', + ], + 'hello', + ), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + const { cpr } = readCpr(res.inboxMessageId) + expect(cpr.spf.verdict).toBe('none') + expect(cpr.dkim).toEqual({ verdict: 'pass', aligned: true }) + expect(cpr.channel_pass).toBe(true) + }) + + it('carrier mail: the CPR survives the pipeline-2 re-entry, beside pbeap_trust', async () => { + const raw: any = { + messageId: 'on-4', from: { address: 'sender@publisher.test' }, to: [], subject: 's', + date: new Date().toISOString(), + rawRfc822: eml( + [ + 'Subject: pkg', + 'From: sender@publisher.test', + `Authentication-Results: ${AUTHENTICATED}`, + 'Content-Type: text/plain', + ], + PBEAP_PKG, + ), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + expect(res.type).toBe('beap') + const { row, cpr } = readCpr(res.inboxMessageId) + expect(row.source_type).toBe('email_beap') + expect(cpr.channel_pass).toBe(true) + // Both verdicts coexist in one blob — neither displaces the other. + expect(JSON.parse(row.depackaged_metadata).pbeap_trust).toBeTruthy() + }) + + it('a quarantined message still gets a CPR in the evidence log', async () => { + const weird = JSON.stringify({ header: { encoding: 'xBEAP' }, metadata: {}, payload: 'x' }) + const raw: any = { + messageId: 'on-5', from: { address: 'a@b.test' }, to: [], subject: 's', + date: new Date().toISOString(), + rawRfc822: eml(['Subject: w', 'From: a@b.test', 'Content-Type: text/plain'], weird), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + expect(res.type).toBe('quarantine') + const records = evidence() + expect(records).toHaveLength(1) + expect(records[0].outcome).toBe('quarantine') + expect(records[0].row_id).toBe(res.inboxMessageId) + expect(records[0].channel_pass).toBe(false) + }) + }) + + // ── Evidence log [IX.11] ──────────────────────────────────────────────────── + + describe('evidence log', () => { + it('records one metadata-only BER per message and keeps the chain verifiable', async () => { + delete process.env.WRDESK_SEAM_DEPACKAGE_CUTOVER + for (const id of ['ev-1', 'ev-2', 'ev-3']) { + await detectAndRouteMessage( + db, + 'acc', + { + messageId: id, from: { address: 'sender@publisher.test' }, to: [], + subject: 'secret subject', text: 'secret body', date: new Date().toISOString(), + headers: { authenticationResults: [AUTHENTICATED] }, + } as any, + SESSION, + ) + } + const records = evidence() + expect(records).toHaveLength(3) + expect(verifyEvidenceChain(evidenceDb, LOCAL_EVIDENCE_CHAIN)).toEqual({ valid: true, length: 4 }) + + const serialized = JSON.stringify(records) + expect(serialized).not.toContain('secret subject') + expect(serialized).not.toContain('secret body') + expect(serialized).not.toContain('SIGBYTES') + expect(records[0]).toMatchObject({ + direction: 'ingress', + channel: 'email', + ingest_path: 'inline', + outcome: 'inbox', + dkim: 'pass', + dkim_aligned: true, + channel_pass: true, + authenticated_sender_domain: 'publisher.test', + discovery_record: 'not_evaluated', + }) + }) + + it('a carrier message is evidenced exactly once, as seam_carrier', async () => { + process.env.WRDESK_ROLE = 'sandbox' + process.env.WRDESK_SEAM_DEPACKAGE_CUTOVER = '1' + const raw: any = { + messageId: 'ev-carrier', from: { address: 'sender@publisher.test' }, to: [], subject: 's', + date: new Date().toISOString(), + rawRfc822: eml(['Subject: pkg', 'From: sender@publisher.test', 'Content-Type: text/plain'], PBEAP_PKG), + } + await detectAndRouteMessage(db, 'acc', raw, SESSION) + const records = evidence() + expect(records).toHaveLength(1) + expect(records[0].ingest_path).toBe('seam_carrier') + }) + }) + + // ── Tamper-evidence ───────────────────────────────────────────────────────── + + it('the verdict is bound into the seal: a post-write upgrade is rejected on read', async () => { + delete process.env.WRDESK_SEAM_DEPACKAGE_CUTOVER + const raw: any = { + messageId: 'tamper-1', from: { address: 'sender@publisher.test' }, to: [], + subject: 's', text: 'body', date: new Date().toISOString(), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + const SEL = 'SELECT * FROM inbox_messages WHERE id = ?' + + clearTamperingEvents() + expect(sealedQuery(db, SEL, [res.inboxMessageId], 'depackaged_json', { forceKeySource: 'outer' })).toHaveLength(1) + expect(getTamperingEvents()).toHaveLength(0) + + const forged = JSON.parse( + (db.prepare('SELECT depackaged_metadata AS m FROM inbox_messages WHERE id=?').get(res.inboxMessageId) as any).m, + ) + forged.channel_provenance.channel_pass = true + forged.channel_provenance.dkim = { verdict: 'pass', aligned: true } + forged.channel_provenance.authenticated_sender_domain = 'publisher.test' + db.prepare('UPDATE inbox_messages SET depackaged_metadata=? WHERE id=?').run( + JSON.stringify(forged), + res.inboxMessageId, + ) + + clearTamperingEvents() + expect(sealedQuery(db, SEL, [res.inboxMessageId], 'depackaged_json', { forceKeySource: 'outer' })).toHaveLength(0) + expect(getTamperingEvents().some((e) => e.reason === 'metadata_hash_mismatch')).toBe(true) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/inboxReadErrorTaxonomy.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/inboxReadErrorTaxonomy.test.ts new file mode 100644 index 000000000..f7e784f91 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/inboxReadErrorTaxonomy.test.ts @@ -0,0 +1,219 @@ +/** + * Pre-Phase-4 (i) — inbox-read error taxonomy. + * + * `MESSAGE_NOT_FOUND` used to cover three materially different states, so a + * caller could not tell a missing message from a tampered one from a + * key-availability problem. This pins the split: each state reaches its own + * code, and no caller may branch on the old conflated meaning. + * + * All three cases run against the same fixture shape so the ONLY difference is + * the state under test. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { bindKeyProvider, unbindKeyProvider, computeSeal } from '../../sealed-storage/index' +import { deriveLedgerSealKey } from '../../sealed-storage/ledgerSealKey' +import { getInstanceId } from '../../orchestrator/orchestratorModeStore' +import { prepareBeapInboxSandboxClone } from '../beapInboxClonePrepare' +import type { InternalSandboxListEntry } from '../../handshake/internalSandboxesApi' +import { HandshakeState, type HandshakeRecord, type SSOSession } from '../../handshake/types' +import { + createSealedStorageTestContext, + type SealedStorageTestContext, +} from 'test/harness/sealed-storage' + +const { listAvailableInternalSandboxes, getHandshakeRecord } = vi.hoisted(() => ({ + listAvailableInternalSandboxes: vi.fn(), + getHandshakeRecord: vi.fn(), +})) + +vi.mock('../../handshake/internalSandboxesApi', async (io) => { + const mod = await io() + return { ...mod, listAvailableInternalSandboxes } +}) +vi.mock('../../handshake/db', async (io) => { + const mod = await io() + return { ...mod, getHandshakeRecord } +}) + +const OUTER_KEY = deriveLedgerSealKey('taxonomy-outer-session') + +const session = { + wrdesk_user_id: 'u-tax', + email: 'h@example.com', + sub: 'sub-tax', + iss: 'iss', + email_verified: true, + plan: 'free', + currentHardwareAttestation: null, + currentDnsVerification: null, +} as SSOSession + +function record(id: string): HandshakeRecord { + return { + handshake_id: id, + state: HandshakeState.ACTIVE, + same_principal: true, + relationship_id: 'rel-tax', + local_role: 'initiator', + initiator_device_role: 'host', + acceptor_device_role: 'sandbox', + initiator_coordination_device_id: getInstanceId(), + acceptor_coordination_device_id: 'dev-sandbox-peer', + internal_coordination_identity_complete: true, + p2p_endpoint: 'http://127.0.0.1:51249/beap/ingest', + local_x25519_public_key_b64: 'bG9jYWx4MjU1MTk=', + peer_x25519_public_key_b64: 'cGVlcngyNTUxOQ==', + peer_mlkem768_public_key_b64: 'bWxrZW0xMjM=', + initiator: { wrdesk_user_id: 'u-tax', email: 'h@example.com' }, + acceptor: { wrdesk_user_id: 'u-tax', email: 'h@example.com' }, + internal_peer_pairing_code: '123456', + } as HandshakeRecord +} + +function entry(id = 'hs-tax'): InternalSandboxListEntry { + return { + handshake_id: id, + relationship_id: 'rel-tax', + state: 'ACTIVE', + peer_role: 'sandbox', + peer_label: 'Sandbox', + peer_device_id: 'dev-sb', + peer_device_name: 'Tax Sandbox', + peer_pairing_code_six: '123456', + internal_coordination_identity_complete: true, + p2p_endpoint_set: true, + last_known_delivery_status: 'idle', + live_status_optional: 'relay_connected', + sandbox_keying_complete: true, + beap_clone_eligible: true, + } +} + +describe('inbox-read error taxonomy', () => { + let ctx: SealedStorageTestContext + + beforeEach(() => { + ctx = createSealedStorageTestContext() + listAvailableInternalSandboxes.mockReset() + getHandshakeRecord.mockReset() + unbindKeyProvider('inner') + unbindKeyProvider('outer') + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + const e = entry() + listAvailableInternalSandboxes.mockReturnValue({ + success: true, + sandboxes: [e], + incomplete: [], + sandbox_availability: { status: 'connected', relay_connected: true, use_coordination: true }, + authoritative_device_internal_role: 'host', + }) + getHandshakeRecord.mockReturnValue(record(e.handshake_id)) + }) + + afterEach(() => ctx.cleanup()) + + function insert(opts: { depackaged: string | null; validSeal: boolean }): string { + const msgId = randomUUID() + const dep = opts.depackaged + const s = opts.validSeal + ? computeSeal(dep ?? '', msgId, 'outer') + : computeSeal('a completely different canonical body', msgId, 'outer') + ctx.db! + .prepare( + `INSERT INTO inbox_messages + (id, source_type, handshake_id, subject, body_text, depackaged_json, + has_attachments, from_address, account_id, received_at, ingested_at, + seal, seal_input_json, seal_key_source) + VALUES (?, 'direct_beap', 'hs-orig', 'Taxonomy', 'body', ?, + 0, 'from@test', 'acc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', + ?, ?, 'ledger')`, + ) + .run(msgId, dep, s.seal, s.seal_input_json) + return msgId + } + + it('genuinely absent row → MESSAGE_NOT_FOUND', () => { + if (!ctx.db) return + const r = prepareBeapInboxSandboxClone(ctx.db as any, session, randomUUID(), 'hs-tax', 'tag') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.code).toBe('MESSAGE_NOT_FOUND') + // The copy no longer hedges with "or could not be verified". + expect(r.error).not.toMatch(/could not be verified/i) + } + }) + + it('present but seal does not verify → SOURCE_UNVERIFIABLE', () => { + if (!ctx.db) return + const id = insert({ + depackaged: JSON.stringify({ body: { text: 'present but tampered' } }), + validSeal: false, + }) + const r = prepareBeapInboxSandboxClone(ctx.db as any, session, id, 'hs-tax', 'tag') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.code).toBe('SOURCE_UNVERIFIABLE') + expect(r.error).toMatch(/could not be verified/i) + } + }) + + it('present with no canonical plaintext → SOURCE_NO_CANONICAL_CONTENT', () => { + if (!ctx.db) return + const id = insert({ depackaged: null, validSeal: true }) + const r = prepareBeapInboxSandboxClone(ctx.db as any, session, id, 'hs-tax', 'tag') + expect(r.ok).toBe(false) + if (!r.ok) expect(r.code).toBe('SOURCE_NO_CANONICAL_CONTENT') + }) + + it('absence of content outranks unverifiability', () => { + if (!ctx.db) return + // Both defects at once: you cannot verify what is not there, and "no + // content yet" is the actionable thing to tell the operator. + const id = insert({ depackaged: null, validSeal: false }) + const r = prepareBeapInboxSandboxClone(ctx.db as any, session, id, 'hs-tax', 'tag') + expect(r.ok).toBe(false) + if (!r.ok) expect(r.code).toBe('SOURCE_NO_CANONICAL_CONTENT') + }) + + it('the three codes are distinct and none is the old conflated string', () => { + if (!ctx.db) return + const absent = prepareBeapInboxSandboxClone(ctx.db as any, session, randomUUID(), 'hs-tax', 'tag') + const unverifiable = prepareBeapInboxSandboxClone( + ctx.db as any, + session, + insert({ depackaged: JSON.stringify({ body: { text: 'x' } }), validSeal: false }), + 'hs-tax', + 'tag', + ) + const noContent = prepareBeapInboxSandboxClone( + ctx.db as any, + session, + insert({ depackaged: null, validSeal: true }), + 'hs-tax', + 'tag', + ) + const codes = [absent, unverifiable, noContent].map((r) => (r.ok ? 'ok' : r.code)) + expect(new Set(codes).size).toBe(3) + expect(codes).toEqual([ + 'MESSAGE_NOT_FOUND', + 'SOURCE_UNVERIFIABLE', + 'SOURCE_NO_CANONICAL_CONTENT', + ]) + }) + + it('no production caller branches on the old conflated meaning', async () => { + const { readFileSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + const prepare = readFileSync(join(here, '..', 'beapInboxClonePrepare.ts'), 'utf8') + // Exactly one MESSAGE_NOT_FOUND return remains, and it is the absent-row one. + const returns = prepare.match(/code: 'MESSAGE_NOT_FOUND'/g) ?? [] + expect(returns).toHaveLength(1) + expect(prepare).toMatch(/code: 'SOURCE_UNVERIFIABLE'/) + expect(prepare).toMatch(/code: 'SOURCE_NO_CANONICAL_CONTENT'/) + // The hedging copy is gone from the absent-row branch. + expect(prepare).not.toMatch(/'Inbox message was not found or could not be verified\.'/) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/messageRouter.depackageSeam.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/messageRouter.depackageSeam.test.ts index cd0781305..536441d96 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/messageRouter.depackageSeam.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/messageRouter.depackageSeam.test.ts @@ -192,7 +192,14 @@ describe.skipIf(!Database)('B2 depackage-seam consumer (flag-on, in-process)', ( const raw: any = { messageId: 'ext-3', from: { address: 'a@b.com' }, to: [], subject: 's', date: new Date().toISOString(), - rawRfc822: eml(['Subject: pkg', 'Content-Type: text/plain'], PBEAP_PKG), + // [Order 02 / 2A] The carrier path is reachable only for a + // channel-authenticated message, so the fixture now carries an aligned + // DKIM pass. Without it this asserts the suppression case below, not the + // carrier case it is named for. + rawRfc822: eml( + ['Subject: pkg', 'Content-Type: text/plain', 'Authentication-Results: mx.test; dkim=pass header.d=b.com'], + PBEAP_PKG, + ), } const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) expect(res.type).toBe('beap') @@ -200,6 +207,26 @@ describe.skipIf(!Database)('B2 depackage-seam consumer (flag-on, in-process)', ( expect(row.source_type).toBe('email_beap') }) + it('2A: identical carrier mail WITHOUT channel authentication is never parsed as BEAP', async () => { + // Same bytes as the test above minus the Authentication-Results header. A + // failing `channel_pass` (D5) short-circuits WR parsing and code extraction + // entirely: the package is not extracted, the row is plain, and no + // affordance is derived from it [Order 02 / 2A]. + const raw: any = { + messageId: 'ext-3-unauth', from: { address: 'a@b.com' }, to: [], subject: 's', + date: new Date().toISOString(), + rawRfc822: eml(['Subject: pkg', 'Content-Type: text/plain'], PBEAP_PKG), + } + const res = await detectAndRouteMessage(db, 'acc', raw, SESSION) + expect(res.type).toBe('plain') + const row = db.prepare('SELECT * FROM inbox_messages WHERE id = ?').get(res.inboxMessageId) as any + expect(row.source_type).not.toBe('email_beap') + // The CPR still rides along — the message is evidenced, it simply has no + // authenticated channel. + const meta = JSON.parse(row.depackaged_metadata) + expect(meta.channel_provenance.channel_pass).toBe(false) + }) + it('INV-7: ambiguous classification → quarantine with mapped reason', async () => { const weird = JSON.stringify({ header: { encoding: 'xBEAP' }, metadata: {}, payload: 'x' }) const raw: any = { diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/pbeapTrustPersistence.regression.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/pbeapTrustPersistence.regression.test.ts index 8db687a73..44ec97bb8 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/pbeapTrustPersistence.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/pbeapTrustPersistence.regression.test.ts @@ -77,8 +77,9 @@ import { classifyLivePbeapTrust } from '../../depackaging-microvm/livePbeapTrust import { processBeapPackageInline } from '../beapEmailIngestion' import { detectAndRouteMessageInline } from '../messageRouter' import { depackagedFormatFromJson } from '../../../../src/lib/inboxBeapRowEligibility' -import { migrateHandshakeTables } from '../../handshake/db' +import { migrateHandshakeTables, insertHandshakeRecord } from '../../handshake/db' import { migrateIngestionTables } from '../../ingestion/persistenceDb' +import { buildActiveHandshakeRecord } from '../../handshake/__tests__/helpers' const TEST_DEK = Buffer.from('00'.repeat(32), 'hex') @@ -103,6 +104,14 @@ function makeTestDb() { return db } +/** + * Phase 1 [VII.2.7]: the ingress admission filter is the first inbox stage; + * deliveries need an ACTIVE relationship row to be admitted at all. + */ +function seedActiveHandshake(db: any, hsId: string) { + insertHandshakeRecord(db, buildActiveHandshakeRecord({ handshake_id: hsId, relationship_id: `rel-${hsId}` })) +} + function makePBeapPackage(handshakeId: string): string { const capsule = { content_type: 'beap_message', subject: 'pBEAP trust', body: 'hi', sender: 'a@dev.test' } return JSON.stringify({ @@ -161,6 +170,7 @@ describe.skipIf(!Database)('pBEAP trust verdict persists to inbox_messages.depac describe('P2P relay path', () => { it('persists a verified_bound verdict', async () => { const hsId = randomUUID() + seedActiveHandshake(db, hsId) vi.mocked(classifyLivePbeapTrust).mockReturnValue(VERIFIED_BOUND(hsId)) const result = await processBeapPackageInline(db, makePBeapPackage(hsId), hsId, { sourceType: 'p2p', receivedAt: new Date().toISOString() }) expect(result.outcome).toBe('inbox') @@ -171,6 +181,7 @@ describe.skipIf(!Database)('pBEAP trust verdict persists to inbox_messages.depac it('persists a lesser (unverified_public) verdict', async () => { const hsId = randomUUID() + seedActiveHandshake(db, hsId) vi.mocked(classifyLivePbeapTrust).mockReturnValue(LESSER) const result = await processBeapPackageInline(db, makePBeapPackage(hsId), hsId, { sourceType: 'p2p', receivedAt: new Date().toISOString() }) expect(result.outcome).toBe('inbox') @@ -191,12 +202,17 @@ describe.skipIf(!Database)('pBEAP trust verdict persists to inbox_messages.depac text: makePBeapPackage(hsId), date: new Date().toISOString(), attachments: [], + // [Order 02 / 2A] BEAP detection runs only for a channel-authenticated + // message. These cases are about the pBEAP trust verdict, which only + // exists on the BEAP route, so the fixture carries an aligned DKIM pass. + headers: { authenticationResults: ['mx.test; dkim=pass header.d=dev.test'] }, } return detectAndRouteMessageInline(db, 'acc', raw as any, null, true) } it('persists a verified_bound verdict and preserves format routing', async () => { const hsId = randomUUID() + seedActiveHandshake(db, hsId) vi.mocked(classifyLivePbeapTrust).mockReturnValue(VERIFIED_BOUND(hsId)) const res = await ingestEmail(hsId) expect(res.type).toBe('beap') @@ -212,6 +228,7 @@ describe.skipIf(!Database)('pBEAP trust verdict persists to inbox_messages.depac it('persists a lesser (unverified_public) verdict', async () => { const hsId = randomUUID() + seedActiveHandshake(db, hsId) vi.mocked(classifyLivePbeapTrust).mockReturnValue(LESSER) const res = await ingestEmail(hsId) expect(res.type).toBe('beap') @@ -229,6 +246,7 @@ describe.skipIf(!Database)('pBEAP trust verdict persists to inbox_messages.depac it('P2P: an unaltered row verifies; editing depackaged_metadata is rejected', async () => { const hsId = randomUUID() + seedActiveHandshake(db, hsId) vi.mocked(classifyLivePbeapTrust).mockReturnValue(LESSER) const result = await processBeapPackageInline(db, makePBeapPackage(hsId), hsId, { sourceType: 'p2p', receivedAt: new Date().toISOString() }) expect(result.outcome).toBe('inbox') @@ -251,10 +269,12 @@ describe.skipIf(!Database)('pBEAP trust verdict persists to inbox_messages.depac it('email: an unaltered row verifies; editing depackaged_metadata is rejected', async () => { const hsId = randomUUID() + seedActiveHandshake(db, hsId) vi.mocked(classifyLivePbeapTrust).mockReturnValue(LESSER) const raw = { messageId: `mail-tamper-${hsId}`, from: { address: 'a@dev.test' }, to: [], subject: 'pBEAP', text: makePBeapPackage(hsId), date: new Date().toISOString(), attachments: [], + headers: { authenticationResults: ['mx.test; dkim=pass header.d=dev.test'] }, } const res = await detectAndRouteMessageInline(db, 'acc', raw as any, null, true) expect(res.type).toBe('beap') diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/pr52CloneDeterminism.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/pr52CloneDeterminism.test.ts index 21834d4ee..8b9bad28a 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/pr52CloneDeterminism.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/pr52CloneDeterminism.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { HandshakeState, type HandshakeRecord, type SSOSession } from '../../handshake/types' +import { getInstanceId } from '../../orchestrator/orchestratorModeStore' import type { InternalSandboxListEntry } from '../../handshake/internalSandboxesApi' // ── mocks ──────────────────────────────────────────────────────────────────── @@ -57,11 +58,14 @@ function makeHandshakeRecord(id: string): HandshakeRecord { return { handshake_id: id, state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, relationship_id: 'rel-pr52', local_role: 'initiator', initiator_device_role: 'host', acceptor_device_role: 'sandbox', + // Host/sandbox roles are derived from coordination device ids, not local_role. + initiator_coordination_device_id: getInstanceId(), + acceptor_coordination_device_id: 'dev-sandbox-peer', internal_coordination_identity_complete: true, p2p_endpoint: 'p2p://sandbox-52', local_x25519_public_key_b64: 'bG9jYWx4MjU1MTk=', diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/provenanceGatesParsing.guard.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/provenanceGatesParsing.guard.test.ts new file mode 100644 index 000000000..b79e0d866 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/provenanceGatesParsing.guard.test.ts @@ -0,0 +1,129 @@ +/** + * Guard — provenance gates parsing, and the gate is at the HOST boundary + * (Order 02 / 2A; contradictions G4-2, G4-5). + * + * Three invariants are pinned here, two behavioural and one source-walking, + * because each can be broken independently: + * + * 1. No WR parse or code extraction happens for a message whose `channel_pass` + * is false. Enforced structurally: the detector is not called, rather than + * called and its result discarded. + * + * 2. No affordance is derived from a provenance-failed message — it lands as a + * plain row carrying its CPR, indistinguishable downstream from mail that + * genuinely contained nothing. + * + * 3. Guest package output is UNTRUSTED PAYLOAD. The depackaging guest parses + * hostile bytes — that is what it exists for — and header parsing is a + * precondition of provenance evaluation, so "no parse before provenance" + * can never be literal in-guest. The author's ruling: the guest MAY detect, + * the host MUST NOT act. Trust verdicts are computed host-side, never in + * the least-trusted environment of the pipeline, and every consumer of + * guest package output sits behind the host gate. + */ + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { fileURLToPath } from 'url' + +const here = fileURLToPath(new URL('.', import.meta.url)) +const routerSource = readFileSync(resolve(here, '../messageRouter.ts'), 'utf8') + +describe('2A — detection is gated on channel_pass, structurally', () => { + it('the detector is called exactly once, and only inside the gate', () => { + const calls = routerSource.match(/detectBeapPackageFromMessage\(/g) ?? [] + // One definition, one call site. + expect(calls.length, 'detector referenced more than at its definition and single gated call').toBe(2) + + const gate = routerSource.indexOf('channelProvenance.channel_pass') + const call = routerSource.indexOf('? detectBeapPackageFromMessage(') + expect(gate, 'no channel_pass gate found').toBeGreaterThan(-1) + expect(call, 'detector is not called from the gated ternary').toBeGreaterThan(-1) + expect(call, 'detector call does not sit under the channel_pass gate').toBeGreaterThan(gate) + }) + + it('the CPR is produced before the detector is reached', () => { + const produce = routerSource.indexOf('produceChannelProvenance({') + const call = routerSource.indexOf('? detectBeapPackageFromMessage(') + expect(produce).toBeGreaterThan(-1) + expect(produce, 'detection precedes CPR production in source order').toBeLessThan(call) + }) + + it('a provenance-failed message resolves to the same shape as found-nothing', () => { + // NO_DETECTION must not carry a marker a later change could branch on to + // resurrect an affordance. Only the CPR distinguishes the two cases. + const block = routerSource.slice( + routerSource.indexOf('const NO_DETECTION'), + routerSource.indexOf('})', routerSource.indexOf('const NO_DETECTION')), + ) + expect(block).toMatch(/beapPackageJson:\s*null/) + expect(block).toMatch(/handshakeId:\s*null/) + expect(block).toMatch(/detectedType:\s*'plain'/) + expect(block).not.toMatch(/suppressed|blocked|provenance|reason/i) + }) +}) + +describe('2A — the fail-open degradations are closed', () => { + // The quarantine path is entered when a depackage produced something we could + // not validate. Falling back to a plain inbox row there presents unvalidated + // carrier content as ordinary mail. + // Bounded to the quarantine path itself. The plain-email branch that follows + // it uses `buildPlainEmailInboxPayload` legitimately — that is the ordinary + // route for mail that never was a carrier. + const quarantineBlock = routerSource.slice( + routerSource.indexOf('// ── Quarantine path ──'), + routerSource.indexOf('// ── Plain email path'), + ) + + it('the quarantine path contains no plain-inbox fallback', () => { + expect(quarantineBlock.length).toBeGreaterThan(0) + expect(quarantineBlock).not.toMatch(/buildPlainEmailInboxPayload/) + }) + + it('all three failure conditions hold instead of degrading', () => { + for (const reason of ['no_paired_sandbox', 'quarantine_seal_failed', 'quarantine_validator_rejected']) { + expect(quarantineBlock, `${reason} does not fail closed`).toContain(reason) + } + const held = quarantineBlock.match(/throw new DepackageCutoverHeldError\(/g) ?? [] + expect(held.length, 'expected all three branches to hold').toBe(3) + }) + + it('the inline path holds on the same conditions the seam path already did', () => { + // An invariant that holds on one of two paths is not an invariant. If the + // seam path ever stops holding, this fails too. + const seam = routerSource.slice(routerSource.indexOf('async function quarantineRawBytes')) + for (const reason of ['quarantine_seal_failed', 'quarantine_validator_rejected']) { + expect(seam, `seam path no longer holds on ${reason}`).toContain(reason) + } + }) +}) + +describe('2A — guest package output is untrusted payload behind the host gate', () => { + it('trust verdicts are never computed in the guest', () => { + // The guest is the least-trusted environment in the pipeline. A + // sandbox-computed `channel_pass` would have to be re-verified host-side + // anyway, so it must not exist. + for (const file of ['../../depackaging-microvm/emailDepackage.ts', '../../depackaging-microvm/depackageModel.ts']) { + const source = readFileSync(resolve(here, file), 'utf8') + expect(source, `${file} computes a channel verdict in-guest`).not.toMatch( + /computeChannelPass|evaluateChannelAuthentication|channel_pass/, + ) + } + }) + + it('the guest hands over material, not a verdict', () => { + // `channelAuthentication` carries capped strings for the host to evaluate. + const model = readFileSync(resolve(here, '../../depackaging-microvm/depackageModel.ts'), 'utf8') + expect(model).toMatch(/channelAuthentication/) + }) + + it('every consumer of guest packages is reached through the gated router', () => { + // The seam re-enters `detectAndRouteMessageInline`, which is gated. If a + // future change consumes `result.packages` anywhere else, this catches it. + const seamRegion = routerSource.slice(routerSource.indexOf('const guestMaterial')) + const packageUses = seamRegion.match(/result\.packages/g) ?? [] + expect(packageUses.length, 'guest packages consumed in unexpected places').toBeLessThanOrEqual(2) + expect(seamRegion).toContain('detectAndRouteMessageInline(db, accountId, beapRawMsg, session, true)') + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/email/__tests__/sandboxIngestionProduction.test.ts b/code/apps/electron-vite-project/electron/main/email/__tests__/sandboxIngestionProduction.test.ts index 2b12f2330..e1a09e3b0 100644 --- a/code/apps/electron-vite-project/electron/main/email/__tests__/sandboxIngestionProduction.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/__tests__/sandboxIngestionProduction.test.ts @@ -33,7 +33,7 @@ const h = vi.hoisted(() => { return { handshake_id: 'hs-prod-a2', state: 'ACTIVE', - handshake_type: 'internal', + same_principal: true, internal_coordination_identity_complete: true, initiator_coordination_device_id: 'dev-sandbox', acceptor_coordination_device_id: 'dev-host', diff --git a/code/apps/electron-vite-project/electron/main/email/beapEmailIngestion.ts b/code/apps/electron-vite-project/electron/main/email/beapEmailIngestion.ts index 4595ed03b..e72c664dc 100644 --- a/code/apps/electron-vite-project/electron/main/email/beapEmailIngestion.ts +++ b/code/apps/electron-vite-project/electron/main/email/beapEmailIngestion.ts @@ -678,7 +678,8 @@ export interface P2PInlineResult { outcome: 'inbox' | 'quarantine' | 'error' rowId?: string error?: string - reasonCode?: ReasonCode + /** Vault capability code, or an ingress admission block [VII.2.7]. */ + reasonCode?: ReasonCode | `ingress_admission_${string}` retryable?: boolean } @@ -687,6 +688,7 @@ function buildP2PProvenance( transportSender: string | null, sourceType: ProvenanceMetadata['source_type'], packageJson: string, + grantRef?: string | null, ): ProvenanceMetadata { return { source_type: sourceType, @@ -695,6 +697,8 @@ function buildP2PProvenance( transport_metadata: { sender_address: transportSender ?? undefined, message_id: handshakeId, + // Phase 5 [VII.10.3]: grant the delivery was admitted under. + grant_ref: grantRef ?? undefined, }, input_classification: 'beap_capsule_present', raw_input_hash: createHash('sha256').update(packageJson, 'utf8').digest('hex'), @@ -995,6 +999,35 @@ async function processBeapPackageInlineInternal( console.log(`[BEAP_DELIVERY] native_message_received messageId=${rowId} handshake=${handshakeId} sourceType=${sourceType}`) + // ── Stage 0: ingress admission filter [VII.2.7] ────────────────────────── + // First ingress stage for every inbox delivery: relationship must exist and + // be live, and the delivery is admitted under the relationship's DELIVERY + // grant (Phase 5, E2 [VII.10.2]). Blocked transmissions die pre-visibility — + // no inbox row, no placeholder, no dashboard notification; audit_log and + // the evidence chain carry the record. + let admittedGrantRef: string | null = null + { + const { admitInboundDelivery } = await import('../handshake/ingressAdmission') + const admission = admitInboundDelivery(db, { + handshakeId, + kind: 'beap_message', + source: sourceType, + }) + if (!admission.admitted) { + console.log( + `[BEAP_DELIVERY] receive_blocked messageId=${rowId} reason=ingress_admission:${admission.reason}`, + ) + return { + outcome: 'error', + rowId, + error: `Inbound delivery refused: ${admission.reason}`, + reasonCode: `ingress_admission_${admission.reason}`, + retryable: false, + } + } + admittedGrantRef = admission.grantRef + } + // ── Parse outer package ────────────────────────────────────────────────── let pkg: Record let pkgEncoding: string | undefined @@ -1078,7 +1111,7 @@ async function processBeapPackageInlineInternal( packageJson, { id: rowId, subject: preview.subject, from_address: transportSender, body_text: preview.body_text }, ) - const provenance = buildP2PProvenance(handshakeId, transportSender, sourceType, packageJson) + const provenance = buildP2PProvenance(handshakeId, transportSender, sourceType, packageJson, admittedGrantRef) const resp = await validatorOrchestrator.validate({ envelope: pkg, plaintext_or_encrypted: { kind: 'plaintext', content: dpJson }, @@ -1250,7 +1283,7 @@ async function processBeapPackageInlineInternal( // validation routes through the critical-job dispatcher (in-process → same // forked validator subprocess; byte-identical parity). Flag OFF runs the // original inline call. The qBEAP/pBEAP decrypt above stays untouched. - const provenance = buildP2PProvenance(handshakeId, transportSender, sourceType, packageJson) + const provenance = buildP2PProvenance(handshakeId, transportSender, sourceType, packageJson, admittedGrantRef) const validationInput = { envelope: pkg, plaintext_or_encrypted: { kind: 'plaintext' as const, content: depackagedJsonForRow }, diff --git a/code/apps/electron-vite-project/electron/main/email/beapInboxClonePrepare.ts b/code/apps/electron-vite-project/electron/main/email/beapInboxClonePrepare.ts index 316178c15..33636cceb 100644 --- a/code/apps/electron-vite-project/electron/main/email/beapInboxClonePrepare.ts +++ b/code/apps/electron-vite-project/electron/main/email/beapInboxClonePrepare.ts @@ -80,7 +80,31 @@ export type BeapInboxClonePrepareOptions = { /** Structured failure for `inbox:cloneBeapToSandbox` / prepare (UI + logs). */ export type BeapInboxCloneErrorCode = + /** + * The row is genuinely absent from `inbox_messages`. + * + * Narrowed by the error-taxonomy split: this code used to also cover a row + * that existed but could not be verified, and a row that existed with no + * canonical plaintext. Collapsing three states into one meant neither an + * operator nor a log could tell a missing message from a tampered one from a + * key-availability problem — the Phase-2 diagnosis needed a probe to find out + * which had happened. The two siblings below split those out. + */ | 'MESSAGE_NOT_FOUND' + /** + * The row exists but `sealedQuery` returned nothing for it: a bad seal, a + * content-hash mismatch, or no usable provider for its `seal_key_source`. + * Distinct from absence because the row is there and something is wrong with + * it or with the key situation — a materially different operator story. + */ + | 'SOURCE_UNVERIFIABLE' + /** + * The row exists but carries no canonical plaintext (`depackaged_json` is + * absent), so there is nothing a clone could copy. Production writes this + * shape for rows that genuinely have no plaintext yet — qBEAP pending main + * decode and main-process decode errors. + */ + | 'SOURCE_NO_CANONICAL_CONTENT' | 'MESSAGE_CONTENT_NOT_EXTRACTABLE' | 'NO_ACTIVE_SANDBOX_HANDSHAKE' | 'INCOMPLETE_SANDBOX_KEYING' @@ -405,7 +429,7 @@ export function readInboxRowForClonePrepare( result: { ok: false, code: 'MESSAGE_NOT_FOUND', - error: 'Inbox message was not found or could not be verified.', + error: 'Inbox message was not found.', }, } } @@ -466,19 +490,57 @@ export function readInboxRowForClonePrepare( } } + // The row IS present (the existence probe above passed) but `sealedQuery` + // returned nothing. Split the two reasons that hide behind that, so the log + // and the caller both say which one happened. + const canonicalPresent = typeof rawCanonicalColumn(db, srcId) === 'string' + + if (!canonicalPresent) { + console.log( + `[CLONE_PREPARE] source_no_canonical_content sourceMessageId=${srcId} seal_key_source=${sealKeySource ?? 'unknown'}`, + ) + return { + ok: false, + result: { + ok: false, + code: 'SOURCE_NO_CANONICAL_CONTENT', + error: 'This message has no decrypted content yet, so there is nothing to clone.', + }, + } + } + console.log( - `[CLONE_PREPARE] source_read_blocked sourceMessageId=${srcId} seal_key_source=${sealKeySource ?? 'unknown'} requiresInnerVault=${requiresInnerVault}`, + `[CLONE_PREPARE] source_unverifiable sourceMessageId=${srcId} seal_key_source=${sealKeySource ?? 'unknown'} requiresInnerVault=${requiresInnerVault}`, ) return { ok: false, result: { ok: false, - code: 'MESSAGE_NOT_FOUND', - error: 'Inbox message was not found or could not be verified.', + code: 'SOURCE_UNVERIFIABLE', + error: 'Inbox message could not be verified.', }, } } +/** + * Read the canonical column WITHOUT seal verification, for classification only. + * + * This is deliberately not a content read: the value is never returned to a + * caller, only its presence is, so it distinguishes "no plaintext exists" from + * "plaintext exists but did not verify". Returning unverified content here + * would defeat the gate this function sits behind. + */ +function rawCanonicalColumn(db: any, srcId: string): unknown { + try { + const row = db + .prepare('SELECT depackaged_json FROM inbox_messages WHERE id = ?') + .get(srcId) as { depackaged_json?: unknown } | undefined + return row?.depackaged_json ?? null + } catch { + return null + } +} + /** * Inbox list / query is the access boundary. Prepare does not re-check row `account_id` or * email/BEAP identities against the session; isolation belongs in listing and storage. diff --git a/code/apps/electron-vite-project/electron/main/email/beapSync.ts b/code/apps/electron-vite-project/electron/main/email/beapSync.ts index aa7491d16..57ae07355 100644 --- a/code/apps/electron-vite-project/electron/main/email/beapSync.ts +++ b/code/apps/electron-vite-project/electron/main/email/beapSync.ts @@ -398,10 +398,20 @@ export async function runBeapSyncCycle( } /** - * Start periodic BEAP email sync. + * RETIRED — Phase 5 (5A). Do not revive; do not call. * - * Polls configured accounts at the specified interval and submits any - * detected BEAP capsules to the ingestion pipeline. + * Polls configured accounts and submits detected BEAP capsules to ingestion. + * It has had **no caller** since before this slice began: the live email path + * runs through `syncOrchestrator` → `messageRouter`, which is where Phase 2 + * put the provenance gate and where 5A attaches the email→offer route. + * + * Retired explicitly rather than deleted, per the order's choice: the module + * still exports `setEmailFunctions`, which main.ts uses, so removing the file + * is a wider change than this phase's scope. Retiring it in place records WHY + * it must stay dead — a second, ungated ingest path would bypass the CPR gate + * entirely, which is exactly the fail-open Phase 2 closed. + * + * @deprecated Retired in Phase 5; the sync path is `syncOrchestrator`. */ export function startBeapEmailSync( config: BeapSyncConfig, diff --git a/code/apps/electron-vite-project/electron/main/email/channelProvenanceProducer.ts b/code/apps/electron-vite-project/electron/main/email/channelProvenanceProducer.ts new file mode 100644 index 000000000..c14d68e29 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/email/channelProvenanceProducer.ts @@ -0,0 +1,145 @@ +/** + * Channel Provenance Record producer — build item 2 [IX.3.1, IX.11] + * + * The SINGLE place a CPR is created, persisted, and evidenced. Every ingest + * path calls this, and a guard test proves no path constructs a record on its + * own — one producer means one set of rules, so "what does this deployment + * consider an authenticated channel" has exactly one answer. + * + * A CPR is produced for EVERY processed message. When there is nothing to + * evaluate the verdict is `unverifiable`, which is a real verdict and fails + * closed; a message with no CPR at all would be indistinguishable from one + * that skipped the producer, so that state does not exist. + * + * Phase 1 fidelity: the producer consumes the authentication material the mail + * pipeline already surfaces (`Authentication-Results` where the receiving + * gateway wrote one). Discovery-Record evaluation needs DNS in main and lands + * in Phase 3 — until then the field stays `not_evaluated` and is never + * fabricated. Gating the pipeline on `channel_pass` is Phase 2's job; Phase 1 + * only produces, persists, and evidences the verdict. + */ + +import { createHash } from 'crypto' +import { + channelProvenanceMetadata, + createChannelProvenanceRecord, + type ChannelProvenanceRecord, +} from '@repo/ingestion-core' +import { appendEvidenceBestEffort, LOCAL_EVIDENCE_CHAIN } from '../handshake/evidenceChain' + +/** Which ingest path produced the record — evidence metadata, never a gate. */ +export type ChannelProvenanceIngestPath = 'inline' | 'seam' | 'seam_carrier' | 'p2p' + +export interface ChannelProvenanceSource { + /** + * `Authentication-Results` values, already collected and capped at the + * depackaging boundary. Read here and discarded; nothing from them reaches + * the record. + */ + authenticationResults?: readonly string[] + /** RFC5322.From address or domain, for alignment. */ + fromAddress?: string | null + /** Bytes the verdict is bound to; hashed by `channelProvenanceContentHash`. */ + contentSha256: string +} + +/** + * Content binding for the record. The raw provider payload is the truest + * binding; without it we bind the identity + rendered body, which is what a + * later reader would compare a stored verdict against. + */ +export function channelProvenanceContentHash(parts: { + rawBytes?: Buffer | string | null + messageId?: string + subject?: string + bodyText?: string +}): string { + const hash = createHash('sha256') + if (parts.rawBytes != null && parts.rawBytes.length > 0) { + hash.update(Buffer.isBuffer(parts.rawBytes) ? parts.rawBytes : Buffer.from(parts.rawBytes, 'utf-8')) + return hash.digest('hex') + } + hash.update(`${parts.messageId ?? ''}\n${parts.subject ?? ''}\n${parts.bodyText ?? ''}`, 'utf8') + return hash.digest('hex') +} + +/** Build the record. The only constructor any ingest path may call. */ +export function produceChannelProvenance(source: ChannelProvenanceSource): ChannelProvenanceRecord { + return createChannelProvenanceRecord({ + contentSha256: source.contentSha256, + material: { + authenticationResults: source.authenticationResults, + fromDomain: source.fromAddress ?? null, + }, + }) +} + +/** + * Merge the CPR into a `depackaged_metadata` blob beside `pbeap_trust`. The + * result is the string that is BOTH persisted and bound into the seal, so a + * post-write edit of either verdict fails seal verification at read time. + */ +export function mergeChannelProvenanceMetadata( + existingMetadataJson: string | null | undefined, + record: ChannelProvenanceRecord, +): string { + let base: Record = {} + if (typeof existingMetadataJson === 'string' && existingMetadataJson.trim() !== '') { + try { + const parsed = JSON.parse(existingMetadataJson) + if (typeof parsed === 'object' && parsed !== null) base = parsed as Record + } catch { + // A metadata blob we cannot read is not silently dropped: keep it under a + // quarantine key so the CPR can still be persisted without losing it. + base = { unparsable_metadata: existingMetadataJson } + } + } + return JSON.stringify({ ...base, ...channelProvenanceMetadata(record) }) +} + +/** + * Retain the verdict in the append-only evidence chain [IX.11]. + * + * Class: BER — a message crossing the ingress boundary IS a boundary event, + * and the CPR is the verdict at that crossing. This makes Phase 1 the first + * BER writer (the schema note in `evidenceChain.ts` anticipated Phase 6). + * + * Metadata only: identifiers, digests, and typed verdicts. No subject, no + * body, no addresses beyond the domain the channel actually authenticated. + */ +export function recordChannelProvenanceEvidence(args: { + record: ChannelProvenanceRecord + messageId: string + /** inbox_messages.id or quarantine_messages.id, once known. */ + rowId: string | null + path: ChannelProvenanceIngestPath + outcome: 'inbox' | 'quarantine' | 'held' +}): void { + const { record } = args + appendEvidenceBestEffort({ + chainId: LOCAL_EVIDENCE_CHAIN, + recordType: 'ber', + payload: { + kind: 'channel_provenance', + direction: 'ingress', + channel: 'email', + ingest_path: args.path, + outcome: args.outcome, + message_id: args.messageId, + row_id: args.rowId, + marking_scheme: record.marking_scheme, + producer_version: record.producer_version, + evaluated_at: record.evaluated_at, + content_sha256: record.content_sha256, + spf: record.spf.verdict, + spf_aligned: record.spf.aligned, + dkim: record.dkim.verdict, + dkim_aligned: record.dkim.aligned, + dmarc: record.dmarc.verdict, + dmarc_aligned: record.dmarc.aligned, + channel_pass: record.channel_pass, + authenticated_sender_domain: record.authenticated_sender_domain, + discovery_record: record.discovery_record, + }, + }) +} diff --git a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/dedicatedHostIngestionPollTrigger.test.ts b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/dedicatedHostIngestionPollTrigger.test.ts index 1bd6099ce..ae7c8db64 100644 --- a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/dedicatedHostIngestionPollTrigger.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/dedicatedHostIngestionPollTrigger.test.ts @@ -90,7 +90,7 @@ function hostToSandboxRecord(over: Partial = {}): HandshakeReco initiator: party('u1'), acceptor: party('u1'), local_role: 'initiator', - handshake_type: 'internal', + same_principal: true, internal_coordination_identity_complete: true, internal_coordination_repair_needed: false, initiator_coordination_device_id: 'dev-ws-1', diff --git a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayCapsuleHandler.test.ts b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayCapsuleHandler.test.ts index 2c29e93da..96a2bb610 100644 --- a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayCapsuleHandler.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayCapsuleHandler.test.ts @@ -56,7 +56,7 @@ function party(uid: string) { const hostRecord = { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: 'initiator', initiator: party('u1'), @@ -74,7 +74,7 @@ const hostRecord = { const sandboxRecord = { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: 'acceptor', initiator: party('u1'), diff --git a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayResultCapsuleHandler.test.ts b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayResultCapsuleHandler.test.ts index 292e23f42..f52a9dc73 100644 --- a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayResultCapsuleHandler.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/relayResultCapsuleHandler.test.ts @@ -74,7 +74,7 @@ function party(uid: string) { const hostRecord = { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: 'initiator', initiator: party('u1'), @@ -92,7 +92,7 @@ const hostRecord = { const sandboxRecord = { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: 'acceptor', initiator: party('u1'), diff --git a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/sealedRelayHostPollTrigger.test.ts b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/sealedRelayHostPollTrigger.test.ts index bdb0faec3..f370c47b2 100644 --- a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/sealedRelayHostPollTrigger.test.ts +++ b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/__tests__/sealedRelayHostPollTrigger.test.ts @@ -104,7 +104,7 @@ function hostToSandboxRecord(over: Partial = {}): HandshakeReco initiator: party('u1'), acceptor: party('u1'), local_role: 'initiator', - handshake_type: 'internal', + same_principal: true, internal_coordination_identity_complete: true, internal_coordination_repair_needed: false, initiator_coordination_device_id: 'dev-ws-1', diff --git a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/hostTrigger.ts b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/hostTrigger.ts index 60a10e6e7..bd27d3a15 100644 --- a/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/hostTrigger.ts +++ b/code/apps/electron-vite-project/electron/main/email/ingestionPollTrigger/hostTrigger.ts @@ -125,7 +125,7 @@ export function findActiveHostToSandboxHandshakeRecord(db: unknown): HandshakeRe const localId = getInstanceId().trim() const rows = listHandshakeRecords(db as never, { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) for (const r of rows) { const dr = deriveInternalHostAiPeerRoles(r, localId) diff --git a/code/apps/electron-vite-project/electron/main/email/ipc.ts b/code/apps/electron-vite-project/electron/main/email/ipc.ts index fa8810e9c..98f7e6d7a 100644 --- a/code/apps/electron-vite-project/electron/main/email/ipc.ts +++ b/code/apps/electron-vite-project/electron/main/email/ipc.ts @@ -297,7 +297,11 @@ import { import { resolveInboxReplyMode } from '../../../src/lib/inboxAiCloneClassification' import { reconcileAnalyzeTriage, reconcileInboxClassification } from '../../../src/lib/inboxClassificationReconcile' import { streamInboxOllamaAnalyzeWithSandboxRouting } from './inboxOllamaChatStreamSandbox' -import { appendScamWatchdogToSystemPrompt, buildScamWatchdogUserContext } from './scamWatchdog' +import { + appendScamWatchdogToSystemPrompt, + buildScamWatchdogUserContext, + channelProvenanceAnalysisInput, +} from './scamWatchdog' import { assembleScamWatchdog } from '../../../src/utils/parseInboxAiJson' import { buildInboxAiAnalyzeErrorPayload, buildInboxAiDraftIpcFailure } from './inboxAiErrorMapping' import { formatSourceWeightingForPrompt, sortSourceWeightingFromMessageRow } from '../../../src/lib/inboxSortSourceWeighting' @@ -312,9 +316,24 @@ import { } from './inboxSealedRead' import { readDecryptedAttachmentBuffer, type AttachmentRowCrypto } from './attachmentBlobCrypto' import { inboxLlmChat, isLlmAvailable, INBOX_LLM_LOCAL_TIMEOUT_MS, INBOX_LLM_MAX_OUTPUT_TOKENS, resolveInboxLlmSettings, preResolveInboxLlm, type ResolvedLlmContext } from './inboxLlmChat' +import { attachAndLogProvenance } from '../aiProvenance/attachProvenance' import { EMPTY_LLM_RESPONSE_ERROR } from '../llm/llamaChatResponseContent' import { maybePrewarmLocalLlmForBulkClassify, type LocalLlmBulkPrewarmDiag } from '../llm/localLlmBulkPrewarm' +/** Map inbox LLM provider string to AiProvenance provider string. */ +function inboxProviderForProvenance(): { model_id: string; provider: string } { + try { + const s = resolveInboxLlmSettings() + const pLower = (s.provider ?? 'ollama').toLowerCase() + return { + model_id: s.model ?? 'unknown', + provider: pLower === 'ollama' ? 'local' : `cloud:${pLower}`, + } + } catch { + return { model_id: 'unknown', provider: 'local' } + } +} + /** Per-page strings from DB `extracted_text` (extraction joins pages with \\n\\n). */ function inboxPagesFromStoredExtractedText(text: string): string[] { const t = typeof text === 'string' ? text : '' @@ -1992,6 +2011,7 @@ Rules: try { const rawStr = (await inboxLlmChat({ system: systemPrompt, user: userPrompt, contentTask: { kind: 'summary' } })).trim() + const summaryProv = attachAndLogProvenance(rawStr, inboxProviderForProvenance()) const parsed = parseAiJson(rawStr) if (!parsed || Object.keys(parsed).length === 0) throw new Error('Failed to parse summary JSON') @@ -2008,7 +2028,7 @@ Rules: typeof parsed.patterns_note === 'string' && parsed.patterns_note.trim() ? parsed.patterns_note.trim() : '' - const summaryOut = { headline, patterns_note } + const summaryOut = { headline, patterns_note, provenance: summaryProv.provenance } db.prepare('UPDATE autosort_sessions SET ai_summary_json = ? WHERE id = ?').run(JSON.stringify(summaryOut), sessionId) @@ -3458,7 +3478,9 @@ Rules: * `inbox:cloneBeapToSandbox` is the product channel name; both invoke the same logic. * * Host only: clone is a Host → Sandbox orchestration path (same identity, internal handshake). - * On failure, `code` may include `NO_ACTIVE_SANDBOX_HANDSHAKE`, `MESSAGE_NOT_FOUND`, + * On failure, `code` may include `NO_ACTIVE_SANDBOX_HANDSHAKE`, `MESSAGE_NOT_FOUND` + * (row genuinely absent), `SOURCE_UNVERIFIABLE` (row present, seal verification + * filtered it), `SOURCE_NO_CANONICAL_CONTENT` (row present, no plaintext to clone), * `outer_vault_unavailable`, `outer_vault_or_key_provider_unavailable`, `MESSAGE_CONTENT_NOT_EXTRACTABLE`, * `TARGET_HANDSHAKE_REQUIRED`, or `NOT_HOST_ORCHESTRATOR` (envelope) for structured UI. */ @@ -3932,6 +3954,7 @@ Rules: console.log('[AI-SUMMARIZE] System prompt length:', systemPrompt.length) console.log('[AI-SUMMARIZE] Calling LLM...') const summary = await inboxLlmChat({ system: systemPrompt, user: userPrompt, contentTask: { kind: 'summary' } }) + const summaryProv = attachAndLogProvenance(summary, inboxProviderForProvenance()) console.log('[AI-SUMMARIZE] Raw LLM response:', summary.substring(0, 500)) /** B-7: persist to ai_analysis_json via sealed re-seal so the seal covers this addition. */ @@ -3943,6 +3966,7 @@ Rules: } catch { /* ignore */ } } merged.summary = summary.slice(0, 1000) + merged.provenance = summaryProv.provenance merged.status = merged.status ?? 'summarized' const sealRes = await resealWithAiAnalysis(db, messageId, merged) if (!sealRes.ok) { @@ -3950,7 +3974,7 @@ Rules: return { ok: false, error: `AI analysis could not be applied: ${sealRes.error}` } } - return { ok: true, data: { summary } } + return { ok: true, data: { summary, provenance: summaryProv.provenance } } } catch (err: any) { const isTimeout = err?.message?.startsWith('LLM_TIMEOUT') return { @@ -4271,6 +4295,10 @@ Write a reply specifically to the pbeap field above. Output ONLY the reply text. ...(aiExecDraft ? { aiExecution: aiExecDraft } : {}), contentTask: { kind: 'draft' }, }) + const draftProv = attachAndLogProvenance(draft, { + ...inboxProviderForProvenance(), + ...(tkModel ? { model_id: tkModel } : {}), + }) console.log('[AI-DRAFT] Raw LLM response:', draft.substring(0, 500)) if (isDraftReplyRunStale(messageId, genAtStart)) { @@ -4290,6 +4318,8 @@ Write a reply specifically to the pbeap field above. Output ONLY the reply text. } catch { /* ignore */ } } merged.draftReply = draft.slice(0, 8000) + merged.draftProvenance = draftProv.provenance + merged.provenance = draftProv.provenance merged.status = merged.status ?? 'draft_reply' if (isDraftReplyRunStale(messageId, genAtStart)) { return buildInboxAiDraftIpcFailure(new Error('Draft superseded'), { aiExecution: aiExecDraft, model: aiExecDraft?.model }) as { @@ -4312,7 +4342,7 @@ Write a reply specifically to the pbeap field above. Output ONLY the reply text. } } - return { ok: true, data: { draft } } + return { ok: true, data: { draft, provenance: draftProv.provenance } } } catch (err: unknown) { if (isDraftReplyRunStale(messageId, genAtStart)) { return buildInboxAiDraftIpcFailure(new Error('Draft superseded'), { aiExecution: aiExecDraft, model: aiExecDraft?.model }, isNativeBeap ? { isNativeBeap: true } : undefined) as { @@ -4353,7 +4383,7 @@ Write a reply specifically to the pbeap field above. Output ONLY the reply text. if (!db) return { ok: false, error: 'Database unavailable' } const row = db .prepare( - 'SELECT from_address, from_name, subject, body_text, received_at, source_type, handshake_id, depackaged_json, beap_package_json FROM inbox_messages WHERE id = ?', + 'SELECT from_address, from_name, subject, body_text, received_at, source_type, handshake_id, depackaged_json, depackaged_metadata, beap_package_json FROM inbox_messages WHERE id = ?', ) .get(messageId) as | { @@ -4365,6 +4395,7 @@ Write a reply specifically to the pbeap field above. Output ONLY the reply text. source_type?: string | null handshake_id?: string | null depackaged_json?: string | null + depackaged_metadata?: string | null beap_package_json?: string | null } | undefined @@ -4383,7 +4414,8 @@ Write a reply specifically to the pbeap field above. Output ONLY the reply text. ? buildNativeBeapAnalyzeBody(row) : (row.body_text || '').trim().slice(0, 8000) const sortWAnalyze = sortSourceWeightingFromMessageRow(row) - const userPrompt = `From: ${sender}\nSubject: ${row.subject || '(No subject)'}\nDate: ${row.received_at || '—'}\n\n${body}\n\n${formatSourceWeightingForPrompt(sortWAnalyze)}${buildScamWatchdogUserContext(body)}` + const cprAnalyze = channelProvenanceAnalysisInput(row.depackaged_metadata) + const userPrompt = `From: ${sender}\nSubject: ${row.subject || '(No subject)'}\nDate: ${row.received_at || '—'}\n\n${body}\n\n${formatSourceWeightingForPrompt(sortWAnalyze)}${buildScamWatchdogUserContext(body, cprAnalyze)}` const { tone, sortRules } = getToneAndSortForPrompts(db) const contextBlock = getContextBlockForPrompts(db) @@ -4422,6 +4454,7 @@ Respond ONLY with one valid JSON object. No markdown, no backticks, no preamble, console.log('[AI-ANALYZE] System prompt length:', systemPrompt.length) console.log('[AI-ANALYZE] Calling LLM...') const raw = await inboxLlmChat({ system: systemPrompt, user: userPrompt, contentTask: { kind: 'analysis' } }) + const analyzeProv = attachAndLogProvenance(raw, inboxProviderForProvenance()) console.log('[AI-ANALYZE] Raw LLM response:', raw.substring(0, 500)) const parsed = parseAiJson(raw) as { needsReply?: boolean @@ -4490,6 +4523,7 @@ Respond ONLY with one valid JSON object. No markdown, no backticks, no preamble, archiveReason, draftReply, scamWatchdog, + provenance: analyzeProv.provenance, }, } } catch (err: any) { @@ -4724,7 +4758,7 @@ Respond ONLY with one valid JSON object. No markdown, no backticks, no preamble, } const row = db .prepare( - 'SELECT from_address, from_name, subject, body_text, received_at, source_type, handshake_id, depackaged_json, beap_package_json FROM inbox_messages WHERE id = ?', + 'SELECT from_address, from_name, subject, body_text, received_at, source_type, handshake_id, depackaged_json, depackaged_metadata, beap_package_json FROM inbox_messages WHERE id = ?', ) .get(messageId) as | { @@ -4736,6 +4770,7 @@ Respond ONLY with one valid JSON object. No markdown, no backticks, no preamble, source_type?: string | null handshake_id?: string | null depackaged_json?: string | null + depackaged_metadata?: string | null beap_package_json?: string | null } | undefined @@ -4815,7 +4850,8 @@ Respond ONLY with one valid JSON object. No markdown, no backticks, no preamble, ? buildNativeBeapAnalyzeBody(row) : (row.body_text || '').trim().slice(0, 8000) const sortWStream = sortSourceWeightingFromMessageRow(row) - const userPrompt = `From: ${sender}\nSubject: ${row.subject || '(No subject)'}\nDate: ${row.received_at || '—'}\n\n${body}\n\n${formatSourceWeightingForPrompt(sortWStream)}${buildScamWatchdogUserContext(body)}` + const cprStream = channelProvenanceAnalysisInput(row.depackaged_metadata) + const userPrompt = `From: ${sender}\nSubject: ${row.subject || '(No subject)'}\nDate: ${row.received_at || '—'}\n\n${body}\n\n${formatSourceWeightingForPrompt(sortWStream)}${buildScamWatchdogUserContext(body, cprStream)}` const { tone, sortRules } = getToneAndSortForPrompts(db) const contextBlock = getContextBlockForPrompts(db) @@ -4928,8 +4964,42 @@ Respond ONLY with one valid JSON object. No markdown, no backticks, no preamble, })}`, ) assertMinimumAnalysisOutput(finalAnalysisText, { messageId, requestId }) + const streamProv = attachAndLogProvenance(finalAnalysisText, inboxProviderForProvenance()) + // Art. 50: persist same provenance object into ai_analysis_json (not a second mint). + try { + const dbPersist = await resolveDb() + if (dbPersist) { + const parsedStream = + parseAnalysisJsonObjectFromStreamText(finalAnalysisText) ?? + ({ analysisText: finalAnalysisText } as Record) + const existingRow = dbPersist + .prepare('SELECT ai_analysis_json FROM inbox_messages WHERE id = ?') + .get(messageId) as { ai_analysis_json?: string | null } | undefined + let merged: Record = {} + if (existingRow?.ai_analysis_json) { + try { + merged = JSON.parse(existingRow.ai_analysis_json) as Record + } catch { + merged = {} + } + } + merged = { ...merged, ...parsedStream, provenance: streamProv.provenance } + const sealRes = await resealWithAiAnalysis(dbPersist, messageId, merged) + if (!sealRes.ok) { + console.warn( + '[Inbox IPC] aiAnalyzeMessageStream provenance persist re-seal failed:', + sealRes.error, + ) + } + } + } catch (persistErr: unknown) { + console.warn( + '[Inbox IPC] aiAnalyzeMessageStream provenance persist failed:', + persistErr instanceof Error ? persistErr.message : String(persistErr), + ) + } markAnalysisStreamReplayDone(analyzeDedupeKey) - event.sender.send('inbox:aiAnalyzeMessageDone', { messageId }) + event.sender.send('inbox:aiAnalyzeMessageDone', { messageId, provenance: streamProv.provenance }) console.log( `[INBOX_AUDIT] analysis_done_sent ${JSON.stringify({ messageId, @@ -5113,6 +5183,7 @@ ${formatSourceWeightingForPrompt(sortWeight)}` } : undefined, }) + const classifyProv = attachAndLogProvenance(raw ?? '', inboxProviderForProvenance()) const parsed = parseAiJson(raw) as { category?: string urgency?: number @@ -5221,6 +5292,7 @@ ${formatSourceWeightingForPrompt(sortWeight)}` actionItems: [], draftReply: needsReply ? (parsed.draftReply ?? null) : null, status: 'classified', + provenance: classifyProv.provenance, } const sealResClassify = await resealWithAiAnalysis(db, messageId, aiAnalysisData) if (!sealResClassify.ok) { diff --git a/code/apps/electron-vite-project/electron/main/email/messageRouter.ts b/code/apps/electron-vite-project/electron/main/email/messageRouter.ts index c851b656b..5b701cbb1 100644 --- a/code/apps/electron-vite-project/electron/main/email/messageRouter.ts +++ b/code/apps/electron-vite-project/electron/main/email/messageRouter.ts @@ -38,6 +38,12 @@ import { extractPdfText, isPdfFile, resolveInboxPdfExtractionStatus } from './pd import { writeEncryptedAttachmentFile } from './attachmentBlobCrypto' import { decryptQBeapPackage } from '../beap/decryptQBeapPackage' import { classifyLivePbeapTrust, pbeapTrustMetadata } from '../depackaging-microvm/livePbeapTrust' +import { + channelProvenanceContentHash, + mergeChannelProvenanceMetadata, + produceChannelProvenance, + recordChannelProvenanceEvidence, +} from './channelProvenanceProducer' import { validatorOrchestrator } from '../validator-process/orchestrator' import { isSeamValidationCutoverEnabled } from '../critical-jobs/featureFlags' import { isOpaqueIngestionActive } from './opaqueIngestion' @@ -51,7 +57,7 @@ import { getHandshakeRecord } from '../handshake/db' import { encryptForQuarantine } from '../quarantine-encrypt/index' import { writeQuarantineBlob } from '../quarantine-blob-storage/index' import type { SSOSession } from '../handshake/types' -import type { ProvenanceMetadata } from '@repo/ingestion-core' +import type { ChannelProvenanceRecord, ProvenanceMetadata } from '@repo/ingestion-core' // ── Types ── @@ -61,7 +67,17 @@ export interface RawEmailMessage { uid?: string /** IMAP folder the message was listed under (for remote MOVE chaining). */ folder?: string - headers?: { messageId?: string; inReplyTo?: string; references?: string[] } + headers?: { + messageId?: string + inReplyTo?: string + references?: string[] + /** + * `Authentication-Results` values collected at the depackaging boundary + * (in-guest flag-on, by the provider flag-off). Consumed by the CPR + * producer and discarded — no verdict field of the record quotes them. + */ + authenticationResults?: string[] + } from: { address: string; name?: string } to: Array<{ address: string; name?: string }> cc?: Array<{ address: string; name?: string }> @@ -356,6 +372,88 @@ export async function detectAndRouteMessage( * flag-off path AND the proven pipeline-2 path that the B2 seam consumer re-enters * for an extracted carrier package (passing the package JSON as `text`). */ +interface BeapDetection { + readonly beapPackageJson: string | null + readonly handshakeId: string | null + readonly detectedType: 'beap' | 'plain' +} + +/** + * The verdict for a message no detector was allowed to look at. Distinct from + * "looked and found nothing" only in how it was reached — deliberately the same + * shape, so a provenance-failed message cannot be told apart downstream by + * anything other than its CPR, and no code path can special-case it into an + * affordance. + */ +const NO_DETECTION: BeapDetection = Object.freeze({ + beapPackageJson: null, + handshakeId: null, + detectedType: 'plain', +}) + +/** + * Carrier detection: attachments first, then a JSON body, then JSON attachments. + * + * [Order 02 / 2A] Extracted so that the provenance gate at the call site is a + * single visible boundary. Every WR parse and code extraction of the inline + * path lives in here; if this function is not called, none of it happens. + */ +function detectBeapPackageFromMessage( + attachments: NonNullable, + bodyText: string, +): BeapDetection { + for (const att of attachments) { + if (!isBeapAttachment(att)) continue + const content = att.content + if (!content || content.length === 0) continue + const text = content.toString('utf-8') + if (text.length > 65536) continue + const capsule = detectBeapCapsule(text) + if (capsule.detected && capsule.capsuleJson) { + let handshakeId: string + try { handshakeId = extractHandshakeId(JSON.parse(capsule.capsuleJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } + return { beapPackageJson: capsule.capsuleJson, handshakeId, detectedType: 'beap' } + } + const pkg = detectBeapMessagePackage(text) + if (pkg.detected && pkg.packageJson) { + let handshakeId: string + try { handshakeId = extractHandshakeId(JSON.parse(pkg.packageJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } + return { beapPackageJson: pkg.packageJson, handshakeId, detectedType: 'beap' } + } + } + + if (bodyText.trim().startsWith('{')) { + const capsule = detectBeapCapsule(bodyText) + if (capsule.detected && capsule.capsuleJson) { + let handshakeId: string + try { handshakeId = extractHandshakeId(JSON.parse(capsule.capsuleJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } + return { beapPackageJson: capsule.capsuleJson, handshakeId, detectedType: 'beap' } + } + const pkg = detectBeapMessagePackage(bodyText) + if (pkg.detected && pkg.packageJson) { + let handshakeId: string + try { handshakeId = extractHandshakeId(JSON.parse(pkg.packageJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } + return { beapPackageJson: pkg.packageJson, handshakeId, detectedType: 'beap' } + } + } + + for (const att of attachments) { + if (!isJsonAttachment(att)) continue + const content = att.content + if (!content || content.length === 0) continue + const text = content.toString('utf-8') + if (text.length > 65536) continue + try { + const parsed = JSON.parse(text) + if (detectBeapInJson(parsed)) { + return { beapPackageJson: text, handshakeId: extractHandshakeId(parsed) ?? '__email_import__', detectedType: 'beap' } + } + } catch { /* not valid JSON */ } + } + + return NO_DETECTION +} + export async function detectAndRouteMessageInline( db: any, accountId: string, @@ -391,65 +489,37 @@ export async function detectAndRouteMessageInline( const imapRfcMessageId = rawMsg.headers?.messageId?.trim() || null const hasAttachments = attachments.length > 0 - // ── Step 1: Detect BEAP vs plain (sync) ────────────────────────────────── - - let beapPackageJson: string | null = null - let handshakeId: string | null = null - let detectedType: 'beap' | 'plain' = 'plain' - - for (const att of attachments) { - if (!isBeapAttachment(att)) continue - const content = att.content - if (!content || content.length === 0) continue - const text = content.toString('utf-8') - if (text.length > 65536) continue - const capsule = detectBeapCapsule(text) - if (capsule.detected && capsule.capsuleJson) { - beapPackageJson = capsule.capsuleJson - try { handshakeId = extractHandshakeId(JSON.parse(capsule.capsuleJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } - detectedType = 'beap'; break - } - const pkg = detectBeapMessagePackage(text) - if (pkg.detected && pkg.packageJson) { - beapPackageJson = pkg.packageJson - try { handshakeId = extractHandshakeId(JSON.parse(pkg.packageJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } - detectedType = 'beap'; break - } - } + // ── Step 0: Channel Provenance Record [IX.3.1] ─────────────────────────── + // + // Produced for EVERY message, before anything is derived from its content. + // Phase 1 records the verdict and persists it; Phase 2 makes a failing + // `channel_pass` short-circuit the detection below. + const channelProvenance = produceChannelProvenance({ + authenticationResults: rawMsg.headers?.authenticationResults, + fromAddress: fromAddr, + contentSha256: channelProvenanceContentHash({ + rawBytes: rawMsg.rawRfc822 ?? null, + messageId, + subject, + bodyText, + }), + }) - if (detectedType === 'plain' && bodyText.trim().startsWith('{')) { - const capsule = detectBeapCapsule(bodyText) - if (capsule.detected && capsule.capsuleJson) { - beapPackageJson = capsule.capsuleJson - try { handshakeId = extractHandshakeId(JSON.parse(capsule.capsuleJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } - detectedType = 'beap' - } - } - if (detectedType === 'plain' && bodyText.trim().startsWith('{')) { - const pkg = detectBeapMessagePackage(bodyText) - if (pkg.detected && pkg.packageJson) { - beapPackageJson = pkg.packageJson - try { handshakeId = extractHandshakeId(JSON.parse(pkg.packageJson)) ?? '__email_import__' } catch { handshakeId = '__email_import__' } - detectedType = 'beap' - } - } - if (detectedType === 'plain') { - for (const att of attachments) { - if (!isJsonAttachment(att)) continue - const content = att.content - if (!content || content.length === 0) continue - const text = content.toString('utf-8') - if (text.length > 65536) continue - try { - const parsed = JSON.parse(text) - if (detectBeapInJson(parsed)) { - beapPackageJson = text - handshakeId = extractHandshakeId(parsed) ?? '__email_import__' - detectedType = 'beap'; break - } - } catch { /* not valid JSON */ } - } - } + // ── Step 1: Detect BEAP vs plain (sync) ────────────────────────────────── + // + // [Order 02 / 2A] Detection runs ONLY for a channel-authenticated message. + // A failing `channel_pass` (D5) short-circuits every WR parse and code + // extraction below: the message lands as plain carrying its CPR, and no + // affordance of any kind is derived from it. The suppression is structural + // — the detection code is never reached, rather than its result discarded + // afterwards, so there is no intermediate state a later change could leak. + const detection = channelProvenance.channel_pass + ? detectBeapPackageFromMessage(attachments, bodyText) + : NO_DETECTION + + const beapPackageJson: string | null = detection.beapPackageJson + const handshakeId: string | null = detection.handshakeId + const detectedType: 'beap' | 'plain' = detection.detectedType // ── Step 2a: Attachment preprocessing (Att-2, PR B-3.1) ────────────────── // @@ -585,8 +655,34 @@ export async function detectAndRouteMessageInline( // depackaged_json exactly as before — persistence is additive, not a routing change. let pbeapTrustMetaJson: string | null = null + // ── Stage 0: ingress admission filter [VII.2.7] ── + // A BEAP package addressed to a revoked/expired/unknown relationship must + // never be depackaged into a visible email_beap row. Blocked packages skip + // decrypt+validate and take the encrypted quarantine containment below + // (never the BEAP inbox). Packages without a resolvable handshake id keep + // today's depackage-failure handling. + let ingressBlocked = false + let admittedGrantRef: string | null = null + if (handshakeId && handshakeId !== '__email_import__') { + const { admitInboundDelivery } = await import('../handshake/ingressAdmission') + const admission = admitInboundDelivery(db, { + handshakeId, + kind: encoding === 'qBEAP' ? 'beap_message' : 'handshake_capsule', + source: 'email', + }) + if (!admission.admitted) { + ingressBlocked = true + depackageError = `ingress_admission_blocked:${admission.reason}` + } else { + // Phase 5 [VII.10.3]: grant the delivery is admitted under. + admittedGrantRef = admission.grantRef + } + } + // ── Inline depackage ── - if (encoding === 'qBEAP') { + if (ingressBlocked) { + // canonicalJson stays null → quarantine containment path below. + } else if (encoding === 'qBEAP') { try { const decrypted = await decryptQBeapPackage(beapPackageJson, handshakeId ?? '', db, { reportFailure: (info) => console.warn('[messageRouter] qBEAP decrypt failure', info), @@ -631,7 +727,14 @@ export async function detectAndRouteMessageInline( // routes through the critical-job dispatcher (in-process → same forked // validator subprocess, so parity is byte-identical). Flag OFF keeps the // original inline call verbatim. The qBEAP/pBEAP decrypt above is untouched. - const provenance = buildProvenance(fromAddr, messageId, bodyText, 'beap_capsule_present') + const provenance: ProvenanceMetadata = { + ...buildProvenance(fromAddr, messageId, bodyText, 'beap_capsule_present'), + transport_metadata: { + sender_address: fromAddr, + message_id: messageId, + grant_ref: admittedGrantRef ?? undefined, + }, + } const validationInput = { envelope: packageObj ?? {}, plaintext_or_encrypted: { kind: 'plaintext' as const, content: canonicalJson }, @@ -657,14 +760,15 @@ export async function detectAndRouteMessageInline( if (resp && resp.outcome.ok) { const sealed = resp.outcome.sealed - // Bind the pBEAP trust verdict tamper-evidently into the seal (when present), - // so the persisted depackaged_metadata cannot be altered post-write undetected. - const { seal, seal_input_json } = computeSeal(sealed.canonical_json, inboxMessageId, 'outer', pbeapTrustMetaJson) + // Bind the pBEAP trust verdict AND the channel verdict tamper-evidently + // into the seal, so neither can be altered post-write undetected. + const inboxMetaJson = mergeChannelProvenanceMetadata(pbeapTrustMetaJson, channelProvenance) + const { seal, seal_input_json } = computeSeal(sealed.canonical_json, inboxMessageId, 'outer', inboxMetaJson) writePayload = { kind: 'inbox', sourceType: 'email_beap', depackagedJson: sealed.canonical_json, - depackagedMetadata: pbeapTrustMetaJson, + depackagedMetadata: inboxMetaJson, seal, sealInputJson: seal_input_json, sealKeySource: 'ledger', @@ -685,24 +789,27 @@ export async function detectAndRouteMessageInline( const rejectionReason = depackageError ?? 'depackage_failed' const sandboxHandshake = findPairedSandboxHandshake(db, session) + // [Order 02 / 2A] Fail-open closure, all three degradation branches. + // + // A depackage that failed produced a row we could not validate. The three + // ways quarantining can itself fail — no custody key, sealing failed, + // validator rejected — previously each fell back to a PLAIN inbox row, + // which presents unvalidated carrier content as ordinary mail. That is + // the fail-open: the worse the failure, the weaker the handling. + // + // All three now HOLD, matching what the seam path already does in + // `quarantineRawBytes`. Held means not inserted and not downgraded; the + // sync caller skips the message this round and retries. Consistency + // between the two paths is the point — an invariant that holds on only + // one of them is not an invariant. if (!sandboxHandshake) { - console.warn('[MessageRouter] No sandbox for quarantine; falling back to plain inbox row:', messageId) - writePayload = await buildPlainEmailInboxPayload( - inboxMessageId, messageId, accountId, rawMsg, fromAddr, - fromName, subject, bodyText, bodyHtml, toList, ccList, - receivedAt, attachmentsCanonical, - ) + throw new DepackageCutoverHeldError('no_paired_sandbox') } else { const emailBytes = Buffer.from(beapPackageJson, 'utf-8') const encResult = encryptForQuarantine(emailBytes, sandboxHandshake.peer_x25519_public_key_b64) if (!encResult.ok) { - console.error('[MessageRouter] encryptForQuarantine failed:', encResult.error) - writePayload = await buildPlainEmailInboxPayload( - inboxMessageId, messageId, accountId, rawMsg, fromAddr, - fromName, subject, bodyText, bodyHtml, toList, ccList, - receivedAt, attachmentsCanonical, - ) + throw new DepackageCutoverHeldError(`quarantine_seal_failed:${encResult.error}`) } else { const blobResult = writeQuarantineBlob(encResult.blob) const quarantineId = randomUUID() @@ -723,12 +830,11 @@ export async function detectAndRouteMessageInline( }) if (!qResp.outcome.ok) { - // Structural bug: host_quarantine content should always pass. - console.error('[MessageRouter] quarantine validator rejected (bug):', qResp.outcome.sealed_quarantine.rejection_reason) - writePayload = await buildPlainEmailInboxPayload( - inboxMessageId, messageId, accountId, rawMsg, fromAddr, - fromName, subject, bodyText, bodyHtml, toList, ccList, - receivedAt, attachmentsCanonical, + // host_quarantine content should always pass; a reject is a + // structural bug, and a bug is the last condition under which to + // relax handling. + throw new DepackageCutoverHeldError( + `quarantine_validator_rejected:${qResp.outcome.sealed_quarantine.rejection_reason}`, ) } else { const qSealed = qResp.outcome.sealed @@ -753,6 +859,7 @@ export async function detectAndRouteMessageInline( inboxMessageId, messageId, accountId, rawMsg, fromAddr, fromName, subject, bodyText, bodyHtml, toList, ccList, receivedAt, attachmentsCanonical, + mergeChannelProvenanceMetadata(null, channelProvenance), ) } @@ -790,6 +897,16 @@ export async function detectAndRouteMessageInline( }, ) + // quarantine_messages has no depackaged_metadata column, so the evidence + // chain is where this message's channel verdict is retained [IX.11]. + recordChannelProvenanceEvidence({ + record: channelProvenance, + messageId, + rowId: writePayload.quarantineId, + path: viaSeam ? 'seam_carrier' : 'inline', + outcome: 'quarantine', + }) + return { type: 'quarantine', messageId, inboxMessageId: writePayload.quarantineId } } @@ -893,6 +1010,14 @@ export async function detectAndRouteMessageInline( 'outer', ) + recordChannelProvenanceEvidence({ + record: channelProvenance, + messageId, + rowId: inboxMessageId, + path: viaSeam ? 'seam_carrier' : 'inline', + outcome: 'inbox', + }) + return { type: writePayload.sourceType === 'email_beap' ? 'beap' : 'plain', messageId, @@ -979,15 +1104,29 @@ async function routeViaDepackageSeam( const { dispatchDepackageEmail } = await import('../critical-jobs/liveDepackageCutover') const out = await dispatchDepackageEmail(opaque, sandbox.peer_x25519_public_key_b64, undefined, form) + // ── Channel Provenance Record [IX.3.1] ───────────────────────────────────── + // + // Flag-on, the authentication material is collected in-guest (header handling + // never happens here) and arrives typed and capped. A depackage failure means + // we never got any: the record is then `unverifiable`, which is a verdict — + // the message is still evidenced, it simply has no authenticated channel. + const contentSha256 = channelProvenanceContentHash({ rawBytes: opaque }) + const guestMaterial = out.ok && out.result.ok ? out.result.channelAuthentication : undefined + const channelProvenance = produceChannelProvenance({ + authenticationResults: guestMaterial?.authenticationResults, + fromAddress: guestMaterial?.fromDomain ?? null, + contentSha256, + }) + // INV-5 logging: identifiers/codes only, never plaintext/bytes. if (!out.ok) { console.warn('[messageRouter] depackage-email dispatch failed', { messageId, code: out.code }) - return quarantineRawBytes(db, opaque, sandbox, messageId, rawMsg, mapDepackageCodeToReason(out.code)) + return quarantineRawBytes(db, opaque, sandbox, messageId, rawMsg, mapDepackageCodeToReason(out.code), channelProvenance) } const result = out.result if (!result.ok) { console.warn('[messageRouter] depackage-email worker failure', { messageId, code: result.code }) - return quarantineRawBytes(db, opaque, sandbox, messageId, rawMsg, mapDepackageCodeToReason(result.code)) + return quarantineRawBytes(db, opaque, sandbox, messageId, rawMsg, mapDepackageCodeToReason(result.code), channelProvenance) } if (result.type === 'beap-carrier' || result.type === 'mixed') { @@ -1009,8 +1148,16 @@ async function routeViaDepackageSeam( to: env.to.map((a) => ({ address: a.email, name: a.name })), cc: env.cc.map((a) => ({ address: a.email, name: a.name })), date: coerceReceivedAtIso(rawMsg.date ?? env.date, new Date().toISOString()), - // Guest-derived threading key (no orchestrator header parse). - headers: th?.messageId ? { ...rawMsg.headers, messageId: th.messageId } : rawMsg.headers, + // Guest-derived threading key + CPR material (no orchestrator header parse). + // The re-entered inline path produces and evidences the record itself, so + // the carrier case is evidenced exactly once, as `seam_carrier`. + headers: { + ...rawMsg.headers, + ...(th?.messageId ? { messageId: th.messageId } : {}), + ...(result.channelAuthentication + ? { authenticationResults: [...result.channelAuthentication.authenticationResults] } + : {}), + }, text: pkgJson, html: undefined, attachments: [], @@ -1021,7 +1168,7 @@ async function routeViaDepackageSeam( // Plain mail: consumer-wrap the guest SafeText, preserve sealed originals. console.warn('[messageRouter] depackage-email plain', { messageId, artifacts: result.artifacts.length }) - return writePlainSeamInbox(db, accountId, rawMsg, messageId, result.safeText, result.artifacts, result.displayEnvelope, result.threadingHints) + return writePlainSeamInbox(db, accountId, rawMsg, messageId, result.safeText, result.artifacts, result.displayEnvelope, result.threadingHints, channelProvenance) } /** @@ -1036,6 +1183,7 @@ async function quarantineRawBytes( messageId: string, rawMsg: RawEmailMessage, rejectionReason: string, + channelProvenance: ChannelProvenanceRecord, ): Promise { const fromAddr = rawMsg.from?.address ?? (rawMsg.from as any)?.email ?? '' const receivedAt = coerceReceivedAtIso(rawMsg.date, new Date().toISOString()) @@ -1076,6 +1224,13 @@ async function quarantineRawBytes( ], { seal: qSealed.seal, seal_input_json: qSealed.seal_input_json, canonical_json: qCanonicalJson, row_id: quarantineId }, ) + recordChannelProvenanceEvidence({ + record: channelProvenance, + messageId, + rowId: quarantineId, + path: 'seam', + outcome: 'quarantine', + }) return { type: 'quarantine', messageId, inboxMessageId: quarantineId } } @@ -1095,7 +1250,8 @@ async function writePlainSeamInbox( safeText: { subject: string; body_text: string; attachment_refs: readonly string[] }, artifacts: ReadonlyArray<{ blob_id: string; content_type: string; filename?: string; blob: import('../quarantine-blob-storage/index').QuarantineBlobFile }>, envelope: import('../depackaging-microvm/emailDepackage').DisplayEnvelope, - threadingHints?: import('../depackaging-microvm/emailDepackage').ThreadingHints, + threadingHints: import('../depackaging-microvm/emailDepackage').ThreadingHints | undefined, + channelProvenance: ChannelProvenanceRecord, ): Promise { const inboxMessageId = randomUUID() const now = new Date().toISOString() @@ -1135,6 +1291,7 @@ async function writePlainSeamInbox( inboxMessageId, messageId, accountId, rawMsg, fromAddr, fromName, safeText.subject, safeText.body_text, null, toList, ccList, receivedAt, attachmentsCanonical, + mergeChannelProvenanceMetadata(null, channelProvenance), ) const sealedInbox = prepareSealedInsert(db, INBOX_INSERT_SQL) @@ -1142,7 +1299,7 @@ async function writePlainSeamInbox( inboxMessageId, payload.sourceType, null, accountId, messageId, fromAddr, fromName, JSON.stringify(toAddrs), JSON.stringify(ccAddrs), safeText.subject, safeText.body_text, null, null, - payload.depackagedJson, null, attachmentsCanonical.length > 0 ? 1 : 0, attachmentsCanonical.length, + payload.depackagedJson, payload.depackagedMetadata, attachmentsCanonical.length > 0 ? 1 : 0, attachmentsCanonical.length, receivedAt, now, folder, imapRfcMessageId, payload.validatedAt, payload.validatorVersion, payload.validationReason, payload.seal, payload.sealInputJson, 'ledger', @@ -1153,6 +1310,13 @@ async function writePlainSeamInbox( [], 'outer', ) + recordChannelProvenanceEvidence({ + record: channelProvenance, + messageId, + rowId: inboxMessageId, + path: 'seam', + outcome: 'inbox', + }) return { type: 'plain', messageId, inboxMessageId } } @@ -1172,10 +1336,16 @@ async function buildPlainEmailInboxPayload( ccList: Array<{ address: string; name?: string }>, receivedAt: string, attachmentsCanonical: ChildAttachmentDescriptor[], + /** + * `depackaged_metadata` JSON (the CPR, and `pbeap_trust` where one exists). + * Bound into the seal so the persisted verdict is tamper-evident. + */ + depackagedMetadata?: string | null, ): Promise<{ kind: 'inbox' sourceType: 'email_plain' depackagedJson: string + depackagedMetadata: string | null seal: string sealInputJson: string validatedAt: string @@ -1218,12 +1388,14 @@ async function buildPlainEmailInboxPayload( } const canonicalJson = JSON.stringify(canonicalObj) const nowIso = new Date().toISOString() - const { seal, seal_input_json } = computeSeal(canonicalJson, inboxMessageId, 'outer') + const metadataJson = depackagedMetadata ?? null + const { seal, seal_input_json } = computeSeal(canonicalJson, inboxMessageId, 'outer', metadataJson) return { kind: 'inbox', sourceType: 'email_plain', depackagedJson: canonicalJson, + depackagedMetadata: metadataJson, seal, sealInputJson: seal_input_json, sealKeySource: 'ledger', diff --git a/code/apps/electron-vite-project/electron/main/email/providers/gmail.ts b/code/apps/electron-vite-project/electron/main/email/providers/gmail.ts index c6da73b1d..4415ee40a 100644 --- a/code/apps/electron-vite-project/electron/main/email/providers/gmail.ts +++ b/code/apps/electron-vite-project/electron/main/email/providers/gmail.ts @@ -27,6 +27,10 @@ import { SendEmailPayload, SendResult } from '../types' +import { + serializeForMime, + shouldApplyMachineMarking, +} from '../../../../../../packages/shared/src/aiProvenance' import type { OrchestratorRemoteOperation, OrchestratorRemoteApplyResult, @@ -1537,6 +1541,14 @@ export class GmailProvider extends BaseEmailProvider { if (payload.references?.length) { lines.push(`References: ${payload.references.join(' ')}`) } + + // Art. 50 Layer A — machine-readable provenance headers (provider duty, not user-optional). + if (shouldApplyMachineMarking(payload.provenance)) { + const mimeHeaders = serializeForMime(payload.provenance!) + for (const [key, val] of Object.entries(mimeHeaders)) { + lines.push(`${key}: ${val}`) + } + } const hasAttachments = payload.attachments?.length && payload.attachments.length > 0 diff --git a/code/apps/electron-vite-project/electron/main/email/providers/imap.ts b/code/apps/electron-vite-project/electron/main/email/providers/imap.ts index f579dd383..58fb63be7 100644 --- a/code/apps/electron-vite-project/electron/main/email/providers/imap.ts +++ b/code/apps/electron-vite-project/electron/main/email/providers/imap.ts @@ -2118,6 +2118,19 @@ export class ImapProvider extends BaseEmailProvider { contentType: a.mimeType, })) + // Art. 50 Layer A — machine-readable headers when draft has AI provenance. + let art50Headers: Record | undefined + try { + const { shouldApplyMachineMarking, serializeForMime } = await import( + '../../../../../../packages/shared/src/aiProvenance' + ) + if (shouldApplyMachineMarking(payload.provenance)) { + art50Headers = serializeForMime(payload.provenance!) + } + } catch { + /* shared import failure must not block send */ + } + const info = await this.transporter.sendMail({ from: this.config.email, to: payload.to.join(', '), @@ -2128,6 +2141,7 @@ export class ImapProvider extends BaseEmailProvider { inReplyTo: payload.inReplyTo, references: payload.references?.join(' '), attachments: attachments.length > 0 ? attachments : undefined, + ...(art50Headers ? { headers: art50Headers } : {}), }) return { diff --git a/code/apps/electron-vite-project/electron/main/email/providers/outlook.ts b/code/apps/electron-vite-project/electron/main/email/providers/outlook.ts index 2d04e00a3..49b87980f 100644 --- a/code/apps/electron-vite-project/electron/main/email/providers/outlook.ts +++ b/code/apps/electron-vite-project/electron/main/email/providers/outlook.ts @@ -730,6 +730,21 @@ export class OutlookProvider extends BaseEmailProvider { contentBytes: a.contentBase64 })) } + // Art. 50 Layer A — Graph internetMessageHeaders when AI provenance present. + try { + const { shouldApplyMachineMarking, serializeForMime } = await import( + '../../../../../../packages/shared/src/aiProvenance' + ) + if (shouldApplyMachineMarking(payload.provenance)) { + const mimeHeaders = serializeForMime(payload.provenance!) + message.internetMessageHeaders = Object.entries(mimeHeaders).map(([name, value]) => ({ + name, + value, + })) + } + } catch { + /* shared import failure must not block send */ + } await this.graphApiRequest('POST', '/me/sendMail', { message, diff --git a/code/apps/electron-vite-project/electron/main/email/providers/zoho.ts b/code/apps/electron-vite-project/electron/main/email/providers/zoho.ts index 79d376626..0254a42ad 100644 --- a/code/apps/electron-vite-project/electron/main/email/providers/zoho.ts +++ b/code/apps/electron-vite-project/electron/main/email/providers/zoho.ts @@ -18,6 +18,7 @@ import { SendEmailPayload, SendResult, } from '../types' +import { shouldApplyMachineMarking, encodeProvenancePayload } from '../../../../../../packages/shared/src/aiProvenance' import type { OrchestratorRemoteOperation, OrchestratorRemoteApplyResult, @@ -571,12 +572,21 @@ export class ZohoProvider extends BaseEmailProvider { if (!fromAddr) { return { success: false, error: 'Zoho: missing account email for From address' } } + + // Art. 50 Layer A: prepend structured plaintext marker block when provenance requires machine marking. + // Zoho Mail API does not support custom MIME headers via REST; prepend as a parseable comment block. + let bodyText = payload.bodyText + if (payload.provenance && shouldApplyMachineMarking(payload.provenance)) { + const encoded = encodeProvenancePayload(payload.provenance) + bodyText = `[X-AI-Generated: true]\n[X-AI-Provenance: ${encoded}]\n\n${bodyText}` + } + await this.zohoApiRequest('POST', `/api/accounts/${aid}/messages`, { fromAddress: fromAddr, toAddress: payload.to.join(','), ccAddress: payload.cc?.length ? payload.cc.join(',') : undefined, subject: payload.subject, - content: payload.bodyText, + content: bodyText, mailFormat: 'plaintext', }) return { success: true } diff --git a/code/apps/electron-vite-project/electron/main/email/sandboxIngestionProduction.ts b/code/apps/electron-vite-project/electron/main/email/sandboxIngestionProduction.ts index 02a9692b9..1f7cb58a2 100644 --- a/code/apps/electron-vite-project/electron/main/email/sandboxIngestionProduction.ts +++ b/code/apps/electron-vite-project/electron/main/email/sandboxIngestionProduction.ts @@ -45,7 +45,7 @@ export function findActiveSandboxToHostHandshakeRecord(db: unknown): HandshakeRe const localId = getInstanceId().trim() const rows = listHandshakeRecords(db as never, { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) for (const r of rows) { const dr = deriveInternalHostAiPeerRoles(r, localId) diff --git a/code/apps/electron-vite-project/electron/main/email/scamWatchdog.ts b/code/apps/electron-vite-project/electron/main/email/scamWatchdog.ts index 7f61a65f8..be06bb2c7 100644 --- a/code/apps/electron-vite-project/electron/main/email/scamWatchdog.ts +++ b/code/apps/electron-vite-project/electron/main/email/scamWatchdog.ts @@ -14,18 +14,125 @@ * extraction is pure regex over the already-validated message body string. * - Informational only: it flags concrete, nameable signals. It never auto-acts, blocks, * or deletes. + * + * Build item 13 (2C): the Channel Provenance Record is a DECLARED, TYPED input to this + * analysis. Layering is one-directional — the CPR informs the analysis; the analysis + * never informs, suppresses, softens, precedes, or replaces the §IX.3.1 rule-8 alert. + * Only verdicts cross into the prompt; raw `Authentication-Results` never do. */ /** JSON keys the model is asked to add to the combined analysis object. */ export const SCAM_WATCHDOG_JSON_KEYS = ['scamStatus', 'scamFindings'] as const +// ── Channel Provenance as a typed analysis input (build item 13) ────────────── + +export type ChannelProvenanceAnalysisVerdict = 'pass' | 'fail' | 'none' | 'unverifiable' + +/** + * The bounded projection of the CPR the analysis is allowed to read. Verdict fields + * plus the aggregate and the authenticated domain — no raw headers, no signatures, + * no evaluation material. Structurally typed so this module keeps no dependency on + * `@repo/ingestion-core` and stays a pure prompt builder. + */ +export interface ChannelProvenanceAnalysisInput { + spf: ChannelProvenanceAnalysisVerdict + dkim: ChannelProvenanceAnalysisVerdict + dmarc: ChannelProvenanceAnalysisVerdict + channelPass: boolean + authenticatedSenderDomain: string | null +} + +const ANALYSIS_VERDICTS: readonly ChannelProvenanceAnalysisVerdict[] = [ + 'pass', + 'fail', + 'none', + 'unverifiable', +] + +function readVerdict(raw: unknown): ChannelProvenanceAnalysisVerdict | null { + if (typeof raw !== 'object' || raw === null) return null + const v = (raw as Record).verdict + return typeof v === 'string' && (ANALYSIS_VERDICTS as readonly string[]).includes(v) + ? (v as ChannelProvenanceAnalysisVerdict) + : null +} + +/** + * Fail-closed decode of a `depackaged_metadata` blob (object or JSON string) into the + * typed analysis input. Returns null when the record is absent or malformed — the + * analysis then simply runs without the CPR block rather than seeing an invented verdict. + */ +export function channelProvenanceAnalysisInput( + metadata: unknown, +): ChannelProvenanceAnalysisInput | null { + let value: unknown = metadata + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch { + return null + } + } + if (typeof value !== 'object' || value === null) return null + const obj = value as Record + const rec = + obj.channel_provenance && typeof obj.channel_provenance === 'object' + ? (obj.channel_provenance as Record) + : obj + const spf = readVerdict(rec.spf) + const dkim = readVerdict(rec.dkim) + const dmarc = readVerdict(rec.dmarc) + if (!spf || !dkim || !dmarc) return null + const domain = rec.authenticated_sender_domain + return { + spf, + dkim, + dmarc, + channelPass: rec.channel_pass === true, + authenticatedSenderDomain: typeof domain === 'string' && domain ? domain : null, + } +} + +/** + * Declared CPR block for the analysis user prompt. Absent input yields an explicit + * "not available" line rather than silence, so the model never reads a missing record + * as an authenticated one. + */ +export function buildChannelProvenanceAnalysisBlock( + cpr: ChannelProvenanceAnalysisInput | null | undefined, +): string { + if (!cpr) { + return '\n\nChannel Provenance Record: not available for this message. Treat the transport channel as unproven; do NOT infer that it was authenticated.' + } + const domain = cpr.authenticatedSenderDomain ?? 'none' + return `\n\nChannel Provenance Record (typed verdicts from the receiving gateway; evidence only, never a verdict of your own): +- SPF: ${cpr.spf} +- DKIM: ${cpr.dkim} +- DMARC: ${cpr.dmarc} +- Channel authenticated (aggregate): ${cpr.channelPass ? 'yes' : 'no'} +- Authenticated sender domain: ${domain}` +} + /** * Prompt section appended to the analysis system prompt (after the existing keys, the * same way tone / sort-rules / context blocks are appended). Encodes the detection * signals and the first-class false-positive discipline, including the brand * cross-check and the stay-silent cases. */ -export const SCAM_WATCHDOG_PROMPT_SECTION = ` +/** + * One-directional layering clause (build item 13). The CPR may raise the analysis's + * suspicion; the analysis may never lower, precede, or stand in for the rule-8 alert, + * which is rendered by the UI from the record itself and is not model output. + */ +export const CHANNEL_PROVENANCE_ANALYSIS_PROMPT_SECTION = ` + +CHANNEL PROVENANCE (typed input — one-directional): +- A Channel Provenance Record may be supplied with the message. Treat its verdicts as evidence about the transport channel only. +- An unauthenticated channel (SPF/DKIM/DMARC not passing, or absent/unverifiable) MAY strengthen a concrete finding you already have. It is NEVER a finding on its own: an unauthenticated channel alone is still scamStatus "clear". +- An authenticated channel NEVER clears, softens, or outweighs a concrete scam signal. A signed, aligned message can still be a scam. +- The separate sender-verification warning shown to the user is derived from the record by the application, not from you. Do NOT restate it, do NOT claim to replace it, and do NOT say the message is verified or safe because of these verdicts.` + +const SCAM_WATCHDOG_BASE_PROMPT_SECTION = ` ADDITIONAL ANALYSIS — Scam Watchdog (phishing / social-engineering detector). Add these keys to the SAME JSON object: - scamStatus: "clear" | "flagged" — "flagged" ONLY when one or more specific, nameable scam/phishing signals are concretely present in THIS message; otherwise "clear". @@ -46,6 +153,10 @@ FALSE-POSITIVE DISCIPLINE (mandatory — this is a first-class requirement): - A legitimate transactional message (a real invoice, receipt, or account notice from a domain that matches the claimed brand/service) is "clear". - When nothing concrete is present, return scamStatus "clear" and scamFindings [].` +/** Full section appended to the analysis system prompt (signals + CPR layering). */ +export const SCAM_WATCHDOG_PROMPT_SECTION = + SCAM_WATCHDOG_BASE_PROMPT_SECTION + CHANNEL_PROVENANCE_ANALYSIS_PROMPT_SECTION + const BARE_URL_RE = /\bhttps?:\/\/[^\s<>"')\]]+/gi const MD_LINK_RE = /\[([^\]]+)]\((https?:\/\/[^)\s<]+)\)/gi const HTML_ANCHOR_RE = /]*?href\s*=\s*["']?(https?:\/\/[^"'\s>]+)["']?[^>]*>([\s\S]*?)<\/a>/gi @@ -104,17 +215,23 @@ export function extractScamWatchdogLinkStrings(body: string): string[] { } /** - * Build the user-prompt context block listing the detected link strings. Appended to the - * existing analysis user prompt. The sender fields are already present in that prompt, so - * this block only adds the link strings (with an explicit no-fetch reminder). + * Build the user-prompt context block listing the detected link strings, followed by the + * declared Channel Provenance block. Appended to the existing analysis user prompt. The + * sender fields are already present in that prompt, so this block only adds the link + * strings (with an explicit no-fetch reminder) and the typed CPR verdicts. */ -export function buildScamWatchdogUserContext(body: string): string { +export function buildScamWatchdogUserContext( + body: string, + channelProvenance?: ChannelProvenanceAnalysisInput | null, +): string { const links = extractScamWatchdogLinkStrings(body) - if (links.length === 0) { - return '\n\nLink strings detected for Scam Watchdog: none.' - } - const list = links.map((l) => `- ${l}`).join('\n') - return `\n\nLink strings detected for Scam Watchdog (analyze as TEXT ONLY — do NOT visit or fetch):\n${list}` + const linkBlock = + links.length === 0 + ? '\n\nLink strings detected for Scam Watchdog: none.' + : `\n\nLink strings detected for Scam Watchdog (analyze as TEXT ONLY — do NOT visit or fetch):\n${links + .map((l) => `- ${l}`) + .join('\n')}` + return linkBlock + buildChannelProvenanceAnalysisBlock(channelProvenance ?? null) } /** Append the Scam Watchdog key declarations + guidance to an analysis system prompt. */ diff --git a/code/apps/electron-vite-project/electron/main/email/syncOrchestrator.ts b/code/apps/electron-vite-project/electron/main/email/syncOrchestrator.ts index c3fed517b..09da15cf9 100644 --- a/code/apps/electron-vite-project/electron/main/email/syncOrchestrator.ts +++ b/code/apps/electron-vite-project/electron/main/email/syncOrchestrator.ts @@ -281,11 +281,18 @@ function mapToRawEmailMessage( ): RawEmailMessage { const id = detail.id const headerBlock = - detail.headers?.messageId || detail.headers?.inReplyTo || detail.headers?.references + detail.headers?.messageId || + detail.headers?.inReplyTo || + detail.headers?.references || + detail.headers?.authenticationResults ? { ...(detail.headers.messageId ? { messageId: detail.headers.messageId } : {}), ...(detail.headers.inReplyTo ? { inReplyTo: detail.headers.inReplyTo } : {}), ...(detail.headers.references ? { references: detail.headers.references } : {}), + // CPR material [IX.3.1] — consumed by the producer and discarded. + ...(detail.headers.authenticationResults + ? { authenticationResults: detail.headers.authenticationResults } + : {}), } : undefined diff --git a/code/apps/electron-vite-project/electron/main/email/types.ts b/code/apps/electron-vite-project/electron/main/email/types.ts index 5d66e502e..e1e8e604c 100644 --- a/code/apps/electron-vite-project/electron/main/email/types.ts +++ b/code/apps/electron-vite-project/electron/main/email/types.ts @@ -6,6 +6,8 @@ * across the email gateway, MCP tools, and UI. */ +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' + // ============================================================================= // Provider Configuration // ============================================================================= @@ -579,6 +581,12 @@ export interface SanitizedMessageDetail extends SanitizedMessage { messageId?: string inReplyTo?: string references?: string[] + /** + * `Authentication-Results` values the receiving gateway wrote, for the + * Channel Provenance Record [IX.3.1]. Providers that cannot surface them + * leave this unset, and the CPR records `unverifiable` — never a pass. + */ + authenticationResults?: string[] } /** @@ -737,6 +745,14 @@ export interface SendEmailPayload { /** Reference message IDs (for threading) */ references?: string[] + + /** + * Art. 50 Layer A machine-readable AI provenance. + * When present and shouldApplyMachineMarking(provenance) is true, X-AI-Generated + * and X-AI-Provenance MIME headers are injected by the outbound carrier (gmail.ts). + * Not user-optional — editorial responsibility exempts visible label only, not MIME. + */ + provenance?: AiProvenance } /** diff --git a/code/apps/electron-vite-project/electron/main/enforcement/authorizeToolInvocation.ts b/code/apps/electron-vite-project/electron/main/enforcement/authorizeToolInvocation.ts index d9f3043ce..43a8836ab 100644 --- a/code/apps/electron-vite-project/electron/main/enforcement/authorizeToolInvocation.ts +++ b/code/apps/electron-vite-project/electron/main/enforcement/authorizeToolInvocation.ts @@ -1,18 +1,26 @@ /** - * Execution Authorization Gate + * Execution Authorization Gate (Phase 5 — V4) [VII.10.1, VII.2.6, IX.19.2] * - * Central function that authorizes every tool invocation before execution. - * No tool may execute without passing this gate. No alternate execution - * entry point may exist that bypasses it. + * EXECUTION GRANTS ARE DELETED. The former process-global granted-tools + * set and the ACTIVE-handshake blanket execution authorization are gone: + * there is no standing right to execute anything. Authorization comes + * exclusively from a fresh, single-use, Intent-Hash-bound human consent + * record (see `execution/executionConsent.ts`) — verified here per + * invocation, never cached, never batch-approved [VII.10.5.5]. * - * Checks (in order): - * 1. Handshake exists and is active - * 2. Handshake is not revoked - * 3. Tool is explicitly granted in capability set - * 4. Scope matches effective policy - * 5. Purpose matches effective policy - * 6. Parameters are within constraints - * 7. Attestation requirements met (if applicable) + * Checks (in order, fail-closed): + * 1. Consent-tap flow enabled (kill switch refuses ALL execution — it + * never restores a consent-free path) + * 2. Relationship context is live (exists, not revoked, active window) — + * necessary but NEVER sufficient + * 3. Consent record: exists, tapped by a human actor, unconsumed, and its + * Intent Hash matches the request about to execute [IX.19.2] + * 4. Parameters are within constraints + * 5. Attestation requirements met (if applicable) + * + * Divergence between the executed and presented action (intent-hash + * mismatch) is a DEVIATION: the consent record is invalidated and the + * denial is recorded as such. * * Logs an audit record for every decision (allow and deny). */ @@ -23,41 +31,48 @@ import { } from '../handshake/db' import { diagnoseHandshakeInactive } from '../handshake/enforcement' import { HandshakeState as HS } from '../handshake/types' +import { + isConsentTapExecutionEnabled, + verifyConsentForExecution, + type ExecutionConsentRow, +} from '../execution/executionConsent' // ── Types ── export type AuthorizationDenialReason = + | 'EXECUTION_DISABLED' | 'HANDSHAKE_INACTIVE' | 'HANDSHAKE_REVOKED' - | 'TOOL_NOT_GRANTED' - | 'SCOPE_NOT_ALLOWED' - | 'PURPOSE_MISMATCH' + | 'CONSENT_REQUIRED' + | 'CONSENT_NOT_FOUND' + | 'CONSENT_NOT_TAPPED' + | 'CONSENT_CONSUMED' + | 'INTENT_HASH_MISMATCH' | 'PARAMETER_CONSTRAINT_VIOLATION' | 'ATTESTATION_REQUIRED'; export type ToolAuthorizationResult = - | { readonly authorized: true } - | { readonly authorized: false; readonly reason: AuthorizationDenialReason; readonly details: string }; + | { readonly authorized: true; readonly consent: ExecutionConsentRow } + | { + readonly authorized: false + readonly reason: AuthorizationDenialReason + readonly details: string + /** True when the denial is a deviation [IX.19.2] (executed ≠ presented). */ + readonly deviation?: boolean + }; export interface ToolInvocationRequest { + readonly request_id: string; readonly handshake_id: string; readonly tool_name: string; readonly parameters: Record; readonly requested_scope: string; readonly requested_purpose: string; + readonly origin: string; + /** Reference to the per-tap consent record — REQUIRED, no default. */ + readonly consent_ref?: string | null; } -// ── Granted Tools Registry ── - -const GRANTED_TOOLS: ReadonlySet = new Set([ - 'read-context', - 'write-context', - 'decrypt-payload', - 'semantic-search', - 'cloud-escalation', - 'export-context', -]) - // ── Main Function ── export function authorizeToolInvocation( @@ -90,6 +105,8 @@ export function authorizeToolInvocation( requested_purpose: request.requested_purpose, authorized: result.authorized, denial_reason: result.authorized ? undefined : result.reason, + consent_ref: request.consent_ref ?? null, + deviation: result.authorized ? undefined : result.deviation === true || undefined, }, }) } catch { /* audit failure must not mask result */ } @@ -102,44 +119,51 @@ function runAuthorization( request: ToolInvocationRequest, now: Date, ): ToolAuthorizationResult { - // 1. Handshake exists and is active + // 1. Kill switch: refuses everything; never a consent-free path. + if (!isConsentTapExecutionEnabled()) { + return deny('EXECUTION_DISABLED', 'Tool execution is disabled (WRDESK_EXECUTION_CONSENT_TAP=0)') + } + + // 2. Relationship context is live — necessary, never sufficient. const record = getHandshakeRecord(db, request.handshake_id) if (!record) { return deny('HANDSHAKE_INACTIVE', `Handshake ${request.handshake_id} not found`) } - - // 2. Not revoked if (record.state === HS.REVOKED) { return deny('HANDSHAKE_REVOKED', `Handshake ${request.handshake_id} is revoked`) } - const inactiveDiag = diagnoseHandshakeInactive(db, request.handshake_id, now) if (!inactiveDiag.active) { return deny('HANDSHAKE_INACTIVE', inactiveDiag.reason) } - // 3. Tool is explicitly granted - if (!GRANTED_TOOLS.has(request.tool_name)) { - return deny('TOOL_NOT_GRANTED', `Tool "${request.tool_name}" is not in the granted tools set`) + // 3. Per-tap consent with Intent Hash [VII.10.1, IX.19.2]. No consent + // reference → refused; there is no default, no auto-accept, no cache. + if (!request.consent_ref) { + return deny('CONSENT_REQUIRED', 'Every execution requires a fresh human consent tap — no consent reference presented') } - - // 4. Scope check — use handshake policy - const policy = record.effective_policy - if (!policy.allowedScopes.includes('*')) { - if (!policy.allowedScopes.includes(request.requested_scope)) { - return deny('SCOPE_NOT_ALLOWED', `Scope "${request.requested_scope}" is not allowed by effective policy`) + const consent = verifyConsentForExecution(db, request.consent_ref, { + request_id: request.request_id, + handshake_id: request.handshake_id, + tool_name: request.tool_name, + scope_id: request.requested_scope, + purpose_id: request.requested_purpose, + parameters: request.parameters, + origin: request.origin, + }) + if (!consent.ok) { + const deviation = consent.reason === 'INTENT_HASH_MISMATCH' + return { + authorized: false, + reason: consent.reason, + details: deviation + ? 'Executed action diverges from the presented consent preview — consent record invalidated (deviation)' + : `Consent record check failed: ${consent.reason}`, + ...(deviation ? { deviation: true } : {}), } } - // 5. Purpose-specific checks - if (request.tool_name === 'cloud-escalation' && !policy.allowsCloudEscalation) { - return deny('PURPOSE_MISMATCH', 'Cloud escalation is not permitted by effective policy') - } - if (request.tool_name === 'export-context' && !policy.allowsExport) { - return deny('PURPOSE_MISMATCH', 'Export is not permitted by effective policy') - } - - // 6. Parameter constraints + // 4. Parameter constraints if (request.parameters) { for (const [key, value] of Object.entries(request.parameters)) { if (typeof value === 'string' && value.length > 1_000_000) { @@ -148,7 +172,7 @@ function runAuthorization( } } - // 7. Attestation check (for enterprise tier with attestation requirements) + // 5. Attestation check (for enterprise tier with attestation requirements) if (record.tier_snapshot?.effectiveTier === 'enterprise') { const signals = record.current_tier_signals if (!signals.hardwareAttestation?.verified) { @@ -156,7 +180,7 @@ function runAuthorization( } } - return { authorized: true } + return { authorized: true, consent: consent.consent } } function deny(reason: AuthorizationDenialReason, details: string): ToolAuthorizationResult { diff --git a/code/apps/electron-vite-project/electron/main/execution/__tests__/executeToolRequest.test.ts b/code/apps/electron-vite-project/electron/main/execution/__tests__/executeToolRequest.test.ts index 2a0e51070..dbc9b6495 100644 --- a/code/apps/electron-vite-project/electron/main/execution/__tests__/executeToolRequest.test.ts +++ b/code/apps/electron-vite-project/electron/main/execution/__tests__/executeToolRequest.test.ts @@ -1,80 +1,38 @@ /** - * executeToolRequest() — Authorization wiring, hardening, and audit tests. + * executeToolRequest() — Per-tap consent authorization (Phase 5, V4), + * hardening, and audit tests. * * Tests verify: - * - Authorization gate is mandatory and non-bypassable + * - No execution without a fresh, tapped, Intent-Hash-bound consent record + * - Consent is single-use; divergence between executed and presented + * action invalidates the consent (deviation) [IX.19.2] + * - Executions produce PoAE evidence records with intent hash + consent ref * - Request validation catches malformed input - * - Tool handlers only execute after successful authorization * - Parameter hardening (size, poisoned keys, timeout) * - Audit records created for both allow and deny decisions */ -import { describe, test, expect, beforeEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import Database from 'better-sqlite3' import { executeToolRequest } from '../executeToolRequest' import { registerTool, _resetRegistryForTesting } from '../toolRegistry' +import { + prepareExecutionConsent, + confirmExecutionConsent, +} from '../executionConsent' +import { setEvidenceDbProvider, listEvidenceRecords, verifyEvidenceChain } from '../../handshake/evidenceChain' +import { migrateHandshakeTables, insertHandshakeRecord } from '../../handshake/db' +import { HandshakeState } from '../../handshake/types' +import { buildActiveHandshakeRecord } from '../../handshake/__tests__/helpers' -// ── Mock DB ── +const HS = 'hs-001' -function makeMockDb(records: Record = {}, auditEntries: any[] = []) { - return { - prepare: (sql: string) => ({ - run: (...args: any[]) => { auditEntries.push({ sql, args }) }, - get: (...args: any[]) => { - if (sql.includes('handshakes') && sql.includes('handshake_id') && args.length > 0) { - return records[args[0]] ?? undefined - } - return undefined - }, - all: () => [], - }), - transaction: (fn: any) => fn, - } -} - -function makeHandshakeRow(overrides?: any) { - return { - handshake_id: 'hs-001', - relationship_id: 'rel-001', - state: 'ACTIVE', - initiator_json: JSON.stringify({ email: 'a@b.com', wrdesk_user_id: 'u-1', iss: 'i', sub: 's' }), - acceptor_json: JSON.stringify({ email: 'c@d.com', wrdesk_user_id: 'u-2', iss: 'i', sub: 's' }), - local_role: 'acceptor', - sharing_mode: 'reciprocal', - reciprocal_allowed: 1, - tier_snapshot_json: JSON.stringify({ claimedTier: null, computedTier: 'free', effectiveTier: 'free', signals: { plan: 'free', hardwareAttestation: null, dnsVerification: null, wrStampStatus: null }, downgraded: false }), - current_tier_signals_json: JSON.stringify({ plan: 'free', hardwareAttestation: null, dnsVerification: null, wrStampStatus: null }), - last_seq_sent: 0, - last_seq_received: 0, - last_capsule_hash_sent: '', - last_capsule_hash_received: 'a'.repeat(64), - effective_policy_json: JSON.stringify({ - allowedScopes: ['*'], - effectiveTier: 'free', - allowsCloudEscalation: false, - allowsExport: false, - onRevocationDeleteBlocks: false, - effectiveExternalProcessing: 'none', - reciprocalAllowed: true, - effectiveSharingModes: ['receive-only', 'reciprocal'], - }), - external_processing: 'none', - created_at: new Date().toISOString(), - activated_at: new Date().toISOString(), - expires_at: new Date(Date.now() + 86400000).toISOString(), - revoked_at: null, - revocation_source: null, - initiator_wrdesk_policy_hash: 'a'.repeat(64), - initiator_wrdesk_policy_version: '1.0', - acceptor_wrdesk_policy_hash: 'b'.repeat(64), - acceptor_wrdesk_policy_version: '1.0', - ...overrides, - } -} +let db: InstanceType function makeToolRequest(overrides?: any) { return { request_id: 'req-001', - handshake_id: 'hs-001', + handshake_id: HS, relationship_id: 'rel-001', tool_name: 'read-context', scope_id: 'test-scope', @@ -86,101 +44,166 @@ function makeToolRequest(overrides?: any) { } } +/** Prepare + tap a consent for the given request; returns request with consent_ref. */ +function withConsent(req: ReturnType) { + const prep = prepareExecutionConsent(db, { + request_id: req.request_id, + handshake_id: req.handshake_id, + tool_name: req.tool_name, + scope_id: req.scope_id, + purpose_id: req.purpose_id, + parameters: req.parameters, + origin: req.origin, + }) + const tap = confirmExecutionConsent(db, prep.consent_id, 'local-user-001') + expect(tap.ok).toBe(true) + return { ...req, consent_ref: prep.consent_id, __intent_hash: prep.intent_hash } +} + beforeEach(() => { _resetRegistryForTesting() + db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + insertHandshakeRecord(db, buildActiveHandshakeRecord()) + setEvidenceDbProvider(() => db) +}) + +afterEach(() => { + setEvidenceDbProvider(null) + try { db.close() } catch { /* noop */ } }) // ═══════════════════════════════════════════════════════════════════════ -// Authorization Wiring Tests +// Per-Tap Consent Authorization (V4) // ═══════════════════════════════════════════════════════════════════════ -describe('executeToolRequest — Authorization Wiring', () => { - // Test 1: Revoked handshake → denied, handler NOT called - test('1: revoked handshake → denied, tool handler NOT called', async () => { +describe('executeToolRequest — Per-Tap Consent Authorization', () => { + test('no consent reference → refused, tool handler NOT called', async () => { let handlerCalled = false registerTool('read-context', async () => { handlerCalled = true; return 'ok' }) - const db = makeMockDb({ 'hs-001': makeHandshakeRow({ state: 'REVOKED' }) }) const result = await executeToolRequest(db, makeToolRequest()) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('HANDSHAKE_REVOKED') - } + if (!result.success) expect(result.reason).toBe('CONSENT_REQUIRED') expect(handlerCalled).toBe(false) }) - // Test 2: Tool not granted → denied, handler NOT called - test('2: tool not granted → denied, tool handler NOT called', async () => { + test('prepared-but-untapped consent → refused (no auto-accept)', async () => { let handlerCalled = false - registerTool('delete-everything', async () => { handlerCalled = true; return 'ok' }) + registerTool('read-context', async () => { handlerCalled = true; return 'ok' }) - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = await executeToolRequest(db, makeToolRequest({ tool_name: 'delete-everything' })) + const req = makeToolRequest() + const prep = prepareExecutionConsent(db, { + request_id: req.request_id, + handshake_id: req.handshake_id, + tool_name: req.tool_name, + scope_id: req.scope_id, + purpose_id: req.purpose_id, + parameters: req.parameters, + origin: req.origin, + }) + // No confirmExecutionConsent — the human never tapped. + const result = await executeToolRequest(db, { ...req, consent_ref: prep.consent_id }) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('TOOL_NOT_GRANTED') - } + if (!result.success) expect(result.reason).toBe('CONSENT_NOT_TAPPED') expect(handlerCalled).toBe(false) }) - // Test 3: Scope disallowed → denied - test('3: scope disallowed → denied', async () => { - registerTool('read-context', async () => 'ok') + test('revoked handshake → denied even with a tapped consent', async () => { + let handlerCalled = false + registerTool('read-context', async () => { handlerCalled = true; return 'ok' }) - const restrictedPolicy = { - allowedScopes: ['allowed-scope-only'], - effectiveTier: 'free', - allowsCloudEscalation: false, - allowsExport: false, - onRevocationDeleteBlocks: false, - effectiveExternalProcessing: 'none', - reciprocalAllowed: true, - effectiveSharingModes: ['receive-only'], - } - const db = makeMockDb({ - 'hs-001': makeHandshakeRow({ effective_policy_json: JSON.stringify(restrictedPolicy) }), - }) - const result = await executeToolRequest(db, makeToolRequest({ scope_id: 'forbidden-scope' })) + const req = withConsent(makeToolRequest()) + db.prepare(`UPDATE handshakes SET state = ? WHERE handshake_id = ?`).run(HandshakeState.REVOKED, HS) + + const result = await executeToolRequest(db, req) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('SCOPE_NOT_ALLOWED') + if (!result.success) expect(result.reason).toBe('HANDSHAKE_REVOKED') + expect(handlerCalled).toBe(false) + }) + + test('valid consented request → tool executes, PoAE record with intent hash + consent ref', async () => { + registerTool('read-context', async (params) => ({ blocks: ['block-1'], query: params.query })) + + const req = withConsent(makeToolRequest({ parameters: { query: 'test' } })) + const result = await executeToolRequest(db, req) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.result).toEqual({ blocks: ['block-1'], query: 'test' }) } + + const records = listEvidenceRecords(db, HS) + const poae = records.filter((r) => r.record_type === 'poae') + expect(poae.length).toBe(1) + const payload = JSON.parse(poae[0].payload_json) + expect(payload.kind).toBe('execution') + expect(payload.intent_hash).toBe(req.__intent_hash) + expect(payload.consent_id).toBe(req.consent_ref) + expect(payload.outcome).toBe('success') + expect(verifyEvidenceChain(db, HS).valid).toBe(true) }) - // Test 4: Parameter constraint violation → denied - test('4: parameter constraint violation → denied at authorization', async () => { - registerTool('read-context', async () => 'ok') + test('consent is single-use: replaying the same consent → refused', async () => { + let calls = 0 + registerTool('read-context', async () => { calls += 1; return 'ok' }) - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = await executeToolRequest(db, makeToolRequest({ - parameters: { data: 'x'.repeat(1_000_001) }, - })) + const req = withConsent(makeToolRequest()) + const first = await executeToolRequest(db, req) + expect(first.success).toBe(true) + + const second = await executeToolRequest(db, req) + expect(second.success).toBe(false) + if (!second.success) expect(second.reason).toBe('CONSENT_CONSUMED') + expect(calls).toBe(1) + }) + + test('mutating the proposal after preview → INTENT_HASH_MISMATCH, deviation PoAE, no execution [IX.19.2]', async () => { + let handlerCalled = false + registerTool('read-context', async () => { handlerCalled = true; return 'ok' }) + + const req = withConsent(makeToolRequest({ parameters: { path: '/safe' } })) + // The action about to execute differs from the presented preview. + const tampered = { ...req, parameters: { path: '/etc/shadow' } } + const result = await executeToolRequest(db, tampered) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('PARAMETER_CONSTRAINT_VIOLATION') + if (!result.success) expect(result.reason).toBe('INTENT_HASH_MISMATCH') + expect(handlerCalled).toBe(false) + + const poae = listEvidenceRecords(db, HS).filter((r) => r.record_type === 'poae') + expect(poae.length).toBe(1) + expect(JSON.parse(poae[0].payload_json).outcome).toBe('refused_deviation') + }) + + test('kill switch (WRDESK_EXECUTION_CONSENT_TAP=0) refuses ALL execution — never a consent-free path', async () => { + let handlerCalled = false + registerTool('read-context', async () => { handlerCalled = true; return 'ok' }) + + process.env.WRDESK_EXECUTION_CONSENT_TAP = '0' + try { + const req = withConsent(makeToolRequest()) + const result = await executeToolRequest(db, req) + expect(result.success).toBe(false) + if (!result.success) expect(result.reason).toBe('EXECUTION_DISABLED') + expect(handlerCalled).toBe(false) + } finally { + delete process.env.WRDESK_EXECUTION_CONSENT_TAP } }) - // Test 5: Valid authorization → tool executes, returns success - test('5: valid authorization → tool executes, returns success', async () => { - registerTool('read-context', async (params) => { - return { blocks: ['block-1', 'block-2'], query: params.query } - }) + test('parameter constraint violation → denied at authorization', async () => { + registerTool('read-context', async () => 'ok') - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = await executeToolRequest(db, makeToolRequest({ - parameters: { query: 'test' }, - })) + const req = withConsent(makeToolRequest({ parameters: { data: 'x'.repeat(1_000_001) } })) + const result = await executeToolRequest(db, req) - expect(result.success).toBe(true) - if (result.success) { - expect(result.result).toEqual({ blocks: ['block-1', 'block-2'], query: 'test' }) - expect(result.duration_ms).toBeGreaterThanOrEqual(0) - } + expect(result.success).toBe(false) + if (!result.success) expect(result.reason).toBe('PARAMETER_CONSTRAINT_VIOLATION') }) }) @@ -189,60 +212,49 @@ describe('executeToolRequest — Authorization Wiring', () => { // ═══════════════════════════════════════════════════════════════════════ describe('executeToolRequest — Hardening', () => { - // Test 6: Oversized parameters → rejected early - test('6: oversized parameters → rejected before authorization', async () => { + test('oversized parameters → rejected before authorization', async () => { registerTool('read-context', async () => 'ok') - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) const result = await executeToolRequest(db, makeToolRequest({ parameters: { data: 'x'.repeat(6 * 1024 * 1024) }, })) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('PARAMETER_SIZE_EXCEEDED') - } + if (!result.success) expect(result.reason).toBe('PARAMETER_SIZE_EXCEEDED') }) - // Test 7: __proto__ in parameters → rejected - test('7: __proto__ in parameters → rejected', async () => { + test('__proto__ in parameters → rejected', async () => { registerTool('read-context', async () => 'ok') - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) const poisoned = Object.create(null) poisoned.__proto__ = { malicious: true } poisoned.safe_key = 'value' - const result = await executeToolRequest(db, makeToolRequest({ - parameters: poisoned, - })) + const result = await executeToolRequest(db, makeToolRequest({ parameters: poisoned })) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('POISONED_PARAMETERS') - } + if (!result.success) expect(result.reason).toBe('POISONED_PARAMETERS') }) - // Test 8: Tool timeout exceeded → fail-closed - test('8: tool timeout exceeded → fail-closed', async () => { + test('tool timeout exceeded → fail-closed, failure PoAE recorded', async () => { registerTool('read-context', async () => { await new Promise(resolve => setTimeout(resolve, 60_000)) return 'should never reach' }) - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - - // Temporarily override timeout for test speed const { EXECUTION_CONSTANTS } = await import('../types') const originalTimeout = EXECUTION_CONSTANTS.TOOL_TIMEOUT_MS ;(EXECUTION_CONSTANTS as any).TOOL_TIMEOUT_MS = 50 try { - const result = await executeToolRequest(db, makeToolRequest()) + const req = withConsent(makeToolRequest()) + const result = await executeToolRequest(db, req) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('TOOL_TIMEOUT') - } + if (!result.success) expect(result.reason).toBe('TOOL_TIMEOUT') + + const poae = listEvidenceRecords(db, HS).filter((r) => r.record_type === 'poae') + expect(poae.length).toBe(1) + expect(JSON.parse(poae[0].payload_json).outcome).toBe('failure') } finally { ;(EXECUTION_CONSTANTS as any).TOOL_TIMEOUT_MS = originalTimeout } @@ -250,39 +262,28 @@ describe('executeToolRequest — Hardening', () => { test('nested __proto__ in parameters → rejected', async () => { registerTool('read-context', async () => 'ok') - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - // Must use Object.create(null) to keep __proto__ as an own key const inner = Object.create(null) inner.__proto__ = {} inner.safe = 'value' - const result = await executeToolRequest(db, makeToolRequest({ - parameters: { nested: inner }, - })) + const result = await executeToolRequest(db, makeToolRequest({ parameters: { nested: inner } })) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('POISONED_PARAMETERS') - } + if (!result.success) expect(result.reason).toBe('POISONED_PARAMETERS') }) test('constructor key in parameters → rejected', async () => { registerTool('read-context', async () => 'ok') - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) const params = Object.create(null) params.constructor = 'malicious' params.valid = 'data' - const result = await executeToolRequest(db, makeToolRequest({ - parameters: params, - })) + const result = await executeToolRequest(db, makeToolRequest({ parameters: params })) expect(result.success).toBe(false) - if (!result.success) { - expect(result.reason).toBe('POISONED_PARAMETERS') - } + if (!result.success) expect(result.reason).toBe('POISONED_PARAMETERS') }) }) @@ -291,35 +292,24 @@ describe('executeToolRequest — Hardening', () => { // ═══════════════════════════════════════════════════════════════════════ describe('executeToolRequest — Audit', () => { - // Test 9: Allow decision → audit record created - test('9: allow decision → execution audit record created', async () => { + test('allow decision → authorization + execution audit records created', async () => { registerTool('read-context', async () => ({ data: 'test' })) - const auditEntries: any[] = [] - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }, auditEntries) - const result = await executeToolRequest(db, makeToolRequest()) - + const req = withConsent(makeToolRequest()) + const result = await executeToolRequest(db, req) expect(result.success).toBe(true) - // Authorization audit + execution audit - const executionAudits = auditEntries.filter(e => - e.sql.includes('audit_log') && e.sql.includes('INSERT'), - ) - expect(executionAudits.length).toBeGreaterThanOrEqual(1) + const rows = db.prepare(`SELECT action FROM audit_log WHERE handshake_id = ?`).all(HS) as Array<{ action: string }> + expect(rows.some((r) => r.action === 'TOOL_AUTHORIZED')).toBe(true) + expect(rows.some((r) => r.action === 'TOOL_EXECUTION_SUCCESS')).toBe(true) }) - // Test 10: Deny decision → audit record created - test('10: deny decision → audit record created', async () => { - const auditEntries: any[] = [] - const db = makeMockDb({}, auditEntries) + test('deny decision → audit record created', async () => { const result = await executeToolRequest(db, makeToolRequest()) - expect(result.success).toBe(false) - const auditInserts = auditEntries.filter(e => - e.sql.includes('audit_log') && e.sql.includes('INSERT'), - ) - expect(auditInserts.length).toBeGreaterThanOrEqual(1) + const rows = db.prepare(`SELECT action FROM audit_log WHERE handshake_id = ?`).all(HS) as Array<{ action: string }> + expect(rows.some((r) => r.action === 'TOOL_DENIED')).toBe(true) }) }) @@ -329,35 +319,30 @@ describe('executeToolRequest — Audit', () => { describe('executeToolRequest — Request Validation', () => { test('null request → rejected', async () => { - const db = makeMockDb() const result = await executeToolRequest(db, null) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('INVALID_REQUEST') }) test('missing request_id → rejected', async () => { - const db = makeMockDb() const result = await executeToolRequest(db, { ...makeToolRequest(), request_id: '' }) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('INVALID_REQUEST') }) test('missing tool_name → rejected', async () => { - const db = makeMockDb() const result = await executeToolRequest(db, { ...makeToolRequest(), tool_name: '' }) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('INVALID_REQUEST') }) test('invalid origin → rejected', async () => { - const db = makeMockDb() const result = await executeToolRequest(db, { ...makeToolRequest(), origin: 'hacker' }) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('INVALID_REQUEST') }) test('invalid requested_at (not ISO 8601) → rejected', async () => { - const db = makeMockDb() const result = await executeToolRequest(db, { ...makeToolRequest(), requested_at: 'not-a-date' }) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('INVALID_REQUEST') @@ -365,16 +350,15 @@ describe('executeToolRequest — Request Validation', () => { test('missing handshake_id → rejected with MISSING_HANDSHAKE', async () => { registerTool('read-context', async () => 'ok') - const db = makeMockDb() const result = await executeToolRequest(db, { ...makeToolRequest(), handshake_id: undefined }) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('MISSING_HANDSHAKE') }) test('tool not found in registry → TOOL_NOT_FOUND after auth', async () => { - // Don't register any tool — but grant the tool in authorization - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = await executeToolRequest(db, makeToolRequest()) + // Consent granted, but no handler registered. + const req = withConsent(makeToolRequest()) + const result = await executeToolRequest(db, req) expect(result.success).toBe(false) if (!result.success) expect(result.reason).toBe('TOOL_NOT_FOUND') }) diff --git a/code/apps/electron-vite-project/electron/main/execution/executeToolRequest.ts b/code/apps/electron-vite-project/electron/main/execution/executeToolRequest.ts index 0a8e28f1f..aa043e868 100644 --- a/code/apps/electron-vite-project/electron/main/execution/executeToolRequest.ts +++ b/code/apps/electron-vite-project/electron/main/execution/executeToolRequest.ts @@ -1,14 +1,19 @@ /** - * Canonical Tool Execution Entry Point + * Canonical Tool Execution Entry Point (Phase 5 — V4) * * Every tool invocation MUST pass through executeToolRequest(). There is no * alternate runner. Steps (ordered, fail on first error): * * 1. Validate request shape * 2. Resolve governance context (handshake record, active + not revoked) - * 3. Authorize via authorizeToolInvocation() — deny → fail-closed - * 4. Execute tool handler with timeout + parameter sanitization - * 5. Audit (request_id, tool_name, handshake_id, allow/deny, duration) + * 3. Authorize via authorizeToolInvocation() — which requires a fresh, + * single-use, Intent-Hash-bound human consent record [VII.10.1, + * IX.19.2]; deny → fail-closed. Intent-hash divergence is a deviation + * and produces a deviation PoAE record. + * 4. Consume the consent record (single use), execute the tool handler + * with timeout + parameter sanitization + * 5. Audit + PoAE evidence record carrying the Intent Hash and the + * consent reference [IX.19.1] * * Any exception → caught → { success: false }. * No tool code executes if authorization fails. @@ -19,6 +24,8 @@ import { EXECUTION_CONSTANTS } from './types' import { getToolHandler } from './toolRegistry' import { authorizeToolInvocation } from '../enforcement/authorizeToolInvocation' import { insertAuditLogEntry } from '../handshake/db' +import { consumeExecutionConsent, paramsDigest } from './executionConsent' +import { appendEvidenceBestEffort, poaeExecutionPayload } from '../handshake/evidenceChain' const POISONED_KEYS = new Set(['__proto__', 'constructor', 'prototype']) @@ -95,35 +102,91 @@ export async function executeToolRequest( return fail('MISSING_HANDSHAKE', 'handshake_id is required for tool execution', startTime) } - // Step 3: Authorize + // Step 3: Authorize — per-tap consent with Intent Hash (V4). There is no + // standing execution right; a missing/mismatching consent record refuses. const authResult = authorizeToolInvocation(db, { + request_id: req.request_id, handshake_id: req.handshake_id, tool_name: req.tool_name, parameters: req.parameters, requested_scope: req.scope_id ?? '*', requested_purpose: req.purpose_id ?? 'general', + origin: req.origin, + consent_ref: req.consent_ref ?? null, }) if (!authResult.authorized) { auditExecution(db, req, false, authResult.reason, startTime) + // Intent-hash divergence is a deviation [IX.19.2] — evidence it. + if (authResult.deviation) { + appendEvidenceBestEffort({ + chainId: req.handshake_id, + recordType: 'poae', + payload: poaeExecutionPayload({ + handshake_id: req.handshake_id, + request_id: req.request_id, + tool_name: req.tool_name, + intent_hash: '', + consent_id: req.consent_ref ?? '', + outcome: 'refused_deviation', + params_digest: paramsDigest(req.parameters), + }), + }) + } return fail(authResult.reason, authResult.details ?? 'Authorization denied', startTime) } + const consent = authResult.consent - // Step 4: Execute tool handler + // Step 4: Execute tool handler — consent is consumed FIRST (single use): + // even a crashing handler never leaves a reusable consent behind. const handler = getToolHandler(req.tool_name) if (!handler) { auditExecution(db, req, false, 'TOOL_NOT_FOUND', startTime) return fail('TOOL_NOT_FOUND', `No handler registered for tool "${req.tool_name}"`, startTime) } - const sanitized = sanitizeParameters(req.parameters) - const result = await withTimeout( - handler(sanitized), - EXECUTION_CONSTANTS.TOOL_TIMEOUT_MS, - ) + consumeExecutionConsent(db, consent.consent_id) + + let result: unknown + try { + const sanitized = sanitizeParameters(req.parameters) + result = await withTimeout( + handler(sanitized), + EXECUTION_CONSTANTS.TOOL_TIMEOUT_MS, + ) + } catch (execErr) { + appendEvidenceBestEffort({ + chainId: req.handshake_id, + recordType: 'poae', + payload: poaeExecutionPayload({ + handshake_id: req.handshake_id, + request_id: req.request_id, + tool_name: req.tool_name, + intent_hash: consent.intent_hash, + consent_id: consent.consent_id, + outcome: 'failure', + params_digest: consent.params_digest, + }), + }) + throw execErr + } - // Step 5: Audit success + // Step 5: Audit + PoAE — Intent Hash and consent reference bound into + // the execution's evidence record [IX.19.1/19.2]. auditExecution(db, req, true, 'OK', startTime) + appendEvidenceBestEffort({ + chainId: req.handshake_id, + recordType: 'poae', + payload: poaeExecutionPayload({ + handshake_id: req.handshake_id, + request_id: req.request_id, + tool_name: req.tool_name, + intent_hash: consent.intent_hash, + consent_id: consent.consent_id, + outcome: 'success', + params_digest: consent.params_digest, + }), + }) return { success: true, diff --git a/code/apps/electron-vite-project/electron/main/execution/executionConsent.ts b/code/apps/electron-vite-project/electron/main/execution/executionConsent.ts new file mode 100644 index 000000000..becc8171e --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/execution/executionConsent.ts @@ -0,0 +1,217 @@ +/** + * Per-tap execution consent (Phase 5 — V4) [VII.10.1, VII.2.6, IX.19.2] + * + * Execution grants are DELETED. There is no standing set of granted tools, + * no ACTIVE-handshake blanket authorization, no auto-accept, no bypass API, + * and no batch-approve-without-visible-set [VII.10.5.5]. Every execution is + * a distinct human consent tap: + * + * 1. `prepareExecutionConsent` renders the consent preview from the bound + * request definition (client-generated, canonical) and computes the + * INTENT HASH — the canonical hash over the preview exactly as + * presented, under a domain-separation tag. + * 2. `confirmExecutionConsent` records the human tap, binding actor + + * intent hash into a single-use consent record. + * 3. `verifyConsentForExecution` (called by the one execution entry point) + * recomputes the intent hash from the request ABOUT TO EXECUTE. + * Divergence between executed and presented action invalidates the + * consent record and is a deviation [IX.19.2]. + * 4. The consent record is consumed exactly once; the execution's PoAE + * record carries the intent hash and the consent reference. + * + * Feature gate (risk register hard coupling): the consent-tap flow ships in + * the same release as the grant deletion. `WRDESK_EXECUTION_CONSENT_TAP=0` + * is a fail-closed KILL SWITCH — it refuses all execution; it never restores + * a consent-free path. + */ + +import { createHash, randomUUID } from 'node:crypto' +import { canonicalJsonString, domainTag, type CanonicalJsonValue } from '@repo/ingestion-core' +import type { ToolRequest } from './types' + +// ── Feature gate (fail-closed kill switch, never a bypass) ────────────────── + +export function isConsentTapExecutionEnabled(): boolean { + return process.env.WRDESK_EXECUTION_CONSENT_TAP !== '0' +} + +// ── Schema ──────────────────────────────────────────────────────────────────── + +export function ensureExecutionConsentSchema(db: any): void { + db.exec(` + CREATE TABLE IF NOT EXISTS wr_execution_consents ( + consent_id TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + handshake_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + intent_hash TEXT NOT NULL, + preview_json TEXT NOT NULL, + params_digest TEXT NOT NULL, + created_at TEXT NOT NULL, + consented_at TEXT, + actor_wrdesk_user_id TEXT, + consumed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_wr_exec_consents_request ON wr_execution_consents (request_id); + `) +} + +// ── Intent Hash ─────────────────────────────────────────────────────────────── + +const INTENT_DOMAIN = 'wr.execution.intent' + +export function paramsDigest(parameters: Record): string { + return createHash('sha256') + .update(JSON.stringify(parameters ?? {}), 'utf8') + .digest('hex') +} + +/** + * The consent preview — client-generated from the bound request definition, + * never counterparty free text. Canonically hashable at presentation time. + * Parameters enter as a digest: the preview names the action and its exact + * parameter bytes without embedding content. + */ +export function buildExecutionPreview(req: { + request_id: string + handshake_id: string + tool_name: string + scope_id?: string + purpose_id?: string + parameters: Record + origin: string +}): Record { + return { + request_id: req.request_id, + handshake_id: req.handshake_id, + tool_name: req.tool_name, + scope: req.scope_id ?? '*', + purpose: req.purpose_id ?? 'general', + params_digest: paramsDigest(req.parameters), + origin: req.origin, + } +} + +/** Intent Hash = domain-tagged canonical hash of the preview as presented. */ +export function computeIntentHash(preview: Record): string { + return createHash('sha256') + .update(domainTag(INTENT_DOMAIN, 1)) + .update(canonicalJsonString(preview), 'utf8') + .digest('hex') +} + +// ── Consent lifecycle ───────────────────────────────────────────────────────── + +export interface ExecutionConsentRow { + consent_id: string + request_id: string + handshake_id: string + tool_name: string + intent_hash: string + preview_json: string + params_digest: string + created_at: string + consented_at: string | null + actor_wrdesk_user_id: string | null + consumed_at: string | null +} + +/** + * Step 1 — render the consent screen material for ONE request. The returned + * preview is exactly what the consent record pins; the UI must present it + * unmodified. + */ +export function prepareExecutionConsent( + db: any, + req: Pick & { + handshake_id: string + scope_id?: string + purpose_id?: string + }, + now: Date = new Date(), +): { consent_id: string; intent_hash: string; preview: Record } { + ensureExecutionConsentSchema(db) + const preview = buildExecutionPreview(req) + const intentHash = computeIntentHash(preview) + const consentId = randomUUID() + db.prepare( + `INSERT INTO wr_execution_consents + (consent_id, request_id, handshake_id, tool_name, intent_hash, preview_json, params_digest, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + consentId, + req.request_id, + req.handshake_id, + req.tool_name, + intentHash, + canonicalJsonString(preview), + paramsDigest(req.parameters), + now.toISOString(), + ) + return { consent_id: consentId, intent_hash: intentHash, preview } +} + +/** Step 2 — the human tap. Binds the actor; without it, execution refuses. */ +export function confirmExecutionConsent( + db: any, + consentId: string, + actorWrdeskUserId: string, + now: Date = new Date(), +): { ok: true } | { ok: false; reason: 'not_found' | 'already_consumed' } { + ensureExecutionConsentSchema(db) + const row = db + .prepare(`SELECT consent_id, consumed_at FROM wr_execution_consents WHERE consent_id = ?`) + .get(consentId) as { consent_id: string; consumed_at: string | null } | undefined + if (!row) return { ok: false, reason: 'not_found' } + if (row.consumed_at) return { ok: false, reason: 'already_consumed' } + db.prepare( + `UPDATE wr_execution_consents SET consented_at = ?, actor_wrdesk_user_id = ? WHERE consent_id = ?`, + ).run(now.toISOString(), actorWrdeskUserId, consentId) + return { ok: true } +} + +export type ConsentVerification = + | { ok: true; consent: ExecutionConsentRow } + | { + ok: false + reason: + | 'CONSENT_NOT_FOUND' + | 'CONSENT_NOT_TAPPED' + | 'CONSENT_CONSUMED' + | 'INTENT_HASH_MISMATCH' + } + +/** + * Step 3 — gate check at the execution entry point. Recomputes the intent + * hash from the request about to execute; divergence from the presented + * preview invalidates the consent record [IX.19.2]. Single-use. + */ +export function verifyConsentForExecution( + db: any, + consentId: string, + req: Parameters[0], +): ConsentVerification { + ensureExecutionConsentSchema(db) + const row = db + .prepare(`SELECT * FROM wr_execution_consents WHERE consent_id = ?`) + .get(consentId) as ExecutionConsentRow | undefined + if (!row) return { ok: false, reason: 'CONSENT_NOT_FOUND' } + if (row.consumed_at) return { ok: false, reason: 'CONSENT_CONSUMED' } + if (!row.consented_at || !row.actor_wrdesk_user_id) { + return { ok: false, reason: 'CONSENT_NOT_TAPPED' } + } + const executedIntentHash = computeIntentHash(buildExecutionPreview(req)) + if (executedIntentHash !== row.intent_hash) { + return { ok: false, reason: 'INTENT_HASH_MISMATCH' } + } + return { ok: true, consent: row } +} + +/** Step 4 — consume exactly once (called by the execution entry point). */ +export function consumeExecutionConsent(db: any, consentId: string, now: Date = new Date()): void { + ensureExecutionConsentSchema(db) + db.prepare(`UPDATE wr_execution_consents SET consumed_at = ? WHERE consent_id = ? AND consumed_at IS NULL`).run( + now.toISOString(), + consentId, + ) +} diff --git a/code/apps/electron-vite-project/electron/main/execution/types.ts b/code/apps/electron-vite-project/electron/main/execution/types.ts index da8dc948d..13169e05b 100644 --- a/code/apps/electron-vite-project/electron/main/execution/types.ts +++ b/code/apps/electron-vite-project/electron/main/execution/types.ts @@ -16,6 +16,12 @@ export interface ToolRequest { readonly parameters: Record; readonly requested_at: string; readonly origin: ToolRequestOrigin; + /** + * Phase 5 (V4) [VII.10.1]: reference to the single-use, Intent-Hash-bound + * human consent record for THIS execution. Required — execution without a + * fresh consent tap is refused fail-closed. + */ + readonly consent_ref?: string; } export type ToolRequestOrigin = 'local_ui' | 'extension' | 'sandbox' | 'automation'; diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/acceptX25519Binding.internal.regression.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/acceptX25519Binding.internal.regression.test.ts index 1cd0cf08f..a5957c620 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/acceptX25519Binding.internal.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/acceptX25519Binding.internal.regression.test.ts @@ -76,7 +76,7 @@ function internalPendingRecord(overrides: Partial): HandshakeRe counterparty_p2p_token: null, counterparty_public_key: 'a'.repeat(64), receiver_email: 'user-int@test.com', - handshake_type: 'internal', + same_principal: true, initiator_coordination_device_id: 'initiator-orch-1', initiator_device_role: 'host', initiator_device_name: 'HostComputer', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/antiRollback.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/antiRollback.test.ts new file mode 100644 index 000000000..15dd26490 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/antiRollback.test.ts @@ -0,0 +1,100 @@ +/** + * Phase 2 — acceptance test 6: generic anti-rollback high-water store (G4) + * [IX.4.2, X.7.8]. + * + * - A validly signed object with a version BELOW the persisted high-water + * mark is rejected fail-closed as a rollback. + * - Equal versions are accepted (idempotent redelivery is not a rollback). + * - The documented backup/restore scenario is exercised: the store lives in + * the same DB file as the objects it guards, so a whole-file restore + * keeps marks and data coherent (no mass-rejection), and the operator + * restore marker records the discontinuity in the audit log. + */ + +import { describe, it, expect } from 'vitest' +import Database from 'better-sqlite3' + +import { migrateHandshakeTables } from '../db' +import { enforceHighWater, getHighWater, recordRestoreMarker } from '../antiRollback' +import { checkAndRecordNonce } from '../nonceStore' + +function makeDb(): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + return db +} + +describe('Phase 2 — anti-rollback high-water store (G4)', () => { + it('accepts first-seen, raises monotonically, rejects below the mark fail-closed', () => { + const db = makeDb() + + expect(enforceHighWater(db, 'wr.core', 'obj-1', 3)).toEqual({ ok: true, raised: true, highWater: 3 }) + expect(enforceHighWater(db, 'wr.core', 'obj-1', 7)).toEqual({ ok: true, raised: true, highWater: 7 }) + + // Equal version: idempotent redelivery, not a rollback. + expect(enforceHighWater(db, 'wr.core', 'obj-1', 7)).toEqual({ ok: true, raised: false, highWater: 7 }) + + // Below the mark: rejected regardless of signature validity upstream. + const rejected = enforceHighWater(db, 'wr.core', 'obj-1', 5) + expect(rejected).toEqual({ ok: false, reason: 'rollback', highWater: 7, presented: 5 }) + expect(getHighWater(db, 'wr.core', 'obj-1')).toBe(7) + }) + + it('keys by (object class, object identity) — no cross-object bleed', () => { + const db = makeDb() + enforceHighWater(db, 'wr.core', 'obj-a', 10) + expect(enforceHighWater(db, 'wr.core', 'obj-b', 1).ok).toBe(true) + expect(enforceHighWater(db, 'wr.policy', 'obj-a', 1).ok).toBe(true) + expect(enforceHighWater(db, 'wr.core', 'obj-a', 9).ok).toBe(false) + }) + + it('rejects malformed versions fail-closed', () => { + const db = makeDb() + expect(enforceHighWater(db, 'wr.core', 'obj-x', -1).ok).toBe(false) + expect(enforceHighWater(db, 'wr.core', 'obj-x', 1.5).ok).toBe(false) + expect(enforceHighWater(db, 'wr.core', 'obj-x', Number.NaN).ok).toBe(false) + // Nothing was persisted by the malformed attempts. + expect(getHighWater(db, 'wr.core', 'obj-x')).toBeNull() + }) + + it('documented restore scenario: marks travel with the DB file; marker records the discontinuity', () => { + // "Live" DB advances past the backup point. + const live = makeDb() + enforceHighWater(live, 'wr.core', 'rel-1', 4) + enforceHighWater(live, 'wr.core', 'rel-1', 9) + + // The "backup" is a snapshot of the WHOLE file at version 4 — store and + // objects together. Simulated as a second DB whose mark is 4. + const restored = makeDb() + enforceHighWater(restored, 'wr.core', 'rel-1', 4) + + // Post-restore: objects at the restored version are NOT mass-rejected — + // the mark travelled with the data (primary risk-register failure mode). + expect(enforceHighWater(restored, 'wr.core', 'rel-1', 4)).toEqual({ ok: true, raised: false, highWater: 4 }) + // Progress resumes from the restored mark. + expect(enforceHighWater(restored, 'wr.core', 'rel-1', 5).ok).toBe(true) + // Genuine rollback below the restored mark is still caught. + expect(enforceHighWater(restored, 'wr.core', 'rel-1', 3).ok).toBe(false) + + // Step (c) of the restore procedure: operator marker in the audit log. + recordRestoreMarker(restored, { restoredFrom: 'backup-2026-07-20', operator: 'ops@dev.test' }) + const marker = restored + .prepare("SELECT action, reason_code, metadata FROM audit_log WHERE action = 'HIGH_WATER_RESTORE_MARKER'") + .get() as { action: string; reason_code: string; metadata: string } + expect(marker).toBeTruthy() + expect(marker.reason_code).toBe('operator_restore') + expect(JSON.parse(marker.metadata).restoredFrom).toBe('backup-2026-07-20') + }) +}) + +describe('Phase 2 — core nonce store unit semantics [VII.3.1]', () => { + it('first-seen ok; same nonce + same bound hash ok (redelivery); different hash → replay', () => { + const db = makeDb() + expect(checkAndRecordNonce(db, 's', 'n1', 'hash-a')).toEqual({ ok: true, firstSeen: true }) + expect(checkAndRecordNonce(db, 's', 'n1', 'hash-a')).toEqual({ ok: true, firstSeen: false }) + expect(checkAndRecordNonce(db, 's', 'n1', 'hash-b')).toEqual({ ok: false, reason: 'replay', boundHash: 'hash-a' }) + // Scopes are independent. + expect(checkAndRecordNonce(db, 'other-scope', 'n1', 'hash-b')).toEqual({ ok: true, firstSeen: true }) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/connectOfferConsentTestKit.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/connectOfferConsentTestKit.ts new file mode 100644 index 000000000..928f39e1a --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/connectOfferConsentTestKit.ts @@ -0,0 +1,107 @@ +/** + * Test kit for the Phase-4 Connect-offer consent gate [IX.3.1]. + * + * Inbound `handshake-initiate` capsules no longer create relationship rows + * directly — they stage a Connect offer, and only a consent event lets the + * ONE formation pipeline create the record. Tests that exercise the + * post-formation surface (accept / refresh / context_sync / revoke) use + * `submitCapsuleThroughConsentGate` to walk the REAL staged → consent → + * record path instead of the deleted auto-insert. + * + * This is NOT a bypass: the consent step goes through + * `prepareFormationConsent` (hash-pinned consent record) and the second + * ingest carries the `formationConsent` ref exactly like the production + * `handshake.accept` / `handshake.consentToConnectOffer` flows. + */ + +import Database from 'better-sqlite3' +import { setConnectOfferDbProvider, prepareFormationConsent } from '../formationPipeline' +import { handleIngestionRPC } from '../../ingestion/ipc' +import type { SSOSession } from '../types' + +let stagingDb: InstanceType | null = null + +/** Point the Connect-offer staging store at a fresh in-memory DB (call in beforeEach). */ +export function installInMemoryConnectOffers(): void { + try { + stagingDb?.close() + } catch { + /* noop */ + } + stagingDb = new Database(':memory:') + setConnectOfferDbProvider(() => stagingDb) +} + +/** Restore the default provider (call in afterEach). */ +export function uninstallInMemoryConnectOffers(): void { + try { + stagingDb?.close() + } catch { + /* noop */ + } + stagingDb = null + setConnectOfferDbProvider(null) +} + +/** + * Ingest a capsule. When an inbound initiate stages a Connect offer, consent + * to it as the receiving session and re-run the ingest behind the consent + * gate, returning the final (record-creating) result. All other capsule + * types and every failure pass through unchanged. + */ +export async function submitCapsuleThroughConsentGate( + capsuleJson: string, + db: unknown, + session: SSOSession, + opts?: { sourceType?: string; channelId?: string }, +): Promise { + const sourceType = opts?.sourceType ?? 'email' + const ingestParams = { + rawInput: { body: capsuleJson, mime_type: 'application/vnd.beap+json' }, + sourceType, + transportMeta: { + channel_id: opts?.channelId ?? 'test-consent-gate', + mime_type: 'application/vnd.beap+json', + }, + } + const first = await handleIngestionRPC('ingestion.ingest', ingestParams, db, session) + + const staged = first?.handshake_result + if (!first?.success || !staged || staged.staged !== true || !staged.offerId) { + return first + } + + let prep = prepareFormationConsent({ + offerId: staged.offerId, + actorWrdeskUserId: session.wrdesk_user_id, + }) + if (!prep.ok && prep.reason === 'OFFER_NOT_CONSENTABLE' && stagingDb) { + // Two-party tests replay the same initiate into each party's relationship + // DB while sharing ONE staging store (production gives each device its + // own). Re-arm the consumed offer so the second party can consent too. + stagingDb + .prepare( + `UPDATE wr_connect_offers SET consumed_at = NULL, consumed_action = NULL, consent_id = NULL WHERE offer_id = ?`, + ) + .run(staged.offerId) + prep = prepareFormationConsent({ + offerId: staged.offerId, + actorWrdeskUserId: session.wrdesk_user_id, + }) + } + if (!prep.ok) { + return { + ...first, + success: false, + error: `Consent preparation failed: ${prep.reason}`, + reason: prep.reason, + } + } + + return handleIngestionRPC( + 'ingestion.ingest', + { ...ingestParams, formationConsent: prep.consentRef }, + db, + session, + ) +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/connectOfferWrCodeSchema.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/connectOfferWrCodeSchema.test.ts new file mode 100644 index 000000000..05434da23 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/connectOfferWrCodeSchema.test.ts @@ -0,0 +1,314 @@ +/** + * Phase 4 / 4B exit criteria — offer schema, preview-hash coverage, consent gate. + * + * The preview hash is what the operator's consent is pinned to. If two offers + * that differ in something the operator was shown produce the same hash, the + * consent record does not actually bind what was consented to — so the coverage + * assertions below are the substance of this suite, not paperwork. + */ +import { describe, expect, it } from 'vitest' +import { createRequire } from 'node:module' +import { + buildConnectOfferPreview, + ensureConnectOfferSchema, + insertConsentRecord, + revalidateOfferStatusForConsent, + stageConnectOffer, + type ConnectOfferRow, + type WrCodeOfferResolution, +} from '../connectOfferStaging' + +const _require = createRequire(import.meta.url) +let Database: any = null +try { + Database = _require('better-sqlite3') + const probe = new Database(':memory:') + probe.close() +} catch { + Database = null +} + +function db(): any { + const d = new Database(':memory:') + ensureConnectOfferSchema(d) + return d +} + +const RESOLUTION: WrCodeOfferResolution = { + wr_code_canonical: 'WR7X4K9B2M3PC', + publisher_part: 'WR7X4K', + entry_local_part: '9B2M3', + umbrella_handshake_id: 'hs-umbrella', + entry_status: 'published', + resolution_mode: 'public', + session_bound_expires_at: null, + evp_ref: 'sha256:' + 'a'.repeat(43), + value_statement: 'Signed value statement', + catalog_epoch: 7, + audit_url: 'https://wrc.example/v1/audit/sha256:aaa', + publisher_domain_verified: true, +} + +function baseOfferRow(over: Partial = {}): ConnectOfferRow { + return { + offer_id: 'off-1', + handshake_id: 'hs-1', + capsule_json: JSON.stringify({ context_scopes: ['a'], external_processing: 'none' }), + capsule_hash: 'cap-hash', + sender_email: 's@example.com', + sender_iss: 'iss', + sender_sub: 'sub', + sender_wrdesk_user_id: 'u-1', + receiver_email: 'r@example.com', + profile_id: 'p-1', + ingress_path: 'assisted_email', + invitation_class: 'public_bearer', + verification_status: 'verified', + verification_reason: null, + suppressed: 0, + staged_at: '2026-08-09T00:00:00.000Z', + expires_at: '2026-08-16T00:00:00.000Z', + consumed_at: null, + consumed_action: null, + consent_id: null, + wr_code_canonical: RESOLUTION.wr_code_canonical, + publisher_part: RESOLUTION.publisher_part, + entry_local_part: RESOLUTION.entry_local_part, + umbrella_handshake_id: RESOLUTION.umbrella_handshake_id, + entry_status: RESOLUTION.entry_status, + resolution_mode: RESOLUTION.resolution_mode, + session_bound_expires_at: null, + evp_ref: RESOLUTION.evp_ref, + value_statement: RESOLUTION.value_statement, + catalog_epoch: RESOLUTION.catalog_epoch, + audit_url: RESOLUTION.audit_url, + ...over, + } +} + +describe.skipIf(!Database)('4B — offer schema carries resolution output', () => { + it('stages and reads back every resolution field', () => { + const d = db() + try { + const res = stageConnectOffer(d, { + handshake_id: 'hs-1', + capsule: { context_scopes: ['a'] }, + capsule_hash: 'cap-1', + profile_id: 'p-1', + ingress_path: 'assisted_email', + verification: { ok: true }, + wr_code: RESOLUTION, + }) + expect(res.staged).toBe(true) + const row = d + .prepare('SELECT * FROM wr_connect_offers WHERE handshake_id = ?') + .get('hs-1') as ConnectOfferRow + expect(row.wr_code_canonical).toBe(RESOLUTION.wr_code_canonical) + expect(row.publisher_part).toBe('WR7X4K') + expect(row.entry_local_part).toBe('9B2M3') + expect(row.umbrella_handshake_id).toBe('hs-umbrella') + expect(row.entry_status).toBe('published') + expect(row.resolution_mode).toBe('public') + expect(row.evp_ref).toBe(RESOLUTION.evp_ref) + expect(row.value_statement).toBe('Signed value statement') + expect(row.catalog_epoch).toBe(7) + expect(row.audit_url).toBe(RESOLUTION.audit_url) + } finally { + d.close() + } + }) + + it('a non-WR-code offer stages with nulls, not defaults', () => { + const d = db() + try { + stageConnectOffer(d, { + handshake_id: 'hs-2', + capsule: {}, + capsule_hash: 'cap-2', + profile_id: 'p-1', + ingress_path: 'link', + verification: { ok: true }, + }) + const row = d.prepare('SELECT * FROM wr_connect_offers WHERE handshake_id = ?').get('hs-2') as ConnectOfferRow + expect(row.publisher_part).toBeNull() + expect(row.resolution_mode).toBeNull() + } finally { + d.close() + } + }) + + it('the column migration is idempotent on an existing table', () => { + const d = new Database(':memory:') + try { + // Simulate a pre-Phase-4 database. + d.exec(`CREATE TABLE wr_connect_offers ( + offer_id TEXT PRIMARY KEY, handshake_id TEXT NOT NULL, capsule_json TEXT NOT NULL, + capsule_hash TEXT NOT NULL, sender_email TEXT, sender_iss TEXT, sender_sub TEXT, + sender_wrdesk_user_id TEXT, receiver_email TEXT, profile_id TEXT NOT NULL, + ingress_path TEXT NOT NULL, invitation_class TEXT NOT NULL DEFAULT 'public_bearer', + verification_status TEXT NOT NULL, verification_reason TEXT, suppressed INTEGER NOT NULL DEFAULT 0, + staged_at TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT, consumed_action TEXT, consent_id TEXT)`) + ensureConnectOfferSchema(d) + ensureConnectOfferSchema(d) // twice — must not throw + const cols = (d.prepare('PRAGMA table_info(wr_connect_offers)').all() as Array<{ name: string }>).map( + (c) => c.name, + ) + for (const c of ['wr_code_canonical', 'publisher_part', 'resolution_mode', 'evp_ref', 'value_statement', 'catalog_epoch', 'audit_url']) { + expect(cols, c).toContain(c) + } + } finally { + d.close() + } + }) +}) + +describe('4B — preview hash coverage', () => { + it('two offers differing ONLY in resolution_mode hash differently', () => { + const a = buildConnectOfferPreview(baseOfferRow({ resolution_mode: 'public' })) + const b = buildConnectOfferPreview(baseOfferRow({ resolution_mode: 'session_bound' })) + expect(a.preview_hash).not.toBe(b.preview_hash) + }) + + it('two offers differing ONLY in the entry hash differently', () => { + const a = buildConnectOfferPreview(baseOfferRow({ entry_local_part: '9B2M3' })) + const b = buildConnectOfferPreview(baseOfferRow({ entry_local_part: 'OTHER' })) + expect(a.preview_hash).not.toBe(b.preview_hash) + }) + + it('O2 extension: evp_ref and value_statement are covered', () => { + const base = baseOfferRow() + const diffRef = buildConnectOfferPreview(baseOfferRow({ evp_ref: 'sha256:' + 'b'.repeat(43) })) + const diffStatement = buildConnectOfferPreview( + baseOfferRow({ value_statement: 'A DIFFERENT promise' }), + ) + const same = buildConnectOfferPreview(base) + expect(diffRef.preview_hash).not.toBe(same.preview_hash) + // What the operator consents to includes the value promise they were shown. + expect(diffStatement.preview_hash).not.toBe(same.preview_hash) + }) + + it('catalog_epoch and publisher part are covered', () => { + const same = buildConnectOfferPreview(baseOfferRow()) + expect(buildConnectOfferPreview(baseOfferRow({ catalog_epoch: 8 })).preview_hash).not.toBe( + same.preview_hash, + ) + expect(buildConnectOfferPreview(baseOfferRow({ publisher_part: 'OTHER1' })).preview_hash).not.toBe( + same.preview_hash, + ) + }) + + it('boundDefinition gains publisher_domain_verified', () => { + const verified = buildConnectOfferPreview(baseOfferRow()) + const unverified = buildConnectOfferPreview(baseOfferRow({ publisher_part: null })) + expect(verified.bound_definition_hash).not.toBe(unverified.bound_definition_hash) + expect(verified.preview.bound_definition).toMatchObject({ publisher_domain_verified: true }) + }) + + it('identical offers hash identically (the check is not just nondeterminism)', () => { + expect(buildConnectOfferPreview(baseOfferRow()).preview_hash).toBe( + buildConnectOfferPreview(baseOfferRow()).preview_hash, + ) + }) +}) + +describe.skipIf(!Database)('4B — consent records resolution_mode', () => { + it('persists the mode with the consent it belongs to', () => { + const d = db() + try { + const rec = insertConsentRecord(d, { + offer_id: 'off-1', + handshake_id: 'hs-1', + role: 'acceptor', + preview_hash: 'ph', + bound_definition_hash: 'bh', + contract_state_hash: 'ch', + capture_method: 'assisted_email', + ingress_path: 'assisted_email', + actor_wrdesk_user_id: 'u-1', + resolution_mode: 'session_bound', + }) + const row = d + .prepare('SELECT resolution_mode FROM wr_consent_records WHERE consent_id = ?') + .get(rec.consent_id) as { resolution_mode: string } + expect(row.resolution_mode).toBe('session_bound') + } finally { + d.close() + } + }) +}) + +describe.skipIf(!Database)('O6 — consent-time re-validation', () => { + function stage(d: any, over: Partial = {}) { + stageConnectOffer(d, { + handshake_id: 'hs-o6', + capsule: {}, + capsule_hash: 'cap-o6', + profile_id: 'p-1', + ingress_path: 'assisted_email', + verification: { ok: true }, + wr_code: { ...RESOLUTION, ...over }, + }) + return (d.prepare('SELECT offer_id FROM wr_connect_offers WHERE handshake_id = ?').get('hs-o6') as { + offer_id: string + }).offer_id + } + + it('passes while the entry is still published', () => { + const d = db() + try { + expect(revalidateOfferStatusForConsent(d, stage(d)).ok).toBe(true) + } finally { + d.close() + } + }) + + it('fails on a mid-window transition away from published', () => { + const d = db() + try { + const id = stage(d) + d.prepare('UPDATE wr_connect_offers SET entry_status = ? WHERE offer_id = ?').run('suspended', id) + const r = revalidateOfferStatusForConsent(d, id) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('ENTRY_NOT_PUBLISHED') + } finally { + d.close() + } + }) + + it('fails when a session-bound resolution has expired', () => { + const d = db() + try { + const id = stage(d, { + resolution_mode: 'session_bound', + session_bound_expires_at: new Date(Date.now() - 60_000).toISOString(), + }) + const r = revalidateOfferStatusForConsent(d, id) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('OFFER_RESOLUTION_EXPIRED') + } finally { + d.close() + } + }) + + it('non-WR-code offers are unaffected', () => { + const d = db() + try { + stageConnectOffer(d, { + handshake_id: 'hs-plain', + capsule: {}, + capsule_hash: 'cap-plain', + profile_id: 'p-1', + ingress_path: 'link', + verification: { ok: true }, + }) + const id = ( + d.prepare('SELECT offer_id FROM wr_connect_offers WHERE handshake_id = ?').get('hs-plain') as { + offer_id: string + } + ).offer_id + expect(revalidateOfferStatusForConsent(d, id).ok).toBe(true) + } finally { + d.close() + } + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/contextBlocks.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/contextBlocks.test.ts index 1a5064fed..61821a923 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/contextBlocks.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/contextBlocks.test.ts @@ -1,8 +1,7 @@ import { describe, test, expect } from 'vitest' import { verifyContextBinding } from '../steps/contextBinding' -import { verifyContextVersions } from '../steps/contextVersions' import { ReasonCode } from '../types' -import { buildCtx, buildVerifiedCapsuleInput, buildReceiverPolicy, buildContextBlock } from './helpers' +import { buildCtx, buildVerifiedCapsuleInput } from './helpers' describe('Context Binding', () => { // Hardened model: verifyContextBinding only validates context_block_proofs structure (proof hashes). @@ -50,29 +49,7 @@ describe('Context Binding', () => { }) }) -describe('Context Version Monotonicity', () => { - // Hardened model: verifyContextVersions is a no-op for handshake capsules (proof-only). - // Version checks enforced when full content blocks arrive via BEAP-Capsule pipeline. - test('step always passes (no-op for handshake capsules)', () => { - const ctx = buildCtx({ - input: buildVerifiedCapsuleInput({ context_blocks: [buildContextBlock({ block_id: 'block-1', version: 2 })] }), - contextBlockVersions: new Map([['sender-user-001:block-1', 1]]), - }) - expect(verifyContextVersions.execute(ctx).passed).toBe(true) - }) -}) - -describe('Context Block Dedup', () => { - test('same block_id from different senders → both valid (separate namespace)', () => { - const versions = new Map() - const ctx = buildCtx({ - input: buildVerifiedCapsuleInput({ - sender_wrdesk_user_id: 'user-A', - context_blocks: [buildContextBlock({ block_id: 'shared-block', version: 1 })], - }), - contextBlockVersions: versions, - }) - expect(verifyContextVersions.execute(ctx).passed).toBe(true) - }) -}) +// Phase 1 dead-path removal (A12): the no-op verify_context_versions step was +// deleted from the pipeline. Version monotonicity for full content blocks is +// enforced on the BEAP-Capsule content path, not on handshake capsules. diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/counterpartyKeyBinding.regression.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/counterpartyKeyBinding.regression.test.ts index b032596b4..c4c09ad5a 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/counterpartyKeyBinding.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/counterpartyKeyBinding.regression.test.ts @@ -6,7 +6,7 @@ * filled counterparty with the *local* acceptor key when the initiator key was still missing. */ -import { describe, test, expect, beforeEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' // Email gateway reads `app.getPath` at module-load time via `ingestion/ipc` → `emailTransport` → // `messageRouter` (same pattern as ipc.internal.relayPush.test.ts). @@ -26,8 +26,12 @@ vi.mock('electron', () => ({ import { buildTestSession } from '../sessionFactory' import { createHandshakeTestDb } from './handshakeTestDb' import { migrateIngestionTables } from '../../ingestion/persistenceDb' -import { handleIngestionRPC } from '../../ingestion/ipc' import { _resetSSOSessionProvider } from '../ipc' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' import { setEmailSendFn, _resetEmailSendFn } from '../emailTransport' import { buildInitiateCapsuleWithKeypair, buildAcceptCapsule, buildContextSyncCapsule } from '../capsuleBuilder' import { updateHandshakeSigningKeys, updateHandshakeCounterpartyKey } from '../db' @@ -50,17 +54,13 @@ function bobSession(): SSOSession { }) } +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) + async function submitCapsule(capsuleJson: string, db: any, session: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { body: capsuleJson, mime_type: 'application/vnd.beap+json' }, - sourceType: 'email', - transportMeta: { channel_id: 'test', mime_type: 'application/vnd.beap+json' }, - }, - db, - session, - ) + return submitCapsuleThroughConsentGate(capsuleJson, db, session, { channelId: 'test' }) } describe('counterparty key binding + context_sync (regression)', () => { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.pipeline.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.pipeline.test.ts index b9ae49c45..f94c85f49 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.pipeline.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.pipeline.test.ts @@ -24,7 +24,7 @@ * In single-process tests, use distinct IDs for the sender vs receiver session. */ -import { describe, test, expect, beforeEach } from 'vitest' +import { describe, test, expect, beforeEach, afterEach } from 'vitest' import { buildInitiateCapsule, buildAcceptCapsule, @@ -38,11 +38,18 @@ import { deriveRelationshipId } from '../relationshipId' import { buildTestSession } from '../sessionFactory' import { buildDefaultReceiverPolicy } from '../types' import { submitCapsuleViaRpc } from '../capsuleTransport' -import { handleIngestionRPC } from '../../ingestion/ipc' import { migrateIngestionTables } from '../../ingestion/persistenceDb' import type { SSOSession } from '../types' import { HandshakeState } from '../types' import { createHandshakeTestDb } from './handshakeTestDb' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' + +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) // ── Session factories ── @@ -63,21 +70,15 @@ function receiverSession(): SSOSession { } // ── Submit helper ── +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline, so record assertions exercise the real +// staged → consent → record path. async function submitCapsule(capsule: any, db: any, session: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { - body: JSON.stringify(capsule), - mime_type: 'application/vnd.beap+json', - }, - sourceType: 'internal' as any, - transportMeta: { channel_id: 'test' }, - }, - db, - session, - ) + return submitCapsuleThroughConsentGate(JSON.stringify(capsule), db, session, { + sourceType: 'internal', + channelId: 'test', + }) } // ── Tests ── @@ -297,7 +298,11 @@ describe('BEAP Pipeline E2E — Happy Path', () => { expect(result.success).toBe(true) expect(result.distribution_target).toBe('handshake_pipeline') - expect(result.handshake_result?.handshakeRecord?.state).toBe(HandshakeState.PENDING_REVIEW) + // Phase 4 [IX.3.1]: without a consent event the pipeline stages a Connect + // offer — it never creates a relationship row. + expect(result.handshake_result?.staged).toBe(true) + expect(result.handshake_result?.offerId).toBeTruthy() + expect(result.handshake_result?.handshakeRecord).toBeNull() }) // ── P11: G11 — ownership check (same user → fail) ── diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.roundtrip.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.roundtrip.test.ts index 6578969ee..d141fe427 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.roundtrip.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/e2e.roundtrip.test.ts @@ -11,11 +11,15 @@ * T18: Full initiate → accept → refresh round-trip (mocked email gateway) */ -import { describe, test, expect, beforeEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { buildTestSession } from '../sessionFactory' import { createHandshakeTestDb } from './handshakeTestDb' import { migrateIngestionTables } from '../../ingestion/persistenceDb' -import { handleIngestionRPC } from '../../ingestion/ipc' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' import { handleHandshakeRPC, setSSOSessionProvider, @@ -52,20 +56,13 @@ function bobSession(): SSOSession { }) } +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) + async function submitCapsule(capsuleJson: string, db: any, session: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { - body: capsuleJson, - mime_type: 'application/vnd.beap+json', - }, - sourceType: 'email', - transportMeta: { channel_id: 'email:test', mime_type: 'application/vnd.beap+json' }, - }, - db, - session, - ) + return submitCapsuleThroughConsentGate(capsuleJson, db, session, { channelId: 'email:test' }) } describe('BEAP E2E Round-Trip — Two-Party Flow', () => { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/enforcement.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/enforcement.test.ts index a24fa812e..412bc83c4 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/enforcement.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/enforcement.test.ts @@ -58,10 +58,17 @@ describe('Duplicate Capsule', () => { }) describe('Handshake Ownership', () => { + const senderParty = { + email: 'sender@example.com', + wrdesk_user_id: 'sender-user-001', + iss: 'https://auth.wrdesk.com', + sub: 'sub-sender-001', + } + test('capsule from correct counterparty → passes', () => { const ctx = buildCtx({ input: buildVerifiedCapsuleInput({ capsuleType: 'handshake-refresh', sender_wrdesk_user_id: 'sender-user-001', seq: 1, prev_hash: 'h' }), - handshakeRecord: buildActiveHandshakeRecord({ initiator: { email: 'sender@example.com', wrdesk_user_id: 'sender-user-001', iss: 'i', sub: 's' } }), + handshakeRecord: buildActiveHandshakeRecord({ initiator: { ...senderParty } }), localUserId: 'local-user-001', }) expect(verifyHandshakeOwnership.execute(ctx).passed).toBe(true) @@ -69,7 +76,12 @@ describe('Handshake Ownership', () => { test('capsule from unrelated user → HANDSHAKE_OWNERSHIP_VIOLATION', () => { const ctx = buildCtx({ - input: buildVerifiedCapsuleInput({ capsuleType: 'handshake-refresh', sender_wrdesk_user_id: 'unknown-user' }), + input: buildVerifiedCapsuleInput({ + capsuleType: 'handshake-refresh', + sender_wrdesk_user_id: 'unknown-user', + sender_email: 'unknown@example.com', + senderIdentity: { email: 'unknown@example.com', iss: 'https://auth.wrdesk.com', sub: 'sub-unknown', email_verified: true, wrdesk_user_id: 'unknown-user' }, + }), handshakeRecord: buildActiveHandshakeRecord(), localUserId: 'local-user-001', }) @@ -78,11 +90,40 @@ describe('Handshake Ownership', () => { if (!r.passed) expect(r.reason).toBe(ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION) }) + test('cross-SSO refresh: matching sub/wrdesk under a different issuer → HANDSHAKE_OWNERSHIP_VIOLATION [VII.3.8/3.10]', () => { + const ctx = buildCtx({ + input: buildVerifiedCapsuleInput({ + capsuleType: 'handshake-refresh', + sender_wrdesk_user_id: 'sender-user-001', + senderIdentity: { ...senderParty, iss: 'https://evil-idp.example.com', email_verified: true }, + }), + handshakeRecord: buildActiveHandshakeRecord({ initiator: { ...senderParty } }), + localUserId: 'local-user-001', + }) + const r = verifyHandshakeOwnership.execute(ctx) + expect(r.passed).toBe(false) + if (!r.passed) expect(r.reason).toBe(ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION) + }) + + test('routing fields disagreeing with senderIdentity claims → HANDSHAKE_OWNERSHIP_VIOLATION', () => { + const ctx = buildCtx({ + input: buildVerifiedCapsuleInput({ + capsuleType: 'handshake-refresh', + sender_wrdesk_user_id: 'someone-else', + }), + handshakeRecord: buildActiveHandshakeRecord({ initiator: { ...senderParty } }), + localUserId: 'local-user-001', + }) + const r = verifyHandshakeOwnership.execute(ctx) + expect(r.passed).toBe(false) + if (!r.passed) expect(r.reason).toBe(ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION) + }) + test('accept from initiator (own handshake) → HANDSHAKE_OWNERSHIP_VIOLATION', () => { const ctx = buildCtx({ input: buildVerifiedCapsuleInput({ capsuleType: 'handshake-accept', sender_wrdesk_user_id: 'sender-user-001' }), handshakeRecord: buildHandshakeRecord({ - initiator: { email: 's@e.com', wrdesk_user_id: 'sender-user-001', iss: 'i', sub: 's' }, + initiator: { ...senderParty }, receiver_email: 'other@e.com', }), localUserId: 'local-user-001', @@ -96,14 +137,32 @@ describe('Handshake Ownership', () => { const ctx = buildCtx({ input: buildVerifiedCapsuleInput({ capsuleType: 'handshake-accept', sender_wrdesk_user_id: 'sender-user-001' }), handshakeRecord: buildHandshakeRecord({ - initiator: { email: 'same@e.com', wrdesk_user_id: 'sender-user-001', iss: 'i', sub: 's' }, - receiver_email: 'same@e.com', + initiator: { ...senderParty }, + receiver_email: 'sender@example.com', }), localUserId: 'local-user-001', }) expect(verifyHandshakeOwnership.execute(ctx).passed).toBe(true) }) + test('cross-SSO accept: initiator claims under a different issuer → HANDSHAKE_OWNERSHIP_VIOLATION [VII.3.8/3.10]', () => { + const ctx = buildCtx({ + input: buildVerifiedCapsuleInput({ + capsuleType: 'handshake-accept', + sender_wrdesk_user_id: 'sender-user-001', + senderIdentity: { ...senderParty, iss: 'https://evil-idp.example.com', email_verified: true }, + }), + handshakeRecord: buildHandshakeRecord({ + initiator: { ...senderParty }, + receiver_email: 'sender@example.com', + }), + localUserId: 'local-user-001', + }) + const r = verifyHandshakeOwnership.execute(ctx) + expect(r.passed).toBe(false) + if (!r.passed) expect(r.reason).toBe(ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION) + }) + test('self-handshake initiate → HANDSHAKE_OWNERSHIP_VIOLATION', () => { const ctx = buildCtx({ input: buildVerifiedCapsuleInput({ @@ -111,6 +170,7 @@ describe('Handshake Ownership', () => { sender_wrdesk_user_id: 'local-user-001', sender_email: 'a@e.com', receiver_email: 'b@e.com', + senderIdentity: { email: 'a@e.com', iss: 'https://auth.wrdesk.com', sub: 'sub-local-001', email_verified: true, wrdesk_user_id: 'local-user-001' }, }), handshakeRecord: null, localUserId: 'local-user-001', @@ -120,19 +180,37 @@ describe('Handshake Ownership', () => { if (!r.passed) expect(r.reason).toBe(ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION) }) - test('internal same-account initiate (same wrdesk id + same email) → passes', () => { + test('internal same-account initiate (full session claims + same email) → passes', () => { const ctx = buildCtx({ input: buildVerifiedCapsuleInput({ capsuleType: 'handshake-initiate', sender_wrdesk_user_id: 'local-user-001', - sender_email: 'me@e.com', - receiver_email: 'me@e.com', + sender_email: 'local@wrdesk.com', + receiver_email: 'local@wrdesk.com', + senderIdentity: { email: 'local@wrdesk.com', iss: 'https://auth.wrdesk.com', sub: 'sub-local-001', email_verified: true, wrdesk_user_id: 'local-user-001' }, }), handshakeRecord: null, localUserId: 'local-user-001', }) expect(verifyHandshakeOwnership.execute(ctx).passed).toBe(true) }) + + test('cross-SSO initiate: local wrdesk id claimed under a different issuer → HANDSHAKE_OWNERSHIP_VIOLATION [VII.3.8/3.10]', () => { + const ctx = buildCtx({ + input: buildVerifiedCapsuleInput({ + capsuleType: 'handshake-initiate', + sender_wrdesk_user_id: 'local-user-001', + sender_email: 'local@wrdesk.com', + receiver_email: 'local@wrdesk.com', + senderIdentity: { email: 'local@wrdesk.com', iss: 'https://evil-idp.example.com', sub: 'sub-local-001', email_verified: true, wrdesk_user_id: 'local-user-001' }, + }), + handshakeRecord: null, + localUserId: 'local-user-001', + }) + const r = verifyHandshakeOwnership.execute(ctx) + expect(r.passed).toBe(false) + if (!r.passed) expect(r.reason).toBe(ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION) + }) }) describe('Receiver binding (initiate)', () => { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshake-e2e-hardened.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshake-e2e-hardened.test.ts index 0d8663403..c8f6e4e03 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshake-e2e-hardened.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshake-e2e-hardened.test.ts @@ -13,11 +13,15 @@ * - context_block_proofs validated and persisted */ -import { describe, test, expect, beforeEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { buildTestSession } from '../sessionFactory' import { createHandshakeTestDb } from './handshakeTestDb' import { migrateIngestionTables } from '../../ingestion/persistenceDb' -import { handleIngestionRPC } from '../../ingestion/ipc' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' import { handleHandshakeRPC, setSSOSessionProvider, @@ -55,20 +59,13 @@ function bobSession(): SSOSession { }) } +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) + async function submitCapsule(capsuleJson: string, db: any, session: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { - body: capsuleJson, - mime_type: 'application/vnd.beap+json', - }, - sourceType: 'email', - transportMeta: { channel_id: 'test', mime_type: 'application/vnd.beap+json' }, - }, - db, - session, - ) + return submitCapsuleThroughConsentGate(capsuleJson, db, session, { channelId: 'test' }) } describe('Handshake E2E — Hardened', () => { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeAccountIsolation.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeAccountIsolation.test.ts index 2149e8d43..bcea3f69a 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeAccountIsolation.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeAccountIsolation.test.ts @@ -66,7 +66,7 @@ describe('handshakeAccountIsolation', () => { test('hides internal row when initiator/acceptor are different principals', () => { const r = minimalRow({ handshake_id: 'h1', - handshake_type: 'internal', + same_principal: true, initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), acceptor: party({ email: 'b@test.com', wrdesk_user_id: 'user-b', iss, sub: 'sub-b' }), }) @@ -77,7 +77,7 @@ describe('handshakeAccountIsolation', () => { const p = party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }) const r = minimalRow({ handshake_id: 'h2', - handshake_type: 'internal', + same_principal: true, initiator: p, acceptor: { ...p }, }) @@ -88,7 +88,7 @@ describe('handshakeAccountIsolation', () => { const p = party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }) const r = minimalRow({ handshake_id: 'h3', - handshake_type: 'internal', + same_principal: true, initiator: p, acceptor: { ...p }, }) @@ -98,7 +98,7 @@ describe('handshakeAccountIsolation', () => { test('standard handshake visible to initiator', () => { const r = minimalRow({ handshake_id: 'h4', - handshake_type: 'standard', + same_principal: false, initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), acceptor: party({ email: 'b@test.com', wrdesk_user_id: 'user-b', iss, sub: 'sub-b' }), }) @@ -109,7 +109,7 @@ describe('handshakeAccountIsolation', () => { test('hides standard handshake for unrelated session', () => { const r = minimalRow({ handshake_id: 'h5', - handshake_type: 'standard', + same_principal: false, initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), acceptor: party({ email: 'b@test.com', wrdesk_user_id: 'user-b', iss, sub: 'sub-b' }), }) @@ -139,7 +139,7 @@ describe('handshakeAccountIsolation', () => { test('filterHandshakeRecordsForCurrentSession logs hidden internal mismatch', () => { const r = minimalRow({ handshake_id: 'h-bad', - handshake_type: 'internal', + same_principal: true, initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), acceptor: party({ email: 'b@test.com', wrdesk_user_id: 'user-b', iss, sub: 'sub-b' }), }) @@ -159,7 +159,7 @@ describe('handshakeAccountIsolation', () => { test('external pending acceptor with matching receiver_email → visible (regression fix)', () => { const r = minimalRow({ handshake_id: 'h-pending-ext', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.PENDING_REVIEW, local_role: 'acceptor', acceptor: null, @@ -172,7 +172,7 @@ describe('handshakeAccountIsolation', () => { test('internal pending acceptor with matching receiver_email → visible (regression fix)', () => { const r = minimalRow({ handshake_id: 'h-pending-int', - handshake_type: 'internal', + same_principal: true, state: HandshakeState.PENDING_REVIEW, local_role: 'acceptor', acceptor: null, @@ -185,7 +185,7 @@ describe('handshakeAccountIsolation', () => { test('foreign pending row with non-matching receiver_email → hidden (regression guard)', () => { const r = minimalRow({ handshake_id: 'h-foreign-pending', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.PENDING_REVIEW, local_role: 'acceptor', acceptor: null, @@ -207,7 +207,7 @@ describe('handshakeAccountIsolation', () => { test('active cross-account row, unrelated session → hidden', () => { const r = minimalRow({ handshake_id: 'h-active-foreign', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.ACTIVE, local_role: 'initiator', initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), @@ -228,7 +228,7 @@ describe('handshakeAccountIsolation', () => { test('active row, current user is initiator → visible (unchanged)', () => { const r = minimalRow({ handshake_id: 'h-active-initiator', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.ACTIVE, local_role: 'initiator', initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), @@ -240,7 +240,7 @@ describe('handshakeAccountIsolation', () => { test('active row, current user is acceptor → visible (unchanged)', () => { const r = minimalRow({ handshake_id: 'h-active-acceptor', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.ACTIVE, local_role: 'acceptor', initiator: party({ email: 'a@test.com', wrdesk_user_id: 'user-a', iss, sub: 'sub-a' }), @@ -252,7 +252,7 @@ describe('handshakeAccountIsolation', () => { test('initiator-side pending PENDING_ACCEPT → visible (unchanged)', () => { const r = minimalRow({ handshake_id: 'h-pending-out', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.PENDING_ACCEPT, local_role: 'initiator', acceptor: null, @@ -267,7 +267,7 @@ describe('handshakeAccountIsolation', () => { try { const r = minimalRow({ handshake_id: 'h-legacy-receiver', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.PENDING_REVIEW, local_role: 'acceptor', acceptor: null, @@ -283,7 +283,7 @@ describe('handshakeAccountIsolation', () => { test('acceptor in non-pending state does not use pending acceptor helper', () => { const r = minimalRow({ handshake_id: 'h-acceptor-active-no-match', - handshake_type: 'standard', + same_principal: false, state: HandshakeState.ACCEPTED, local_role: 'acceptor', receiver_email: 'b@test.com', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeTestDb.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeTestDb.ts index 87b9933aa..9bdce0113 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeTestDb.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeTestDb.ts @@ -21,6 +21,8 @@ export function createHandshakeTestDb() { const handshakes = new Map() const seenHashes = new Map>() // handshake_id → Set + const grants: any[] = [] // wr_grants (Phase 5) + const offscopeEvents: any[] = [] // wr_grant_offscope_events (Phase 5) const auditLog: any[] = [] const ingestionAuditLog: any[] = [] const quarantine: any[] = [] @@ -132,6 +134,48 @@ export function createHandshakeTestDb() { return { changes: 1 } } + // INSERT INTO wr_grants (Phase 5 grant objects) + if (/INSERT INTO wr_grants/i.test(sql)) { + grants.push({ + grant_id: pos[0], + handshake_id: pos[1], + grant_type: pos[2], + direction: pos[3], + scopes_json: pos[4], + limit_extensions_json: pos[5], + consent_id: pos[6], + backfilled: pos[7], + created_at: pos[8], + revoked_at: null, + revoke_reason: null, + }) + return { changes: 1 } + } + + // UPDATE wr_grants SET revoked_at + if (/UPDATE wr_grants SET revoked_at/i.test(sql)) { + const g = grants.find((x) => x.grant_id === pos[2]) + if (g) { + g.revoked_at = pos[0] + g.revoke_reason = pos[1] + return { changes: 1 } + } + return { changes: 0 } + } + + // INSERT INTO wr_grant_offscope_events + if (/INSERT INTO wr_grant_offscope_events/i.test(sql)) { + offscopeEvents.push({ + handshake_id: pos[0], + grant_id: pos[1], + scope: pos[2], + kind: pos[3], + source: pos[4], + created_at: pos[5], + }) + return { changes: 1 } + } + // INSERT INTO ingestion_audit_log if (/INSERT INTO ingestion_audit_log/i.test(sql)) { ingestionAuditLog.push({ args: args ?? pos }) @@ -239,6 +283,30 @@ export function createHandshakeTestDb() { return handshakes.get(pos[0]) ?? undefined } + // SELECT COUNT(*) FROM wr_grant_offscope_events + if (/COUNT\(\*\).*FROM wr_grant_offscope_events/i.test(sql)) { + return { n: offscopeEvents.filter((e) => e.handshake_id === pos[0]).length } + } + + // SELECT * FROM wr_grants (single-row lookups: active grant / existing backfill probe) + if (/FROM wr_grants.*WHERE handshake_id/i.test(sql)) { + let rows = grants.filter( + (g) => g.handshake_id === pos[0] && g.grant_type === 'delivery' && g.direction === 'inbound', + ) + if (/revoked_at IS NULL/i.test(sql)) rows = rows.filter((g) => g.revoked_at === null) + if (/created_at <= \?/.test(sql)) { + rows = grants + .filter((g) => g.handshake_id === pos[0] && g.grant_type === 'delivery' && g.direction === 'inbound') + .filter((g) => g.created_at <= pos[1] && (g.revoked_at === null || g.revoked_at > pos[2])) + } + if (/ORDER BY created_at DESC/i.test(sql)) { + rows = [...rows].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) + } else { + rows = [...rows].sort((a, b) => (a.created_at > b.created_at ? 1 : -1)) + } + return rows[0] ?? undefined + } + // SELECT from seen_capsule_hashes (individual) if (/seen_capsule_hashes.*WHERE handshake_id/i.test(sql)) { const set = seenHashes.get(pos[0]) @@ -268,6 +336,13 @@ export function createHandshakeTestDb() { all(...positional: any[]) { const pos = positional + // SELECT * FROM wr_grants (list / revoke sweeps) + if (/FROM wr_grants.*WHERE handshake_id/i.test(sql)) { + let rows = grants.filter((g) => g.handshake_id === pos[0]) + if (/revoked_at IS NULL/i.test(sql)) rows = rows.filter((g) => g.revoked_at === null) + return [...rows].sort((a, b) => (a.created_at > b.created_at ? 1 : -1)) + } + // SELECT * FROM handshakes WHERE state IN (...) if (/FROM handshakes.*state IN/i.test(sql)) { return Array.from(handshakes.values()).filter(r => @@ -328,11 +403,14 @@ export function createHandshakeTestDb() { return { prepare, + // Phase 5: lazy schema ensures (wr_grants etc.) are no-ops on the mock. + exec(_sql: string) { /* CREATE-only statements — no-op */ }, transaction(fn: any) { return (...args: any[]) => fn(...args) }, // Introspection for assertions getHandshakes: () => Array.from(handshakes.values()), + getGrants: () => [...grants], getHandshake: (id: string) => handshakes.get(id), getAuditLog: () => auditLog, getIngestionAuditLog: () => ingestionAuditLog, diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeVerification.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeVerification.test.ts deleted file mode 100644 index 930cfcc35..000000000 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/handshakeVerification.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { verifyHandshakeCapsule, type HandshakeCapsuleFields } from '../handshakeVerification' -import { computeContextHash, generateNonce, type ContextHashInput } from '../contextHash' -import { computeCapsuleHash, type CapsuleHashInput } from '../capsuleHash' - -function buildValidCapsule(overrides?: Partial): HandshakeCapsuleFields { - const base: Omit = { - schema_version: 2, - capsule_type: 'initiate', - handshake_id: 'hs-abc123def456', - relationship_id: 'rel:aabbccdd', - sender_id: 'sender-user-001', - sender_wrdesk_user_id: 'sender-user-001', - sender_email: 'sender@example.com', - receiver_id: 'receiver-user-002', - receiver_email: 'receiver@example.com', - timestamp: new Date().toISOString(), - nonce: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', - seq: 0, - wrdesk_policy_hash: 'f'.repeat(64), - wrdesk_policy_version: '1.0', - context_commitment: null, - receiverIdentity: null, - ...overrides, - } - - const capsuleHashInput: CapsuleHashInput = { - capsule_type: base.capsule_type, - handshake_id: base.handshake_id, - relationship_id: base.relationship_id, - schema_version: base.schema_version, - sender_wrdesk_user_id: base.sender_wrdesk_user_id, - receiver_email: base.receiver_email, - seq: base.seq, - timestamp: base.timestamp, - sharing_mode: base.sharing_mode, - prev_hash: base.prev_hash, - wrdesk_policy_hash: base.wrdesk_policy_hash, - wrdesk_policy_version: base.wrdesk_policy_version, - context_commitment: base.context_commitment, - senderIdentity_sub: base.capsule_type === 'accept' ? base.senderIdentity?.sub : undefined, - receiverIdentity_sub: base.capsule_type === 'accept' ? base.receiverIdentity?.sub ?? undefined : undefined, - } - - const contextHashInput: ContextHashInput = { - schema_version: base.schema_version, - capsule_type: base.capsule_type, - handshake_id: base.handshake_id, - relationship_id: base.relationship_id, - sender_id: base.sender_id, - sender_wrdesk_user_id: base.sender_wrdesk_user_id, - sender_email: base.sender_email, - receiver_id: base.receiver_id, - receiver_email: base.receiver_email, - timestamp: base.timestamp, - nonce: base.nonce, - seq: base.seq, - wrdesk_policy_hash: base.wrdesk_policy_hash, - wrdesk_policy_version: base.wrdesk_policy_version, - sharing_mode: base.sharing_mode, - prev_hash: base.prev_hash, - } - - return { - ...base, - capsule_hash: overrides?.capsule_hash ?? computeCapsuleHash(capsuleHashInput), - context_hash: overrides?.context_hash ?? computeContextHash(contextHashInput), - } -} - -describe('Handshake Verification', () => { - const expectedReceiverEmail = 'receiver@example.com' - const emptyNonces = new Set() - - test('valid capsule passes all checks', () => { - const capsule = buildValidCapsule() - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(true) - }) - - test('missing required field fails', () => { - const capsule = buildValidCapsule() - ;(capsule as any).nonce = '' - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('required_fields') - }) - - test('invalid nonce format fails', () => { - const capsule = buildValidCapsule({ nonce: 'short' }) - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('nonce_format') - }) - - test('expired timestamp fails', () => { - const oldTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString() - const capsule = buildValidCapsule({ timestamp: oldTimestamp }) - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('timestamp_freshness') - }) - - test('replayed nonce fails', () => { - const capsule = buildValidCapsule() - const seenNonces = new Set([capsule.nonce]) - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, seenNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('nonce_replay') - }) - - test('wrong receiver_email fails', () => { - const capsule = buildValidCapsule() - const result = verifyHandshakeCapsule(capsule, 'wrong@example.com', emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('receiver_binding') - }) - - test('tampered context_hash fails', () => { - const capsule = buildValidCapsule({ context_hash: '0'.repeat(64) }) - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('context_hash') - }) - - test('tampered capsule_hash fails', () => { - const capsule = buildValidCapsule({ capsule_hash: '0'.repeat(64) }) - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('capsule_hash') - }) - - test('tampered sender_email detected via context_hash', () => { - const capsule = buildValidCapsule() - ;(capsule as any).sender_email = 'attacker@evil.com' - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('context_hash') - }) - - test('tampered receiver_id detected via context_hash', () => { - const capsule = buildValidCapsule() - ;(capsule as any).receiver_id = 'hijacked-user' - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(false) - if (!result.verified) expect(result.step).toBe('context_hash') - }) - - test('accept capsule with sharing_mode verifies correctly', () => { - const capsule = buildValidCapsule({ capsule_type: 'accept', sharing_mode: 'reciprocal' }) - const result = verifyHandshakeCapsule(capsule, expectedReceiverEmail, emptyNonces) - expect(result.verified).toBe(true) - }) -}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/hardening-verification.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/hardening-verification.test.ts index e04a31095..d9740865d 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/hardening-verification.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/hardening-verification.test.ts @@ -29,7 +29,6 @@ vi.mock('electron', () => ({ import { buildTestSession } from '../sessionFactory' import { createHandshakeTestDb } from './handshakeTestDb' import { migrateIngestionTables } from '../../ingestion/persistenceDb' -import { handleIngestionRPC } from '../../ingestion/ipc' import { handleHandshakeRPC, setSSOSessionProvider, @@ -51,6 +50,17 @@ import { HandshakeState } from '../types' import type { SSOSession } from '../types' import { updateHandshakeSigningKeys, updateHandshakeCounterpartyKey, updateHandshakeContextSyncEnqueued } from '../db' import { MOCK_EXTENSION_X25519_PUBLIC_B64 } from './mockKeypair' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' +import { afterEach } from 'vitest' + +// Phase 4: inbound initiates stage a Connect offer; the kit consents and +// re-runs the pipeline so post-formation assertions still exercise real rows. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) function aliceSession(): SSOSession { return buildTestSession({ @@ -105,19 +115,7 @@ async function setupHandshakeWithKeypairs( } async function submitCapsule(capsuleJson: string, db: any, session: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { - body: capsuleJson, - mime_type: 'application/vnd.beap+json', - }, - sourceType: 'email', - transportMeta: { channel_id: 'test', mime_type: 'application/vnd.beap+json' }, - }, - db, - session, - ) + return submitCapsuleThroughConsentGate(capsuleJson, db, session, { channelId: 'test' }) } describe('Hardening Verification — Fix 1: capsule_hash', () => { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressAdmission.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressAdmission.test.ts new file mode 100644 index 000000000..8aabf8651 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressAdmission.test.ts @@ -0,0 +1,181 @@ +/** + * Receiver-side ingress admission filter — [VII.2.7] acceptance tests. + * + * Phase 1: relationship must exist and be live before any inbound delivery is + * processed; blocked transmissions die pre-visibility with an audit_log record. + * Includes the cross-SSO regression (same sub, different issuer → rejected). + */ +import { describe, test, expect } from 'vitest' +import { admitInboundDelivery } from '../ingressAdmission' +import { insertHandshakeRecord } from '../db' +import { HandshakeState } from '../types' +import { createHandshakeTestDb } from './handshakeTestDb' +import { buildActiveHandshakeRecord, buildHandshakeRecord } from './helpers' + +function dbWithRecord(record: ReturnType) { + const db = createHandshakeTestDb() + insertHandshakeRecord(db, record) + return db +} + +describe('ingress admission filter — relationship existence and state', () => { + test('beap_message for unknown relationship → blocked pre-visibility with audit record', () => { + const db = createHandshakeTestDb() + const r = admitInboundDelivery(db, { handshakeId: 'hs-missing', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('unknown_relationship') + const audit = db.getAuditLog() + expect(audit.length).toBe(1) + expect(JSON.stringify(audit[0].args)).toContain('INGRESS_ADMISSION_BLOCKED') + }) + + test('beap_message for REVOKED relationship → blocked', () => { + const db = dbWithRecord(buildActiveHandshakeRecord({ state: HandshakeState.REVOKED })) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('relationship_revoked') + expect(db.getAuditLog().length).toBe(1) + }) + + test('beap_message for EXPIRED relationship → blocked', () => { + const db = dbWithRecord(buildActiveHandshakeRecord({ state: HandshakeState.EXPIRED })) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('relationship_expired') + }) + + test('beap_message for ACTIVE relationship past expires_at → blocked (defense in depth)', () => { + const db = dbWithRecord( + buildActiveHandshakeRecord({ expires_at: new Date(Date.now() - 60_000).toISOString() }), + ) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('relationship_expired') + }) + + test('beap_message for PENDING_ACCEPT relationship → blocked (not operational)', () => { + const db = dbWithRecord(buildHandshakeRecord({ state: HandshakeState.PENDING_ACCEPT })) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('relationship_not_operational') + }) + + test('beap_message for ACTIVE relationship → admitted with record', () => { + const db = dbWithRecord(buildActiveHandshakeRecord()) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(true) + if (r.admitted) expect(r.record?.handshake_id).toBe('hs-001') + expect(db.getAuditLog().length).toBe(0) + }) + + test('beap_message for ACCEPTED relationship (pre-roundtrip operational window) → admitted', () => { + const db = dbWithRecord(buildActiveHandshakeRecord({ state: HandshakeState.ACCEPTED })) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'p2p' }) + expect(r.admitted).toBe(true) + }) + + test('handshake_capsule with no record (formation) → admitted', () => { + const db = createHandshakeTestDb() + const r = admitInboundDelivery(db, { handshakeId: 'hs-new', kind: 'handshake_capsule', source: 'email' }) + expect(r.admitted).toBe(true) + if (r.admitted) expect(r.record).toBeNull() + }) + + test('handshake_capsule for REVOKED relationship → blocked', () => { + const db = dbWithRecord(buildActiveHandshakeRecord({ state: HandshakeState.REVOKED })) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'handshake_capsule', source: 'email' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('relationship_revoked') + }) + + test('handshake_capsule for PENDING_ACCEPT relationship → admitted (state machine owns it)', () => { + const db = dbWithRecord(buildHandshakeRecord({ state: HandshakeState.PENDING_ACCEPT })) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'handshake_capsule', source: 'email' }) + expect(r.admitted).toBe(true) + }) +}) + +describe('ingress admission filter — full-claim identity guard [VII.3.8–3.10]', () => { + // Local role is acceptor → counterparty is the initiator + // (sender-user-001 / sub-sender-001 @ https://auth.wrdesk.com). + + test('cross-SSO regression: matching sub, different issuer → blocked', () => { + const db = dbWithRecord(buildActiveHandshakeRecord()) + const r = admitInboundDelivery(db, { + handshakeId: 'hs-001', + kind: 'beap_message', + source: 'p2p', + senderClaims: { + iss: 'https://evil-idp.example.com', + sub: 'sub-sender-001', + email: 'sender@example.com', + wrdesk_user_id: 'sender-user-001', + }, + }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('sender_identity_mismatch') + }) + + test('full-claim match on the bound counterparty → admitted', () => { + const db = dbWithRecord(buildActiveHandshakeRecord()) + const r = admitInboundDelivery(db, { + handshakeId: 'hs-001', + kind: 'beap_message', + source: 'p2p', + senderClaims: { + iss: 'https://auth.wrdesk.com', + sub: 'sub-sender-001', + email: 'sender@example.com', + wrdesk_user_id: 'sender-user-001', + }, + }) + expect(r.admitted).toBe(true) + }) + + test('sub-only presentation against fully bound counterparty → blocked (no sub-only shortcut)', () => { + const db = dbWithRecord(buildActiveHandshakeRecord()) + const r = admitInboundDelivery(db, { + handshakeId: 'hs-001', + kind: 'beap_message', + source: 'p2p', + senderClaims: { sub: 'sub-sender-001' }, + }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('sender_identity_mismatch') + }) + + test('no sender claims (unauthenticated transport) → identity deferred to downstream guard', () => { + const db = dbWithRecord(buildActiveHandshakeRecord()) + const r = admitInboundDelivery(db, { handshakeId: 'hs-001', kind: 'beap_message', source: 'email' }) + expect(r.admitted).toBe(true) + }) +}) + +describe('ingress admission filter — sharing_mode scope', () => { + test('context-bearing delivery from acceptor on receive-only relationship → blocked', () => { + const db = dbWithRecord( + buildActiveHandshakeRecord({ sharing_mode: 'receive-only', local_role: 'initiator' }), + ) + const r = admitInboundDelivery(db, { + handshakeId: 'hs-001', + kind: 'beap_message', + source: 'p2p', + carriesContext: true, + }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('sharing_mode_scope_violation') + }) + + test('context-bearing delivery to the acceptor on receive-only relationship → admitted', () => { + const db = dbWithRecord( + buildActiveHandshakeRecord({ sharing_mode: 'receive-only', local_role: 'acceptor' }), + ) + const r = admitInboundDelivery(db, { + handshakeId: 'hs-001', + kind: 'beap_message', + source: 'p2p', + carriesContext: true, + }) + expect(r.admitted).toBe(true) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressCaptureMethodFailClosed.guard.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressCaptureMethodFailClosed.guard.test.ts new file mode 100644 index 000000000..ed4c96527 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressCaptureMethodFailClosed.guard.test.ts @@ -0,0 +1,153 @@ +/** + * Guard — consent evidence never fabricates a capture method (Phase 1C; + * report contradiction G4-3). + * + * `ingressCaptureMethodForOffer` used to fall back to `'assisted_email'` for + * any ingress path missing from `SOURCE_INGRESS_MAP`. The capture method is + * written into the Hash-Pinned consent record as evidence of how the user + * actually received the invitation, so that default attested to an email + * capture for offers that never touched mail — and it silently swallowed the + * exact case this slice is about: `wr_code_public` is a registered, recordable + * ingress path with no mapping, so a WR-Code capture would have been recorded + * as an assisted email. + * + * Fail-closed now: an unmapped path fails the consent with a distinct, + * diagnosable reason rather than inventing provenance. + * + * Run under Electron's Node ABI when available: `pnpm test:native-db `. + */ + +import { createRequire } from 'module' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { isRegisteredIngressPath, isRecordableIngressPath } from '@repo/ingestion-core' + +const require = createRequire(import.meta.url) +let Database: typeof import('better-sqlite3').default | null = null +try { + const D = require('better-sqlite3') as typeof import('better-sqlite3').default + const d = new D(':memory:') + d.close() + Database = D +} catch { + Database = null +} + +const CAPSULE = { + capsule_type: 'handshake-initiate', + handshake_id: 'hs-1c', + context_scopes: ['availability'], + external_processing: 'none', + reciprocal_allowed: true, +} + +describe.skipIf(!Database)('capture method is derived, never defaulted', () => { + let stagingDb: import('better-sqlite3').Database + let formation: typeof import('../formationPipeline') + let staging: typeof import('../connectOfferStaging') + + beforeEach(async () => { + formation = await import('../formationPipeline') + staging = await import('../connectOfferStaging') + stagingDb = new Database!(':memory:') + formation.setConnectOfferDbProvider(() => stagingDb) + }) + + afterEach(() => { + formation.setConnectOfferDbProvider(null) + try { stagingDb.close() } catch { /* noop */ } + }) + + function stageOn(ingressPath: string): string { + const r = staging.stageConnectOffer(stagingDb, { + handshake_id: `hs-${ingressPath}`, + capsule: CAPSULE, + capsule_hash: `hash-${ingressPath}`, + profile_id: 'private_personal', + ingress_path: ingressPath, + verification: { ok: true }, + }) + expect(r.staged).toBe(true) + return (r as { staged: true; offerId: string }).offerId + } + + it('a mapped ingress path consents and records the mapped capture method', () => { + const offerId = stageOn('beap_invitation') + const prep = formation.prepareFormationConsent({ offerId, actorWrdeskUserId: 'me' }) + expect(prep.ok).toBe(true) + if (prep.ok) { + expect(prep.consentRef.formation.capture_method).toBe('assisted_email') + expect(prep.consent.capture_method).toBe('assisted_email') + } + }) + + it('the file-import path records manual entry, not email', () => { + const offerId = stageOn('optirando.ingress.file_import') + const prep = formation.prepareFormationConsent({ offerId, actorWrdeskUserId: 'me' }) + expect(prep.ok).toBe(true) + if (prep.ok) expect(prep.consentRef.formation.capture_method).toBe('manual_entry') + }) + + // Fixture note: this guard originally used `wr_code_public`, which Phase 1 + // recorded as registered-but-unmapped and predicted Phase 5 would have to + // map once the email→offer path went live. Phase 5 mapped it, so the fixture + // moved to a path that is still registered and still unmapped. The guard is + // about the fail-closed rule, not about which particular path lacks a + // mapping today. + it('an unmapped but recordable path fails closed instead of claiming an email capture', () => { + // Precondition: this really is a registered, recordable path — the failure + // below is the missing mapping, not an unknown identifier. + expect(isRegisteredIngressPath('relay_code_claim')).toBe(true) + expect(isRecordableIngressPath('relay_code_claim')).toBe(true) + + const offerId = stageOn('relay_code_claim') + const prep = formation.prepareFormationConsent({ offerId, actorWrdeskUserId: 'me' }) + expect(prep.ok).toBe(false) + if (!prep.ok) expect(prep.reason).toMatch(/^INGRESS_PATH_HAS_NO_CAPTURE_METHOD:/) + }) + + it('the failed consent wrote no consent record and left the offer consentable', () => { + const offerId = stageOn('relay_code_claim') + formation.prepareFormationConsent({ offerId, actorWrdeskUserId: 'me' }) + const consents = stagingDb + .prepare('SELECT COUNT(*) AS c FROM wr_consent_records WHERE offer_id = ?') + .get(offerId) as { c: number } + expect(consents.c).toBe(0) + expect(staging.getConsentableOffer(stagingDb, offerId)).not.toBeNull() + }) + + it('an entirely unknown path also fails closed', () => { + const offerId = stageOn('not_a_registered_path') + const prep = formation.prepareFormationConsent({ offerId, actorWrdeskUserId: 'me' }) + expect(prep.ok).toBe(false) + if (!prep.ok) expect(prep.reason).not.toMatch(/^OK/) + }) +}) + +describe('source: no capture-method fallback survives', () => { + const source = readFileSync(resolve(__dirname, '..', 'formationPipeline.ts'), 'utf8') + const fn = source.slice( + source.indexOf('function ingressCaptureMethodForOffer'), + source.indexOf('// ── Initiator-side formation'), + ) + + it('the resolver is scoped and returns null rather than a default method', () => { + expect(fn.length).toBeGreaterThan(0) + expect(fn).toContain('return null') + expect(fn).not.toMatch(/return\s+['"]assisted_email['"]/) + }) + + it('the consent path rejects the null before it can reach the consent record', () => { + const consentFn = source.slice( + source.indexOf('export function prepareFormationConsent'), + source.indexOf('/** Mark the offer consumed'), + ) + const resolverCall = consentFn.indexOf('ingressCaptureMethodForOffer(offer)') + const nullCheck = consentFn.indexOf('captureMethodId === null') + const consentWrite = consentFn.indexOf('insertConsentRecord(') + expect(resolverCall).toBeGreaterThan(-1) + expect(nullCheck).toBeGreaterThan(resolverCall) + expect(consentWrite).toBeGreaterThan(nullCheck) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressPathLogOnly.guard.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressPathLogOnly.guard.test.ts new file mode 100644 index 000000000..8255337c7 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ingressPathLogOnly.guard.test.ts @@ -0,0 +1,101 @@ +/** + * Phase 2 — A2 guard: `ingress_path` is LOG-ONLY, forever [VII.4.6]. + * + * The field exists for evidence and rendering; formation via different paths + * must yield semantically identical relationships, so NO code may dispatch + * on an ingress_path VALUE. Allowed uses: writing it (construction / log + * payload fields) and structural validation (null / string-shape checks). + * Forbidden uses this guard detects across repository source: + * + * - equality/inequality comparison against a string literal + * (`x.ingress_path === 'relay_pull'`), + * - `switch` on an ingress_path expression, + * - value-prefix dispatch (`ingress_path.startsWith(...)`, + * `.includes(...)`, `.match(...)`, `.endsWith(...)`). + * + * Any future semantic branch trips this test and must instead become + * profile-record data or an extension namespace (Phase 3+). + */ +import { describe, test, expect } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'fs' +import { join, resolve, sep } from 'path' +import { fileURLToPath } from 'url' + +const here = fileURLToPath(new URL('.', import.meta.url)) +const repoRoot = resolve(here, '../../../../../..') + +const SOURCE_ROOTS = [ + 'apps/electron-vite-project/electron', + 'apps/electron-vite-project/src', + 'apps/extension-chromium/src', + 'packages', +] + +const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/ +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', 'out', '.git', 'coverage']) + +function* walk(dir: string): Generator { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry) || entry.startsWith('build0')) continue + const full = join(dir, entry) + let st + try { + st = statSync(full) + } catch { + continue + } + if (st.isDirectory()) { + yield* walk(full) + } else if (SOURCE_EXT.test(entry)) { + yield full + } + } +} + +function normalize(file: string): string { + return file.split(sep).join('/') +} + +/** Semantic-dispatch patterns over an ingress_path value. */ +const FORBIDDEN_PATTERNS: Array<{ name: string; re: RegExp }> = [ + { + name: 'string-literal comparison', + re: /ingress_path\s*(===|!==|==|!=)\s*['"`]/, + }, + { + name: 'string-literal comparison (reversed)', + re: /['"`][a-z0-9_./-]*['"`]\s*(===|!==|==|!=)\s*[A-Za-z0-9_.?!]*ingress_path/i, + }, + { + name: 'switch dispatch', + re: /switch\s*\(\s*[^)]*ingress_path/, + }, + { + name: 'value-prefix dispatch', + re: /ingress_path\s*[.?]+\s*(startsWith|endsWith|includes|match|indexOf)\s*\(/, + }, +] + +describe('ingress_path is log-only [VII.4.6]', () => { + test('no source file dispatches on an ingress_path value', () => { + const offenders: string[] = [] + for (const root of SOURCE_ROOTS) { + for (const file of walk(join(repoRoot, root))) { + const rel = normalize(file).slice(normalize(repoRoot).length + 1) + if (rel.includes('__tests__/') || rel.includes('.test.')) continue + const content = readFileSync(file, 'utf8') + if (!content.includes('ingress_path')) continue + for (const { name, re } of FORBIDDEN_PATTERNS) { + if (re.test(content)) offenders.push(`${rel} (${name})`) + } + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalCoordinationWire.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalCoordinationWire.test.ts index d1354a8e9..ead1ee6f1 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalCoordinationWire.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalCoordinationWire.test.ts @@ -5,7 +5,7 @@ import { } from '../internalCoordinationWire' const completeInternal = { - handshake_type: 'internal' as const, + same_principal: true as const, internal_coordination_identity_complete: true as const, initiator_device_role: 'host' as const, acceptor_device_role: 'sandbox' as const, @@ -18,7 +18,7 @@ describe('coordinationDevicePairForInternalRecord', () => { expect( coordinationDevicePairForInternalRecord( { - handshake_type: 'standard', + same_principal: false, local_role: 'initiator', initiator_coordination_device_id: 'a', acceptor_coordination_device_id: 'b', @@ -33,7 +33,7 @@ describe('coordinationDevicePairForInternalRecord', () => { expect( coordinationDevicePairForInternalRecord( { - handshake_type: 'internal', + same_principal: true, local_role: 'initiator', initiator_coordination_device_id: 'a', acceptor_coordination_device_id: 'b', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalDeviceIdentity.flow.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalDeviceIdentity.flow.test.ts index 215dba51f..ddd858578 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalDeviceIdentity.flow.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalDeviceIdentity.flow.test.ts @@ -25,7 +25,7 @@ describe('verifyInternalCapsuleRouting', () => { receiver_device_id: null, }), handshakeRecord: buildHandshakeRecord({ - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACCEPTED, }), }) @@ -44,7 +44,7 @@ describe('verifyInternalCapsuleRouting', () => { sender_device_id: 'dev-a', receiver_device_id: 'dev-b', }), - handshakeRecord: buildHandshakeRecord({ handshake_type: 'internal', state: HandshakeState.ACCEPTED }), + handshakeRecord: buildHandshakeRecord({ same_principal: true, state: HandshakeState.ACCEPTED }), }) expect(verifyInternalCapsuleRouting.execute(ctx).passed).toBe(true) }) @@ -56,7 +56,7 @@ describe('verifyHandshakeOwnership internal routing duplicate', () => { handshake_id: 'hs-old', relationship_id: 'rel:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', state: HandshakeState.PENDING_ACCEPT, - handshake_type: 'internal', + same_principal: true, internal_routing_key: 'internal:owner-1:alpha:zebra', initiator: { email: 'm@e.com', wrdesk_user_id: 'owner-1', iss: 'i', sub: 's' }, }) @@ -95,7 +95,7 @@ describe('verifyHandshakeOwnership internal routing duplicate', () => { describe('context_sync wire opts', () => { test('internalRelayCapsuleWireOptsFromRecord yields distinct sender and receiver coordination ids', () => { const record = { - handshake_type: 'internal' as const, + same_principal: true as const, local_role: 'initiator' as const, internal_coordination_identity_complete: true as const, initiator_coordination_device_id: 'local-orch-99', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalPersistence.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalPersistence.test.ts index 42f29aff6..55338a0ac 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalPersistence.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalPersistence.test.ts @@ -50,7 +50,7 @@ describe('internalPersistence', () => { test('finalizeInternalHandshakePersistence marks complete only with full symmetry', () => { const incomplete = finalizeInternalHandshakePersistence( baseRecord({ - handshake_type: 'internal', + same_principal: true, initiator_coordination_device_id: 'a', acceptor_coordination_device_id: 'b', initiator_device_role: 'host', @@ -64,7 +64,7 @@ describe('internalPersistence', () => { const complete = finalizeInternalHandshakePersistence( baseRecord({ - handshake_type: 'internal', + same_principal: true, initiator_coordination_device_id: 'a', acceptor_coordination_device_id: 'b', initiator_device_role: 'host', @@ -78,7 +78,7 @@ describe('internalPersistence', () => { }) test('standard handshake clears internal routing fields', () => { - const r = finalizeInternalHandshakePersistence(baseRecord({ handshake_type: 'standard' })) + const r = finalizeInternalHandshakePersistence(baseRecord({ same_principal: false })) expect(r.internal_routing_key).toBeNull() expect(r.internal_coordination_identity_complete).toBe(false) expect(r.internal_coordination_repair_needed).toBe(false) @@ -87,7 +87,7 @@ describe('internalPersistence', () => { test('finalizeInternalHandshakePersistence sets repair_needed for incomplete internal ACTIVE', () => { const r = finalizeInternalHandshakePersistence( baseRecord({ - handshake_type: 'internal', + same_principal: true, state: 'ACTIVE', initiator_coordination_device_id: 'a', acceptor_coordination_device_id: 'b', @@ -104,7 +104,7 @@ describe('internalPersistence', () => { test('finalizeInternalHandshakePersistence clears repair_needed when identity becomes complete', () => { const r = finalizeInternalHandshakePersistence( baseRecord({ - handshake_type: 'internal', + same_principal: true, state: 'ACTIVE', internal_coordination_repair_needed: true, initiator_coordination_device_id: 'a', @@ -123,7 +123,7 @@ describe('internalPersistence', () => { expect( isInternalCoordinationIdentityComplete( baseRecord({ - handshake_type: 'internal', + same_principal: true, initiator_coordination_device_id: 'x', acceptor_coordination_device_id: 'y', initiator_device_role: 'host', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSamePrincipal.contextSync.regression.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSamePrincipal.contextSync.regression.test.ts index c75b0aed6..ff2a5ece8 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSamePrincipal.contextSync.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSamePrincipal.contextSync.regression.test.ts @@ -28,7 +28,7 @@ function internalInitiatorRow( local_role: 'initiator', sharing_mode: 'reciprocal', reciprocal_allowed: true, - handshake_type: 'internal', + same_principal: true, initiator_device_name: 'A', acceptor_device_name: 'B', initiator_device_role: 'host', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSandboxesApi.canonicalRoles.regression.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSandboxesApi.canonicalRoles.regression.test.ts new file mode 100644 index 000000000..0a01cc9c4 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/internalSandboxesApi.canonicalRoles.regression.test.ts @@ -0,0 +1,139 @@ +/** + * Regression: internalSandboxesApi Host/Sandbox decisions must use + * deriveInternalHostAiPeerRoles + assertRecordForServiceRpc, not local_role. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HandshakeState, type HandshakeRecord, type SSOSession } from '../types' +import { + computeAuthoritativeDeviceInternalRole, + isEligibleActiveInternalHostSandboxRecord, +} from '../internalSandboxesApi' +import { localDeviceRole } from '../../internalInference/policy' +import { createHandshakeTestDb } from './handshakeTestDb' +import { insertHandshakeRecord } from '../db' +import { mockKeypairFields } from './mockKeypair' + +const getInstanceIdMock = vi.hoisted(() => vi.fn(() => 'dev-host-1')) +vi.mock('../../orchestrator/orchestratorModeStore', async (importOriginal) => { + const a = await importOriginal() + return { ...a, getInstanceId: () => getInstanceIdMock() } +}) + +const session: SSOSession = { + wrdesk_user_id: 'user-a', + email: 'a@example.com', + iss: 'https://id.example', + sub: 'sub-a', + email_verified: true, + plan: 'free', + currentHardwareAttestation: null, + currentDnsVerification: null, + currentWrStampStatus: null, + session_expires_at: new Date(Date.now() + 3600_000).toISOString(), +} + +function hostSandboxRow(overrides: Partial = {}): HandshakeRecord { + return { + handshake_id: 'hs-canon-1', + relationship_id: 'rel-1', + state: HandshakeState.ACTIVE, + handshake_type: 'internal', + same_principal: true, + // Misleading local_role view: claims initiator/host while this machine may be sandbox. + local_role: 'initiator', + initiator_device_role: 'host', + acceptor_device_role: 'sandbox', + initiator_coordination_device_id: 'dev-host-1', + acceptor_coordination_device_id: 'dev-sand-1', + internal_coordination_identity_complete: true, + p2p_endpoint: 'https://coord.example/beap', + local_x25519_public_key_b64: 'dGVzdC1sb2NhbC14MjU1MTktcHViLWtleQ==', + peer_x25519_public_key_b64: 'cGVlci14MjU1MTk=', + peer_mlkem768_public_key_b64: 'cGVlci1tbGtlbQ==', + initiator: { + email: 'a@example.com', + wrdesk_user_id: 'user-a', + iss: 'https://id.example', + sub: 'sub-a', + }, + acceptor: { + email: 'a@example.com', + wrdesk_user_id: 'user-a', + iss: 'https://id.example', + sub: 'sub-a', + }, + created_at: new Date().toISOString(), + activated_at: new Date().toISOString(), + sharing_mode: null, + reciprocal_allowed: true, + tier_snapshot: {} as any, + current_tier_signals: {} as any, + last_seq_sent: 0, + last_seq_received: 0, + last_capsule_hash_sent: '', + last_capsule_hash_received: '', + effective_policy: {} as any, + external_processing: 'none', + expires_at: null, + revoked_at: null, + revocation_source: null, + initiator_wrdesk_policy_hash: '', + initiator_wrdesk_policy_version: '1', + acceptor_wrdesk_policy_hash: null, + acceptor_wrdesk_policy_version: null, + initiator_context_commitment: null, + acceptor_context_commitment: null, + ...mockKeypairFields(), + ...overrides, + } as HandshakeRecord +} + +describe('internalSandboxesApi canonical Host-AI roles', () => { + let db: ReturnType + + beforeEach(() => { + db = createHandshakeTestDb() + getInstanceIdMock.mockReturnValue('dev-host-1') + }) + + afterEach(() => { + getInstanceIdMock.mockReturnValue('dev-host-1') + }) + + it('local_role host view must not make sandbox instance clone-eligible', () => { + getInstanceIdMock.mockReturnValue('dev-sand-1') + const rec = hostSandboxRow() + // Precondition: weaker local_role helper would still say "host". + expect(localDeviceRole(rec)).toBe('host') + expect(isEligibleActiveInternalHostSandboxRecord(rec, session)).toBe(false) + }) + + it('authoritative role uses coordination ids — sandbox instance is sandbox despite local_role', () => { + getInstanceIdMock.mockReturnValue('dev-sand-1') + insertHandshakeRecord(db, hostSandboxRow({ local_role: 'initiator' })) + expect(computeAuthoritativeDeviceInternalRole(db, session)).toBe('sandbox') + }) + + it('isEligible true for host instance even if local_role claims acceptor', () => { + getInstanceIdMock.mockReturnValue('dev-host-1') + const rec = hostSandboxRow({ + // Wrong per-device view: acceptor would map local_role helper to sandbox. + local_role: 'acceptor', + }) + expect(localDeviceRole(rec)).toBe('sandbox') + expect(isEligibleActiveInternalHostSandboxRecord(rec, session)).toBe(true) + }) + + it('authoritative role is host when coordination id matches host device', () => { + getInstanceIdMock.mockReturnValue('dev-host-1') + insertHandshakeRecord(db, hostSandboxRow({ local_role: 'acceptor' })) + expect(computeAuthoritativeDeviceInternalRole(db, session)).toBe('host') + }) + + it('service-RPC ineligible (identity incomplete) fails even when roles derive as host', () => { + getInstanceIdMock.mockReturnValue('dev-host-1') + const rec = hostSandboxRow({ internal_coordination_identity_complete: false }) + expect(isEligibleActiveInternalHostSandboxRecord(rec, session)).toBe(false) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.handshake.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.handshake.test.ts index 4049d136d..4159f4aab 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.handshake.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.handshake.test.ts @@ -5,7 +5,7 @@ * IPC methods work correctly with mocked email transport and local pipeline. */ -import { describe, test, expect, beforeEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { handleHandshakeRPC, setSSOSessionProvider, @@ -20,6 +20,16 @@ import { createHandshakeTestDb } from './handshakeTestDb' import { migrateIngestionTables } from '../../ingestion/persistenceDb' import { buildInitiateCapsule, buildAcceptCapsule } from '../capsuleBuilder' import { handleIngestionRPC } from '../../ingestion/ipc' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' + +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) import { updateHandshakeSigningKeys, updateHandshakeRecord, getHandshakeRecord } from '../db' import { mockKeypairFields, MOCK_EXTENSION_X25519_PUBLIC_B64 } from './mockKeypair' import type { SSOSession } from '../types' @@ -394,16 +404,10 @@ describe('Handshake IPC — handshake.refresh', () => { receiverEmail: receiver.email, reciprocal_allowed: true, }) - await handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { body: JSON.stringify(initiate), mime_type: 'application/vnd.beap+json' }, - sourceType: 'internal', - transportMeta: { channel_id: 'test' }, - }, - db, - receiver, - ) + await submitCapsuleThroughConsentGate(JSON.stringify(initiate), db, receiver, { + sourceType: 'internal', + channelId: 'test', + }) const { capsule: accept } = buildAcceptCapsule(receiver, { handshake_id: initiate.handshake_id, diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.accept.validation.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.accept.validation.test.ts index f6f7a1516..b67f4ff3f 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.accept.validation.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.accept.validation.test.ts @@ -80,7 +80,7 @@ function internalPendingRecord(overrides: Partial): HandshakeRe counterparty_p2p_token: null, counterparty_public_key: 'a'.repeat(64), receiver_email: 'user-int@test.com', - handshake_type: 'internal', + same_principal: true, initiator_coordination_device_id: 'initiator-orch-1', initiator_device_role: 'host', initiator_device_name: 'HostComputer', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.deviceId.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.deviceId.test.ts index 969f37eb2..45fa5ee87 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.deviceId.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.deviceId.test.ts @@ -40,7 +40,7 @@ describe('handshake.initiate internal — no orchestrator device_id', () => { receiverUserId: 'user-int', receiverEmail: 'user-int@test.com', fromAccountId: 'acct', - handshake_type: 'internal', + profile_id: 'internal_device', device_role: 'host', device_name: 'HostBox', counterparty_device_id: 'peer-dev-1', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.relayPush.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.relayPush.test.ts index c16fdab9a..7b946f4ad 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.relayPush.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.internal.relayPush.test.ts @@ -131,7 +131,7 @@ describe.skipIf(!sqliteAvailable)('handshake.initiate internal — coordination receiverUserId: 'user-int', receiverEmail: 'user-int@test.com', fromAccountId: 'acct-alice-1', - handshake_type: 'internal', + profile_id: 'internal_device', device_role: 'host', device_name: 'HostBox', counterparty_device_id: PEER_ID, @@ -156,7 +156,7 @@ describe.skipIf(!sqliteAvailable)('handshake.initiate internal — coordination expect(regOpts).toMatchObject({ initiator_device_id: INSTANCE_ID, acceptor_device_id: PEER_ID, - handshake_type: 'internal', + same_principal: true, }) // Outbound queue → POST to /beap/capsule. @@ -205,7 +205,7 @@ describe.skipIf(!sqliteAvailable)('handshake.initiate internal — coordination receiverUserId: 'user-int', receiverEmail: 'user-int@test.com', fromAccountId: 'acct-alice-1', - handshake_type: 'internal', + profile_id: 'internal_device', device_role: 'host', device_name: 'HostBox', counterparty_device_id: PEER_ID, diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.rowSessionAuth.regression.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.rowSessionAuth.regression.test.ts new file mode 100644 index 000000000..751fba5aa --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/ipc.rowSessionAuth.regression.test.ts @@ -0,0 +1,141 @@ +/** + * Regression: handshake.get / queryStatus / delete must apply row-level + * session visibility (same rules as handshake.list). Unauthorized or + * unauthenticated callers fail closed as HANDSHAKE_NOT_FOUND. + */ + +import { describe, test, expect, beforeEach } from 'vitest' +import { + handleHandshakeRPC, + setSSOSessionProvider, + _resetSSOSessionProvider, +} from '../ipc' +import { buildTestSession } from '../sessionFactory' +import { createHandshakeTestDb } from './handshakeTestDb' +import { migrateIngestionTables } from '../../ingestion/persistenceDb' +import { insertHandshakeRecord, getHandshakeRecord } from '../db' +import { mockKeypairFields } from './mockKeypair' +import { ReasonCode, type HandshakeRecord, type SSOSession } from '../types' + +const ISS = 'https://auth.optimando.ai' + +function partySession(user: 'a' | 'b' | 'c'): SSOSession { + return buildTestSession({ + wrdesk_user_id: `user-${user}`, + email: `${user}@test.com`, + sub: `sub-${user}`, + iss: ISS, + }) +} + +function standardHandshake(handshakeId: string): HandshakeRecord { + return { + handshake_id: handshakeId, + relationship_id: 'rel-row-auth', + state: 'ACTIVE', + handshake_type: 'standard', + initiator: { + wrdesk_user_id: 'user-a', + email: 'a@test.com', + iss: ISS, + sub: 'sub-a', + email_verified: true, + }, + acceptor: { + wrdesk_user_id: 'user-b', + email: 'b@test.com', + iss: ISS, + sub: 'sub-b', + email_verified: true, + }, + local_role: 'initiator', + sharing_mode: 'reciprocal', + reciprocal_allowed: true, + tier_snapshot: { plan: 'free' }, + current_tier_signals: {}, + last_seq_sent: 0, + last_seq_received: 0, + last_capsule_hash_sent: '', + last_capsule_hash_received: '', + effective_policy: {}, + external_processing: 'none', + created_at: new Date().toISOString(), + initiator_wrdesk_policy_hash: '', + initiator_wrdesk_policy_version: '1.0', + ...mockKeypairFields(), + } as HandshakeRecord +} + +describe('handshake IPC row-level session authorization', () => { + let db: ReturnType + + beforeEach(() => { + db = createHandshakeTestDb() + migrateIngestionTables(db) + _resetSSOSessionProvider() + insertHandshakeRecord(db, standardHandshake('hs-row-1')) + }) + + test('party session can get / queryStatus the row', async () => { + setSSOSessionProvider(() => partySession('a')) + const get = await handleHandshakeRPC('handshake.get', { handshake_id: 'hs-row-1' }, db) + expect(get.error).toBeUndefined() + expect(get.record?.handshake_id).toBe('hs-row-1') + + const status = await handleHandshakeRPC('handshake.queryStatus', { handshakeId: 'hs-row-1' }, db) + expect(status.reason).toBe(ReasonCode.OK) + expect(status.record?.handshake_id).toBe('hs-row-1') + }) + + test('unrelated session cannot get / queryStatus / delete (fail-closed NOT_FOUND)', async () => { + setSSOSessionProvider(() => partySession('c')) + + const get = await handleHandshakeRPC('handshake.get', { handshake_id: 'hs-row-1' }, db) + expect(get.error).toBe('Handshake not found') + expect(get.reason).toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + expect(get.record).toBeUndefined() + + const status = await handleHandshakeRPC('handshake.queryStatus', { handshakeId: 'hs-row-1' }, db) + expect(status.reason).toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + expect(status.record).toBeNull() + + const del = await handleHandshakeRPC('handshake.delete', { handshakeId: 'hs-row-1' }, db) + expect(del.success).toBe(false) + expect(del.reason).toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + expect(getHandshakeRecord(db, 'hs-row-1')).not.toBeNull() + }) + + test('no SSO session cannot get / queryStatus / delete (fail-closed NOT_FOUND)', async () => { + _resetSSOSessionProvider() + + const get = await handleHandshakeRPC('handshake.get', { handshake_id: 'hs-row-1' }, db) + expect(get.reason).toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + + const status = await handleHandshakeRPC('handshake.queryStatus', { handshakeId: 'hs-row-1' }, db) + expect(status.reason).toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + expect(status.record).toBeNull() + + const del = await handleHandshakeRPC('handshake.delete', { handshakeId: 'hs-row-1' }, db) + expect(del.success).toBe(false) + expect(del.reason).toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + expect(getHandshakeRecord(db, 'hs-row-1')).not.toBeNull() + }) + + test('party session can delete a revoked row after visibility check', async () => { + // deleteHandshakeRecord only allows REVOKED / EXPIRED / own PENDING_ACCEPT. + insertHandshakeRecord(db, { ...standardHandshake('hs-row-revoked'), state: 'REVOKED' }) + setSSOSessionProvider(() => partySession('b')) + const del = await handleHandshakeRPC('handshake.delete', { handshakeId: 'hs-row-revoked' }, db) + expect(del.success).toBe(true) + expect(del.reason).not.toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + }) + + test('authorized party on ACTIVE gets delete policy error, not NOT_FOUND leak', async () => { + setSSOSessionProvider(() => partySession('a')) + const del = await handleHandshakeRPC('handshake.delete', { handshakeId: 'hs-row-1' }, db) + expect(del.success).toBe(false) + expect(del.reason).not.toBe(ReasonCode.HANDSHAKE_NOT_FOUND) + expect(String(del.error || '')).toMatch(/revoked|expired|pending/i) + expect(getHandshakeRecord(db, 'hs-row-1')).not.toBeNull() + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/keyExtraction.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/keyExtraction.test.ts new file mode 100644 index 000000000..77c9e045f --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/keyExtraction.test.ts @@ -0,0 +1,173 @@ +/** + * Phase 2 — acceptance test 5: key extraction (G6). + * + * Private key material (`local_private_key`, `local_x25519_private_key_b64`, + * `local_mlkem768_secret_key_b64`) moves out of relationship rows into the + * dedicated `handshake_key_store` via migration v73 (copy-before-null, one + * transaction, idempotent). Post-migration: + * - old key columns on `handshakes` are NULL, + * - sign round-trips still pass on pre-existing relationships (keys are + * overlaid from the store on every read), + * - re-running the migration never clobbers extracted keys, + * - NEW writes route key material to the store, never back onto the row. + */ + +import { describe, it, expect } from 'vitest' +import Database from 'better-sqlite3' + +import { + migrateHandshakeTables, + insertHandshakeRecord, + getHandshakeRecord, + updateHandshakeRecord, + getHandshakeKeys, +} from '../db' +import { generateSigningKeypair, signCapsuleHash, verifyCapsuleSignature } from '../signatureKeys' +import { buildActiveHandshakeRecord } from './helpers' + +function fullyMigratedDb(): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + return db +} + +/** + * Produce a DB in the PRE-v73 shape: all migrations applied, then the + * Phase-2 tables dropped and their bookkeeping rows removed — the state an + * existing installation is in right before this build's migration runs. + */ +function preExtractionDb(): any { + const db = fullyMigratedDb() + db.prepare('DELETE FROM handshake_schema_migrations WHERE version >= 73').run() + db.prepare('DROP TABLE handshake_key_store').run() + db.prepare('DROP TABLE wr_high_water_versions').run() + db.prepare('DROP TABLE wr_core_nonces').run() + return db +} + +function keyColumns(db: any, hsId: string) { + return db + .prepare( + 'SELECT local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64 FROM handshakes WHERE handshake_id = ?', + ) + .get(hsId) as { + local_private_key: string | null + local_x25519_private_key_b64: string | null + local_mlkem768_secret_key_b64: string | null + } +} + +describe('Phase 2 — key extraction migration (G6)', () => { + it('moves existing on-row keys into the key store; old columns nulled; reads overlay', () => { + const db = preExtractionDb() + const keypair = generateSigningKeypair() + const record = buildActiveHandshakeRecord({ + handshake_id: 'hs-keyx-1', + local_public_key: keypair.publicKey, + local_private_key: keypair.privateKey, + local_x25519_private_key_b64: 'x25519-priv-b64==', + local_mlkem768_secret_key_b64: 'mlkem-secret-b64==', + }) + insertHandshakeRecord(db, record) + + // Pre-migration shape: keys live on the relationship row. + const before = keyColumns(db, 'hs-keyx-1') + expect(before.local_private_key).toBe(keypair.privateKey) + expect(before.local_x25519_private_key_b64).toBe('x25519-priv-b64==') + expect(before.local_mlkem768_secret_key_b64).toBe('mlkem-secret-b64==') + + // The one-shot migration runs (v73 + v74 re-apply). + migrateHandshakeTables(db) + + // Old columns retained but NULL. + const after = keyColumns(db, 'hs-keyx-1') + expect(after.local_private_key).toBeNull() + expect(after.local_x25519_private_key_b64).toBeNull() + expect(after.local_mlkem768_secret_key_b64).toBeNull() + + // Key store holds the material. + const stored = getHandshakeKeys(db, 'hs-keyx-1') + expect(stored?.local_private_key).toBe(keypair.privateKey) + expect(stored?.local_x25519_private_key_b64).toBe('x25519-priv-b64==') + expect(stored?.local_mlkem768_secret_key_b64).toBe('mlkem-secret-b64==') + + // Reads overlay the store — callers keep seeing a complete record. + const read = getHandshakeRecord(db, 'hs-keyx-1') + expect(read?.local_private_key).toBe(keypair.privateKey) + expect(read?.local_x25519_private_key_b64).toBe('x25519-priv-b64==') + expect(read?.local_mlkem768_secret_key_b64).toBe('mlkem-secret-b64==') + }) + + it('sign round-trip passes on a pre-existing relationship after migration', () => { + const db = preExtractionDb() + const keypair = generateSigningKeypair() + insertHandshakeRecord( + db, + buildActiveHandshakeRecord({ + handshake_id: 'hs-keyx-sign', + local_public_key: keypair.publicKey, + local_private_key: keypair.privateKey, + }), + ) + migrateHandshakeTables(db) + + const record = getHandshakeRecord(db, 'hs-keyx-sign')! + const capsuleHash = 'f'.repeat(64) + const signature = signCapsuleHash(capsuleHash, record.local_private_key!) + expect(verifyCapsuleSignature(capsuleHash, signature, record.local_public_key!)).toBe(true) + }) + + it('is idempotent: re-running the migration never clobbers extracted keys', () => { + const db = preExtractionDb() + const keypair = generateSigningKeypair() + insertHandshakeRecord( + db, + buildActiveHandshakeRecord({ + handshake_id: 'hs-keyx-idem', + local_public_key: keypair.publicKey, + local_private_key: keypair.privateKey, + local_x25519_private_key_b64: 'xpriv==', + }), + ) + migrateHandshakeTables(db) + expect(getHandshakeKeys(db, 'hs-keyx-idem')?.local_private_key).toBe(keypair.privateKey) + + // Force the v73/v74 statements to execute AGAIN over the already-nulled + // columns (the failure mode: a re-run overwriting stored keys with NULLs). + db.prepare('DELETE FROM handshake_schema_migrations WHERE version >= 73').run() + migrateHandshakeTables(db) + + const stored = getHandshakeKeys(db, 'hs-keyx-idem') + expect(stored?.local_private_key).toBe(keypair.privateKey) + expect(stored?.local_x25519_private_key_b64).toBe('xpriv==') + expect(keyColumns(db, 'hs-keyx-idem').local_private_key).toBeNull() + }) + + it('routes NEW writes to the key store — key material never lands on the row', () => { + const db = fullyMigratedDb() + const keypair = generateSigningKeypair() + insertHandshakeRecord( + db, + buildActiveHandshakeRecord({ + handshake_id: 'hs-keyx-new', + local_public_key: keypair.publicKey, + local_private_key: keypair.privateKey, + local_mlkem768_secret_key_b64: 'mlkem==', + }), + ) + + const cols = keyColumns(db, 'hs-keyx-new') + expect(cols.local_private_key).toBeNull() + expect(cols.local_mlkem768_secret_key_b64).toBeNull() + expect(getHandshakeKeys(db, 'hs-keyx-new')?.local_private_key).toBe(keypair.privateKey) + expect(getHandshakeRecord(db, 'hs-keyx-new')?.local_private_key).toBe(keypair.privateKey) + + // Updates keep the discipline. + const record = getHandshakeRecord(db, 'hs-keyx-new')! + const rotated = generateSigningKeypair() + updateHandshakeRecord(db, { ...record, local_private_key: rotated.privateKey, local_public_key: rotated.publicKey }) + expect(keyColumns(db, 'hs-keyx-new').local_private_key).toBeNull() + expect(getHandshakeKeys(db, 'hs-keyx-new')?.local_private_key).toBe(rotated.privateKey) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/pairingActivation.rig.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/pairingActivation.rig.test.ts index 819df7087..d35c744d8 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/pairingActivation.rig.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/pairingActivation.rig.test.ts @@ -19,14 +19,18 @@ * Run under Electron's Node ABI: `pnpm test:native-db `. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' import Database from 'better-sqlite3' import WebSocket from 'ws' import { startRelayHarness, type RelayHarness } from './rig/coordinationRelayHarness' import { migrateHandshakeTables, updateHandshakeSigningKeys, updateHandshakeCounterpartyKey, updateHandshakeContextSyncEnqueued } from '../db' import { migrateIngestionTables } from '../../ingestion/persistenceDb' -import { handleIngestionRPC } from '../../ingestion/ipc' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' import { setEmailSendFn, _resetEmailSendFn } from '../emailTransport' import { buildInitiateCapsuleWithKeypair, buildAcceptCapsule, buildContextSyncCapsule } from '../capsuleBuilder' import { buildTestSession } from '../sessionFactory' @@ -50,17 +54,13 @@ function makeDb(): any { return db } +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) + function ingest(capsuleJson: string, db: any, asSession: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { body: capsuleJson, mime_type: 'application/vnd.beap+json' }, - sourceType: 'email', - transportMeta: { channel_id: 'relay:test', mime_type: 'application/vnd.beap+json' }, - }, - db, - asSession, - ) + return submitCapsuleThroughConsentGate(capsuleJson, db, asSession, { channelId: 'relay:test' }) } describe('pairing → ACTIVE over a real relay (two real instances)', () => { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase2CanonicalCore.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase2CanonicalCore.acceptance.test.ts new file mode 100644 index 000000000..f5c88064e --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase2CanonicalCore.acceptance.test.ts @@ -0,0 +1,391 @@ +/** + * Phase 2 — Canonical Core: pipeline-level acceptance tests. + * + * Covers (per the Phase-2 brief): + * 1. Replay compatibility — v2 capsules (no envelope) verify under legacy + * rules and are marked `legacy_v2`; new-format capsules that under-sign + * ANY consumed field are rejected [VII.6.1.3]. + * 2. Container semantics [VII.3.5] — unknown non-critical extension + * establishes; unknown critical extension refuses NAMING the namespace; + * container order + unknown entries survive a full round-trip + * byte-identically. + * 4. Nonce/replay [VII.3.1] — a replayed core with a seen nonce is rejected. + * + * (3. canonical determinism lives in packages/ingestion-core/__tests__/ + * canonical.test.ts; 5. key extraction in keyExtraction.test.ts; + * 6. anti-rollback in antiRollback.test.ts.) + * + * Everything below flows through the REAL ingestion pipeline + * (handleIngestionRPC → Gate-2 canonical rebuild → validator → enforcement), + * on a real in-memory sqlite DB — no pipeline internals are mocked. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import Database from 'better-sqlite3' + +import { migrateHandshakeTables } from '../db' +import { migrateIngestionTables } from '../../ingestion/persistenceDb' +import { + installInMemoryConnectOffers, + uninstallInMemoryConnectOffers, + submitCapsuleThroughConsentGate, +} from './connectOfferConsentTestKit' +import { setEmailSendFn, _resetEmailSendFn } from '../emailTransport' +import { buildInitiateCapsuleWithKeypair } from '../capsuleBuilder' +import { buildTestSession } from '../sessionFactory' +import { HandshakeState, ReasonCode } from '../types' +import { + attachCanonicalEnvelope, + hasCanonicalEnvelope, + verifyCanonicalEnvelope, + CAPSULE_DECLARATION_NS, +} from '../canonicalCore' +import { canonicalJsonString } from '@repo/ingestion-core' +import type { CanonicalJsonValue, CorePartyId } from '@repo/ingestion-core' +import type { SSOSession } from '../types' + +function session(user: string): SSOSession { + return buildTestSession({ wrdesk_user_id: user, sub: user, email: `${user}@dev.test` }) +} + +function makeDb(): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + migrateIngestionTables(db) + return db +} + +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. +beforeEach(() => installInMemoryConnectOffers()) +afterEach(() => uninstallInMemoryConnectOffers()) + +function ingest(capsuleJson: string, db: any, asSession: SSOSession) { + return submitCapsuleThroughConsentGate(capsuleJson, db, asSession, { channelId: 'relay:test' }) +} + +interface AuditRow { + action: string + reason_code: string + failed_step: string | null + metadata: Record +} + +function auditRows(db: any, handshakeId: string): AuditRow[] { + const rows = db + .prepare( + 'SELECT action, reason_code, failed_step, metadata FROM audit_log WHERE handshake_id = ? ORDER BY rowid ASC', + ) + .all(handshakeId) as Array<{ action: string; reason_code: string; failed_step: string | null; metadata: string | null }> + return rows.map((r) => ({ ...r, metadata: r.metadata ? JSON.parse(r.metadata) : {} })) +} + +function party(s: SSOSession): CorePartyId { + return { sub: s.sub, iss: s.iss, email: s.email, wrdesk_user_id: s.wrdesk_user_id } +} + +/** Strip the auto-attached envelope, returning the pure v2 capsule surface. */ +function withoutEnvelope(capsule: Record): Record { + const { wr_canonical_v3: _drop, ...rest } = capsule + return rest +} + +describe('Phase 2 — canonical core acceptance (real pipeline)', () => { + const alice = session('p2alice') + const bob = session('p2bob') + + beforeEach(() => { + _resetEmailSendFn() + setEmailSendFn(vi.fn().mockResolvedValue({ success: true, messageId: 'm1' })) + }) + + function buildInitiate(overrides?: { nonce?: string }) { + return buildInitiateCapsuleWithKeypair(alice, { + receiverUserId: bob.wrdesk_user_id, + receiverEmail: bob.email, + reciprocal_allowed: true, + ...(overrides?.nonce ? { nonce: overrides.nonce } : {}), + }) + } + + // ── Dual-format emission ──────────────────────────────────────────────────── + + it('builder emits dual-format: v2 surface + signed canonical v3 envelope', () => { + const { capsule, keypair } = buildInitiate() + expect(hasCanonicalEnvelope(capsule as unknown as Record)).toBe(true) + + const env = (capsule as unknown as Record).wr_canonical_v3 + expect(env.v).toBe(3) + expect(env.core.profile).toEqual({ id: 'legacy_v0', version: 1 }) + expect(env.core.ingress_path).toBeNull() // log-only, null on Phase-2 emissions + expect(env.core.initiator_id).toEqual(party(alice)) + expect(env.core.responder_id).toBeNull() + expect(env.core.nonce).toBe(capsule.nonce) + expect(env.core.declarations[0].ns).toBe(CAPSULE_DECLARATION_NS) + expect(env.core.declarations[0].critical).toBe(true) + expect(env.signatures).toHaveLength(1) + expect(env.signatures[0].mode).toBe('canonical_bytes') + + const verdict = verifyCanonicalEnvelope(capsule as unknown as Record, keypair.publicKey) + expect(verdict.ok).toBe(true) + }) + + it('v3 capsule establishes through the real pipeline, marked canonical_v3 in evidence', async () => { + const db = makeDb() + const { capsule } = buildInitiate() + + const result = await ingest(JSON.stringify(capsule), db, bob) + expect(result.success).toBe(true) + expect(result.handshake_result?.handshakeRecord?.state).toBe(HandshakeState.PENDING_REVIEW) + + const success = auditRows(db, capsule.handshake_id).find((r) => r.action === 'handshake_pipeline_success') + expect(success).toBeTruthy() + expect(success!.metadata.wire_format).toBe('canonical_v3') + }) + + // ── Acceptance 1: replay compatibility ────────────────────────────────────── + + it('stored v2 capsules (no envelope) verify under legacy rules, marked legacy_v2', async () => { + const db = makeDb() + const { capsule } = buildInitiate() + const legacyOnly = withoutEnvelope(capsule as unknown as Record) + + const result = await ingest(JSON.stringify(legacyOnly), db, bob) + expect(result.success).toBe(true) + expect(result.handshake_result?.handshakeRecord?.state).toBe(HandshakeState.PENDING_REVIEW) + + const success = auditRows(db, capsule.handshake_id).find((r) => r.action === 'handshake_pipeline_success') + expect(success).toBeTruthy() + expect(success!.metadata.wire_format).toBe('legacy_v2') + }) + + it('rejects a v3 capsule whose signed core omits a field present on the wire (under-signing)', async () => { + const db = makeDb() + const { capsule, keypair } = buildInitiate() + const v2 = withoutEnvelope(capsule as unknown as Record) + + // Sign a REDUCED capsule view (tierSignals dropped) but send the full wire: + // the declaration then under-covers the wire — structurally what a partial + // signature would produce. Must be rejected fail-closed. + const { tierSignals: _omit, ...reduced } = v2 + const reducedSigned = attachCanonicalEnvelope(reduced, { + initiator: party(alice), + responder: null, + createdAt: capsule.timestamp, + nonce: capsule.nonce, + privateKeyHex: keypair.privateKey, + publicKeyHex: keypair.publicKey, + signer: 'initiator', + }) + const underSigned = { ...v2, wr_canonical_v3: reducedSigned.wr_canonical_v3 } + + const result = await ingest(JSON.stringify(underSigned), db, bob) + expect(result.success).toBe(false) + + const denial = auditRows(db, capsule.handshake_id).find((r) => r.action === 'handshake_pipeline_denial') + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.CANONICAL_ENVELOPE_INVALID) + expect(denial!.metadata.envelope_reason).toBe('under_signed_field:tierSignals') + }) + + it('rejects tampering of a field the LEGACY subset hash never covered (full coverage, A8)', async () => { + const db = makeDb() + const { capsule } = buildInitiate() + + // tierSignals is OUTSIDE the legacy capsule_hash subset — under v2 rules + // this tampering is invisible. The canonical envelope must catch it. + const tampered = JSON.parse(JSON.stringify(capsule)) + tampered.tierSignals.plan = 'pro' + + const result = await ingest(JSON.stringify(tampered), db, bob) + expect(result.success).toBe(false) + + const denial = auditRows(db, capsule.handshake_id).find((r) => r.action === 'handshake_pipeline_denial') + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.CANONICAL_ENVELOPE_INVALID) + expect(denial!.metadata.envelope_reason).toBe('binding_mismatch:tierSignals') + + // Control: the SAME tampering on the legacy-only surface sails through v2 + // verification — proving the envelope is what closed the gap. + const db2 = makeDb() + const legacyTampered = withoutEnvelope(tampered) + const legacyResult = await ingest(JSON.stringify(legacyTampered), db2, bob) + expect(legacyResult.success).toBe(true) + }) + + it('rejects a v3 capsule whose core bytes were altered after signing', async () => { + const db = makeDb() + const { capsule } = buildInitiate() + const tampered = JSON.parse(JSON.stringify(capsule)) + tampered.wr_canonical_v3.core.created_at = new Date(Date.now() + 1000).toISOString() + + const result = await ingest(JSON.stringify(tampered), db, bob) + expect(result.success).toBe(false) + + const denial = auditRows(db, capsule.handshake_id).find((r) => r.action === 'handshake_pipeline_denial') + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.CANONICAL_ENVELOPE_INVALID) + expect(String(denial!.metadata.envelope_reason)).toMatch(/^signature_invalid:/) + }) + + it('rejects a v3 envelope signed by a key other than the pinned sender key', () => { + const { capsule } = buildInitiate() + const otherKey = 'a'.repeat(64) + const verdict = verifyCanonicalEnvelope(capsule as unknown as Record, otherKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toBe('no_full_coverage_signature_from_sender_key') + }) + + // ── Acceptance 2: container semantics [VII.3.5] ───────────────────────────── + + function rebuildWithExtensions(extensions: Array>) { + const { capsule, keypair } = buildInitiate() + const v2 = withoutEnvelope(capsule as unknown as Record) + const withExt = attachCanonicalEnvelope(v2, { + initiator: party(alice), + responder: null, + createdAt: capsule.timestamp, + nonce: capsule.nonce, + extensions: extensions as any, + privateKeyHex: keypair.privateKey, + publicKeyHex: keypair.publicKey, + signer: 'initiator', + }) + return { capsule: withExt, keypair, handshakeId: capsule.handshake_id as string } + } + + it('establishes with an unknown NON-critical extension (preserve and ignore)', async () => { + const db = makeDb() + const unknownEntry = { + ns: 'com.vendor.future-feature', + version: 7, + critical: false, + payload: { anything: ['goes', 'here'], nested: { deep: true } }, + } + const { capsule, keypair, handshakeId } = rebuildWithExtensions([unknownEntry]) + + const verdict = verifyCanonicalEnvelope(capsule, keypair.publicKey) + expect(verdict.ok).toBe(true) + if (verdict.ok) expect(verdict.ignoredNamespaces).toContain('com.vendor.future-feature') + + const result = await ingest(JSON.stringify(capsule), db, bob) + expect(result.success).toBe(true) + expect(result.handshake_result?.handshakeRecord?.state).toBe(HandshakeState.PENDING_REVIEW) + + const success = auditRows(db, handshakeId).find((r) => r.action === 'handshake_pipeline_success') + expect(success!.metadata.wire_format).toBe('canonical_v3') + }) + + it('refuses with an unknown CRITICAL extension, naming the namespace', async () => { + const db = makeDb() + const criticalEntry = { + ns: 'com.vendor.mandatory-thing', + version: 1, + critical: true, + payload: { must: 'understand' }, + } + const { capsule, handshakeId } = rebuildWithExtensions([criticalEntry]) + + const result = await ingest(JSON.stringify(capsule), db, bob) + expect(result.success).toBe(false) + + const denial = auditRows(db, handshakeId).find((r) => r.action === 'handshake_pipeline_denial') + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.UNKNOWN_CRITICAL_EXTENSION) + expect(denial!.metadata.refused_namespace).toBe('com.vendor.mandatory-thing') + + // The refusal must be pre-visibility: no relationship row exists. + const row = db.prepare('SELECT 1 FROM handshakes WHERE handshake_id = ?').get(handshakeId) + expect(row).toBeUndefined() + }) + + it('container order and unknown entries survive a full round-trip byte-identically', () => { + const entries = [ + { ns: 'com.vendor.zzz', version: 2, critical: false, payload: { b: 2, a: 1 } }, + { ns: 'com.vendor.aaa', version: 1, critical: false, payload: [3, 1, 2] }, + { ns: 'optirando.transport.p2p', version: 1, critical: false, payload: { endpoint: 'x' }, vendor_extra: 'kept' }, + ] + const { capsule } = rebuildWithExtensions(entries) + + // Wire round-trip (serialize → parse → serialize) — the transport path. + const roundTripped = JSON.parse(JSON.stringify(capsule)) + expect( + canonicalJsonString(roundTripped.wr_canonical_v3 as CanonicalJsonValue), + ).toBe(canonicalJsonString((capsule as Record).wr_canonical_v3 as CanonicalJsonValue)) + + // Order preserved verbatim, unknown sibling field preserved. + const ext = roundTripped.wr_canonical_v3.core.extensions + expect(ext.map((e: any) => e.ns)).toEqual(['com.vendor.zzz', 'com.vendor.aaa', 'optirando.transport.p2p']) + expect(ext[2].vendor_extra).toBe('kept') + }) + + // ── Acceptance 4: nonce/replay [VII.3.1] ──────────────────────────────────── + + it('rejects a replayed core: seen nonce arriving with different capsule content', async () => { + const db = makeDb() + const { capsule: first } = buildInitiate() + const firstResult = await ingest(JSON.stringify(first), db, bob) + expect(firstResult.success).toBe(true) + + // Fresh capsule (new handshake_id, new content) but the SAME nonce — + // spent freshness reused for a different object. + const { capsule: replayed } = buildInitiate({ nonce: first.nonce }) + expect(replayed.handshake_id).not.toBe(first.handshake_id) + expect(replayed.capsule_hash).not.toBe(first.capsule_hash) + + const replayResult = await ingest(JSON.stringify(replayed), db, bob) + expect(replayResult.success).toBe(false) + + const denial = auditRows(db, replayed.handshake_id).find((r) => r.action === 'handshake_pipeline_denial') + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.NONCE_REPLAY) + + // Pre-visibility: the replayed handshake never materialized. + const row = db.prepare('SELECT 1 FROM handshakes WHERE handshake_id = ?').get(replayed.handshake_id) + expect(row).toBeUndefined() + }) + + it('idempotent redelivery of the SAME capsule is not a nonce replay (dedup owns it)', async () => { + const db = makeDb() + const { capsule } = buildInitiate() + const first = await ingest(JSON.stringify(capsule), db, bob) + expect(first.success).toBe(true) + + const second = await ingest(JSON.stringify(capsule), db, bob) + // Redelivery must NOT be misclassified as a replayed core. + const denials = auditRows(db, capsule.handshake_id).filter((r) => r.action === 'handshake_pipeline_denial') + for (const d of denials) expect(d.reason_code).not.toBe(ReasonCode.NONCE_REPLAY) + // Whatever the dedup verdict, the original relationship is intact. + const row = db.prepare('SELECT state FROM handshakes WHERE handshake_id = ?').get(capsule.handshake_id) + expect(row).toBeTruthy() + void second + }) + + // ── Party binding (full-claim guard extended into the signed core) ────────── + + it('rejects a v3 capsule whose senderIdentity is not a signed core party', () => { + const { capsule, keypair } = buildInitiate() + const v2 = withoutEnvelope(capsule as unknown as Record) + + const mallory: CorePartyId = { + sub: 'mallory', + iss: alice.iss, + email: 'mallory@dev.test', + wrdesk_user_id: 'mallory', + } + const forged = attachCanonicalEnvelope(v2, { + initiator: mallory, + responder: null, + createdAt: capsule.timestamp, + nonce: capsule.nonce, + privateKeyHex: keypair.privateKey, + publicKeyHex: keypair.publicKey, + signer: 'initiator', + }) + + const verdict = verifyCanonicalEnvelope(forged, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toBe('sender_identity_not_bound_to_core_party') + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3CoreStoreSplit.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3CoreStoreSplit.acceptance.test.ts new file mode 100644 index 000000000..38722f879 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3CoreStoreSplit.acceptance.test.ts @@ -0,0 +1,283 @@ +/** + * Phase 3 — Core store + runtime split: acceptance tests. + * + * 4. Migration parity — dry-run harness over a fixture DB with real-shape + * legacy rows: (a) row-count parity old table ↔ core+runtime, (b) every + * legacy row resolves to a `legacy_v0` core record the dispatcher + * accepts, (c) post-migration state round-trips pass on migrated + * relationships, (d) sign/decrypt key material still resolves (Phase-2 + * key store). + * 5. Hash stability (T2) — a core record's hash is stable across process + * restarts (file reopen), migrations (re-run), and read/write round + * trips; no code path mutates a core record in place (SQL triggers + + * structural writer scan). + */ + +import { describe, it, expect } from 'vitest' +import Database from 'better-sqlite3' +import { readdirSync, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' + +import { + migrateHandshakeTables, + insertHandshakeRecord, + updateHandshakeRecord, + getHandshakeRecord, + getHandshakeKeys, + LEDGER_SCHEMA_FREEZE_VERSION, +} from '../db' +import { + buildSyntheticLegacyCore, + computeCoreStoreHash, + getCoreRow, + getRuntimeRow, + verifyCoreRowHash, + insertCoreRecord, + hasWrCoreStore, +} from '../coreStore' +import { getHighWater } from '../antiRollback' +import { resolveProfile } from '@repo/ingestion-core' +import type { WrHandshakeCore } from '@repo/ingestion-core' +import { HandshakeState } from '../types' +import { buildActiveHandshakeRecord } from './helpers' + +function record(id: string, overrides?: Parameters[0]) { + return buildActiveHandshakeRecord({ + handshake_id: id, + relationship_id: `rel-${id}`, + local_private_key: 'a'.repeat(64), + local_public_key: 'b'.repeat(64), + ...overrides, + }) +} + +/** Fixture: a DB in the PRE-SPLIT shape (frozen at v74) with legacy rows. */ +function makePreSplitDbWithRows(ids: string[]): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db, { freezeAtVersion: LEDGER_SCHEMA_FREEZE_VERSION }) + expect(hasWrCoreStore(db)).toBe(false) + for (const id of ids) insertHandshakeRecord(db, record(id)) + return db +} + +describe('Phase 3 — acceptance 4: migration parity (dry-run harness)', () => { + it('(a) row-count parity: handshakes ↔ wr_handshake_core ↔ wr_handshake_runtime', () => { + const ids = ['hs-p3-1', 'hs-p3-2', 'hs-p3-3'] + const db = makePreSplitDbWithRows(ids) + + // The split migration + backfill (what a real DB experiences on upgrade). + migrateHandshakeTables(db) + expect(hasWrCoreStore(db)).toBe(true) + + const count = (t: string) => (db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get() as { n: number }).n + expect(count('wr_handshake_core')).toBe(count('handshakes')) + expect(count('wr_handshake_runtime')).toBe(count('handshakes')) + expect(count('handshakes')).toBe(ids.length) + }) + + it('(b) every legacy row resolves to a legacy_v0 core record the dispatcher accepts — no fabricated signatures/provenance', () => { + const db = makePreSplitDbWithRows(['hs-p3-b1', 'hs-p3-b2']) + migrateHandshakeTables(db) + + for (const id of ['hs-p3-b1', 'hs-p3-b2']) { + const row = getCoreRow(db, id) + expect(row).toBeTruthy() + expect(row!.profile_id).toBe('legacy_v0') + expect(row!.profile_version).toBe(1) + expect(resolveProfile(row!.profile_id, row!.profile_version).ok).toBe(true) + expect(row!.backfilled).toBe(1) + expect(row!.capture_provenance).toBe('unknown_legacy') + // NEVER fabricated: empty signature list, null ingress_path. + expect(JSON.parse(row!.signatures_json)).toEqual([]) + const core = JSON.parse(row!.core_json) as WrHandshakeCore + expect(core.ingress_path).toBeNull() + expect(core.nonce).toBe('') + // High-water tracking begins for core-record versions. + expect(getHighWater(db, 'wr.handshake.core', id)).toBe(1) + } + }) + + it('(c) post-migration state round-trips pass on migrated relationships; runtime mirrors, core stays frozen', () => { + const db = makePreSplitDbWithRows(['hs-p3-c1']) + migrateHandshakeTables(db) + + const before = getCoreRow(db, 'hs-p3-c1')! + const rec = getHandshakeRecord(db, 'hs-p3-c1')! + expect(rec.state).toBe(HandshakeState.ACTIVE) + + // Refresh-style mutation. + updateHandshakeRecord(db, { ...rec, last_seq_sent: 7, last_capsule_hash_sent: 'h7' }) + // Revoke. + const rec2 = getHandshakeRecord(db, 'hs-p3-c1')! + updateHandshakeRecord(db, { + ...rec2, + state: HandshakeState.REVOKED, + revoked_at: new Date().toISOString(), + revocation_source: 'local-user', + }) + + const runtime = getRuntimeRow(db, 'hs-p3-c1')! + expect(runtime.state).toBe('REVOKED') + expect(runtime.last_seq_sent).toBe(7) + + // The core record is byte-identical after all mutations. + const after = getCoreRow(db, 'hs-p3-c1')! + expect(after.core_hash).toBe(before.core_hash) + expect(after.core_json).toBe(before.core_json) + }) + + it('(d) key material still resolves post-split (Phase-2 key store intact)', () => { + const db = makePreSplitDbWithRows(['hs-p3-d1']) + migrateHandshakeTables(db) + + const keys = getHandshakeKeys(db, 'hs-p3-d1') + expect(keys?.local_private_key).toBe('a'.repeat(64)) + const rec = getHandshakeRecord(db, 'hs-p3-d1')! + expect(rec.local_private_key).toBe('a'.repeat(64)) + // Rows stay clean of key material. + const raw = db.prepare('SELECT local_private_key FROM handshakes WHERE handshake_id = ?').get('hs-p3-d1') as any + expect(raw.local_private_key).toBeNull() + }) + + it('backfill is idempotent: re-running migrations leaves core rows byte-identical', () => { + const db = makePreSplitDbWithRows(['hs-p3-i1']) + migrateHandshakeTables(db) + const first = getCoreRow(db, 'hs-p3-i1')! + migrateHandshakeTables(db) + const second = getCoreRow(db, 'hs-p3-i1')! + expect(second.core_hash).toBe(first.core_hash) + expect(second.core_json).toBe(first.core_json) + const n = (db.prepare('SELECT COUNT(*) AS n FROM wr_handshake_core').get() as { n: number }).n + expect(n).toBe(1) + }) + + it('new relationship writes dual-write through the adapter (single writer in db.ts)', () => { + const db = new Database(':memory:') + migrateHandshakeTables(db) + insertHandshakeRecord(db, record('hs-p3-new')) + const core = getCoreRow(db, 'hs-p3-new') + expect(core).toBeTruthy() + expect(core!.profile_id).toBe('legacy_v0') + expect(core!.backfilled).toBe(0) + expect(getRuntimeRow(db, 'hs-p3-new')).toBeTruthy() + }) +}) + +describe('Phase 3 — acceptance 5: hash stability (T2) + append-only store', () => { + it('core hash is stable across serialize/parse round-trips and independent equal constructions', () => { + const rec = record('hs-p3-h1') + const core1 = buildSyntheticLegacyCore(rec) + const core2 = buildSyntheticLegacyCore({ ...rec }) + expect(computeCoreStoreHash(core1)).toBe(computeCoreStoreHash(core2)) + const roundTripped = JSON.parse(JSON.stringify(core1)) as WrHandshakeCore + expect(computeCoreStoreHash(roundTripped)).toBe(computeCoreStoreHash(core1)) + }) + + it('core hash is stable across a process-restart-shaped reopen (file-backed DB)', () => { + const dir = mkdtempSync(join(tmpdir(), 'wr-core-')) + const path = join(dir, 'fixture.db') + try { + let db = new Database(path) + migrateHandshakeTables(db) + insertHandshakeRecord(db, record('hs-p3-h2')) + const before = getCoreRow(db, 'hs-p3-h2')! + db.close() + + db = new Database(path) + migrateHandshakeTables(db) // migrations re-run on every open — must be inert + const after = getCoreRow(db, 'hs-p3-h2')! + expect(after.core_hash).toBe(before.core_hash) + expect(after.core_json).toBe(before.core_json) + expect(verifyCoreRowHash(after)).toBe(true) + db.close() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('the store itself aborts UPDATE and DELETE on wr_handshake_core (append-only triggers)', () => { + const db = new Database(':memory:') + migrateHandshakeTables(db) + insertHandshakeRecord(db, record('hs-p3-h3')) + + expect(() => + db.prepare("UPDATE wr_handshake_core SET capture_provenance = 'forged' WHERE handshake_id = ?").run('hs-p3-h3'), + ).toThrow(/append-only/) + expect(() => + db.prepare('DELETE FROM wr_handshake_core WHERE handshake_id = ?').run('hs-p3-h3'), + ).toThrow(/append-only/) + }) + + it('a differing core for an existing handshake is refused (immutability, [VII.3.3])', () => { + const db = new Database(':memory:') + migrateHandshakeTables(db) + insertHandshakeRecord(db, record('hs-p3-h4')) + const original = getCoreRow(db, 'hs-p3-h4')! + + const differing = buildSyntheticLegacyCore(record('hs-p3-h4', { created_at: '2001-01-01T00:00:00.000Z' })) + const result = insertCoreRecord(db, { + core: differing, + handshakeId: 'hs-p3-h4', + signatures: [], + captureProvenance: 'unknown_legacy', + backfilled: false, + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.inserted).toBe(false) + expect(result.coreHash).toBe(original.core_hash) // existing core wins + } + expect(getCoreRow(db, 'hs-p3-h4')!.core_json).toBe(original.core_json) + }) + + it('anti-rollback: a core version below the high-water mark is rejected', () => { + const db = new Database(':memory:') + migrateHandshakeTables(db) + const core = buildSyntheticLegacyCore(record('hs-p3-h5')) + const v2 = insertCoreRecord(db, { + core, + handshakeId: 'hs-p3-h5', + signatures: [], + captureProvenance: 'unknown_legacy', + backfilled: false, + coreVersion: 2, + }) + expect(v2.ok).toBe(true) + const v1 = insertCoreRecord(db, { + core, + handshakeId: 'hs-p3-h5', + signatures: [], + captureProvenance: 'unknown_legacy', + backfilled: false, + coreVersion: 1, + }) + expect(v1.ok).toBe(false) + if (!v1.ok) expect(v1.reason).toBe('rollback') + }) + + it('structural: no source writer targets wr_handshake_core rows for UPDATE or DELETE', () => { + const here = fileURLToPath(new URL('.', import.meta.url)) + const roots = [resolve(here, '..', '..')] // electron/main + const offenders: string[] = [] + const visit = (dir: string) => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + const st = statSync(full) + if (st.isDirectory()) { + if (entry === 'node_modules' || entry === '__tests__' || entry === 'dist') continue + visit(full) + } else if (/\.(ts|js)$/.test(entry)) { + const text = readFileSync(full, 'utf8') + if (/UPDATE\s+wr_handshake_core/i.test(text) || /DELETE\s+FROM\s+wr_handshake_core/i.test(text)) { + offenders.push(full) + } + } + } + } + for (const root of roots) visit(root) + expect(offenders).toEqual([]) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3LedgerFreeze.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3LedgerFreeze.acceptance.test.ts new file mode 100644 index 000000000..8dca7700b --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3LedgerFreeze.acceptance.test.ts @@ -0,0 +1,172 @@ +/** + * Phase 3 — acceptance 6: ledger freeze & sweep (G5). + * + * The ledger handle is frozen at LEDGER_SCHEMA_FREEZE_VERSION (v74): the + * core-store split (v75+) never lands on it. The one-time sweep copies out + * and removes private-key material from relationship rows and any + * undocumented tables; afterwards the hygiene assertion holds — documented + * tables only, no key-material values on rows, integrity check passes on + * both the frozen (ledger-shaped) and full (vault-shaped) handles. + */ + +import { describe, it, expect } from 'vitest' +import Database from 'better-sqlite3' +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { migrateHandshakeTables, LEDGER_SCHEMA_FREEZE_VERSION } from '../db' +import { auditLedgerTables, sweepLedgerForFreeze, assertLedgerHygiene } from '../ledgerHygiene' + +function makeLedgerShapedDb(): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + // Ledger-native tables (subset sufficient for the sweep/meta paths). + db.prepare(`CREATE TABLE ledger_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`).run() + db.prepare(`CREATE TABLE ledger_handshakes (handshake_id TEXT PRIMARY KEY, relationship_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', capsule_type TEXT NOT NULL, sender_id TEXT NOT NULL, + sender_email TEXT, receiver_id TEXT, receiver_email TEXT, local_role TEXT NOT NULL, + sharing_mode TEXT, capsule_hash TEXT NOT NULL, context_hash TEXT, context_commitment TEXT, + nonce TEXT, policy_hash TEXT, policy_version TEXT, tier_signals TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`).run() + db.prepare(`CREATE TABLE ledger_context_blocks (block_id TEXT NOT NULL, handshake_id TEXT NOT NULL, + block_hash TEXT NOT NULL, block_type TEXT NOT NULL, scope_id TEXT, created_at TEXT NOT NULL, + PRIMARY KEY (block_id, handshake_id))`).run() + db.prepare(`CREATE TABLE ledger_schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, + description TEXT NOT NULL)`).run() + // Persisted freeze marker + FROZEN handshake schema — what openLedger + // applies from Phase 3 on. + db.prepare(`INSERT OR REPLACE INTO ledger_meta (key, value) VALUES ('wr_schema_freeze', ?)`).run( + String(LEDGER_SCHEMA_FREEZE_VERSION), + ) + migrateHandshakeTables(db, { freezeAtVersion: LEDGER_SCHEMA_FREEZE_VERSION }) + return db +} + +describe('Phase 3 — ledger freeze (G5)', () => { + it('frozen handles never receive the core-store split (v75+)', () => { + const db = makeLedgerShapedDb() + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((r: any) => r.name) + expect(tables).not.toContain('wr_handshake_core') + expect(tables).not.toContain('wr_handshake_runtime') + + const maxApplied = db + .prepare('SELECT MAX(version) AS v FROM handshake_schema_migrations') + .get() as { v: number } + expect(maxApplied.v).toBeLessThanOrEqual(LEDGER_SCHEMA_FREEZE_VERSION) + + // Re-running the frozen migration stays frozen (idempotent freeze). + migrateHandshakeTables(db, { freezeAtVersion: LEDGER_SCHEMA_FREEZE_VERSION }) + expect( + db.prepare("SELECT name FROM sqlite_master WHERE name = 'wr_handshake_core'").get(), + ).toBeUndefined() + }) + + it('the persisted freeze marker protects the handle against LAZY migration calls (no options)', () => { + const db = makeLedgerShapedDb() + // What the ingestion IPC layer does: migrateHandshakeTables(db) with no + // idea which handle it received. The ledger_meta marker must hold the line. + migrateHandshakeTables(db) + expect( + db.prepare("SELECT name FROM sqlite_master WHERE name = 'wr_handshake_core'").get(), + ).toBeUndefined() + const maxApplied = db + .prepare('SELECT MAX(version) AS v FROM handshake_schema_migrations') + .get() as { v: number } + expect(maxApplied.v).toBeLessThanOrEqual(LEDGER_SCHEMA_FREEZE_VERSION) + }) + + it('≤v74 tables the pipeline needs (key store, high-water, nonces) DO exist on the frozen handle', () => { + const db = makeLedgerShapedDb() + for (const table of ['handshakes', 'handshake_key_store', 'wr_high_water_versions', 'wr_core_nonces']) { + expect( + db.prepare("SELECT name FROM sqlite_master WHERE name = ?").get(table), + table, + ).toBeTruthy() + } + }) +}) + +describe('Phase 3 — ledger sweep & hygiene assertion (G5)', () => { + it('sweep moves row-level key material to the key store and nulls the columns', () => { + const db = makeLedgerShapedDb() + // A row written with key material ON the row (pre-v73-shaped write that + // landed after the migration already ran — exactly what the sweep exists for). + db.prepare( + `INSERT INTO handshakes (handshake_id, relationship_id, state, initiator_json, local_role, + reciprocal_allowed, external_processing, tier_snapshot_json, current_tier_signals_json, + effective_policy_json, created_at, local_private_key, local_x25519_private_key_b64) + VALUES ('hs-sweep-1', 'rel-sweep-1', 'ACTIVE', '{}', 'initiator', 1, 'none', '{}', '{}', + '{}', datetime('now'), 'PRIVATE_KEY_HEX', 'X25519_SECRET_B64')`, + ).run() + + const summary = sweepLedgerForFreeze(db) + expect(summary.keyRowsSwept).toBe(1) + expect(summary.errors).toEqual([]) + + const row = db + .prepare('SELECT local_private_key, local_x25519_private_key_b64 FROM handshakes WHERE handshake_id = ?') + .get('hs-sweep-1') as any + expect(row.local_private_key).toBeNull() + expect(row.local_x25519_private_key_b64).toBeNull() + + // Copy-before-null: the material survived in the (documented) key store. + const stored = db + .prepare('SELECT local_private_key, local_x25519_private_key_b64 FROM handshake_key_store WHERE handshake_id = ?') + .get('hs-sweep-1') as any + expect(stored.local_private_key).toBe('PRIVATE_KEY_HEX') + expect(stored.local_x25519_private_key_b64).toBe('X25519_SECRET_B64') + + // Idempotent re-run is a no-op. + const second = sweepLedgerForFreeze(db) + expect(second.keyRowsSwept).toBe(0) + expect(second.undocumentedTablesRemoved).toEqual([]) + }) + + it('sweep copies out and drops undocumented tables; hygiene assertion passes afterwards', () => { + const db = makeLedgerShapedDb() + // An undocumented table written through the ledger handle (the + // edge_ingestor class of schema bleed named in migration-and-risk §1.1). + db.prepare(`CREATE TABLE edge_ingestor_pairings (id TEXT PRIMARY KEY, secret TEXT)`).run() + db.prepare(`INSERT INTO edge_ingestor_pairings VALUES ('pair-1', 's3cret')`).run() + + const before = auditLedgerTables(db) + expect(before.undocumented).toEqual(['edge_ingestor_pairings']) + + const dir = mkdtempSync(join(tmpdir(), 'wr-ledger-')) + try { + const summary = sweepLedgerForFreeze(db, { sidecarDir: dir }) + expect(summary.undocumentedTablesRemoved).toEqual(['edge_ingestor_pairings']) + expect(summary.sidecarPath).toBeTruthy() + + // Copy-out happened before the drop. + const sidecar = JSON.parse(readFileSync(summary.sidecarPath!, 'utf8')) + expect(sidecar.tables.edge_ingestor_pairings).toEqual([{ id: 'pair-1', secret: 's3cret' }]) + expect(readdirSync(dir).length).toBe(1) + + const after = auditLedgerTables(db) + expect(after.undocumented).toEqual([]) + + const hygiene = assertLedgerHygiene(db) + expect(hygiene).toEqual({ ok: true, undocumented: [], keyColumnsClear: true, integrityOk: true }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('integrity check passes on both handles: frozen (ledger-shaped) and full (vault-shaped)', () => { + const frozen = makeLedgerShapedDb() + sweepLedgerForFreeze(frozen) + expect(assertLedgerHygiene(frozen).integrityOk).toBe(true) + + const full = new Database(':memory:') + migrateHandshakeTables(full) + // The full handle legitimately carries v75 tables — hygiene's + // undocumented-check is ledger-specific, but integrity must hold. + expect(assertLedgerHygiene(full).integrityOk).toBe(true) + expect(assertLedgerHygiene(full).keyColumnsClear).toBe(true) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3ProfileRegistry.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3ProfileRegistry.acceptance.test.ts new file mode 100644 index 000000000..8f2952b67 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase3ProfileRegistry.acceptance.test.ts @@ -0,0 +1,311 @@ +/** + * Phase 3 — Profile registry & fail-closed dispatch: acceptance tests. + * + * 1. Unknown-profile refusal [VII.4.2] — unknown profile id and unsupported + * profile version each produce a visible refusal NAMING the profile; no + * fallback path exists. + * 2. Schema-level attestation rejection [VII.4.5] — a `private_personal` + * core carrying a publisher_attestation block is rejected by schema, not + * by UI; `pbeap_publisher` without one is likewise rejected. + * 3. Countersignature gate [VII.3.2] — for `org_internal`/`org_cross`, only + * a DOUBLY signed byte-identical core counts as established; a + * countersignature over differing bytes is rejected; the same key twice + * does not satisfy cardinality 2. + * + * Pipeline-level cases run through the REAL ingestion pipeline on an + * in-memory sqlite DB (same harness as phase2CanonicalCore.acceptance). + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import Database from 'better-sqlite3' +import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto' + +import { migrateHandshakeTables } from '../db' +import { migrateIngestionTables } from '../../ingestion/persistenceDb' +import { handleIngestionRPC } from '../../ingestion/ipc' +import { setEmailSendFn, _resetEmailSendFn } from '../emailTransport' +import { buildInitiateCapsuleWithKeypair } from '../capsuleBuilder' +import { buildTestSession } from '../sessionFactory' +import { ReasonCode } from '../types' +import { + buildCoreForCapsule, + signCore, + verifyCanonicalEnvelope, +} from '../canonicalCore' +import { + resolveProfile, + listProfileRecords, + PUBLISHER_ATTESTATION_NS, + WR_CANONICAL_SCHEMA_VERSION, +} from '@repo/ingestion-core' +import type { CorePartyId, CoreSignature, WrHandshakeCore } from '@repo/ingestion-core' +import type { SSOSession } from '../types' + +function session(user: string): SSOSession { + return buildTestSession({ wrdesk_user_id: user, sub: user, email: `${user}@dev.test` }) +} + +function makeDb(): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + migrateIngestionTables(db) + return db +} + +function ingest(capsuleJson: string, db: any, asSession: SSOSession) { + return handleIngestionRPC( + 'ingestion.ingest', + { + rawInput: { body: capsuleJson, mime_type: 'application/vnd.beap+json' }, + sourceType: 'email', + transportMeta: { channel_id: 'relay:test', mime_type: 'application/vnd.beap+json' }, + }, + db, + asSession, + ) +} + +function auditDenial(db: any, handshakeId: string) { + const row = db + .prepare( + "SELECT reason_code, metadata FROM audit_log WHERE handshake_id = ? AND action = 'handshake_pipeline_denial' ORDER BY rowid DESC LIMIT 1", + ) + .get(handshakeId) as { reason_code: string; metadata: string | null } | undefined + return row ? { reason_code: row.reason_code, metadata: row.metadata ? JSON.parse(row.metadata) : {} } : null +} + +function party(s: SSOSession): CorePartyId { + return { sub: s.sub, iss: s.iss, email: s.email, wrdesk_user_id: s.wrdesk_user_id } +} + +/** Fresh Ed25519 keypair as (seed hex, raw public key hex) — pub derived FROM the seed. */ +function mkKeys(): { privateKey: string; publicKey: string } { + const seed = randomBytes(32) + const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]) + const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }) + const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer + return { privateKey: seed.toString('hex'), publicKey: spki.subarray(spki.length - 32).toString('hex') } +} + +const alice = session('p3alice') +const bob = session('p3bob') + +function buildInitiate() { + return buildInitiateCapsuleWithKeypair(alice, { + receiverUserId: bob.wrdesk_user_id, + receiverEmail: bob.email, + reciprocal_allowed: true, + }) +} + +/** + * Build a capsule whose v3 envelope carries an ARBITRARY profile + signature + * list (the production builder always emits legacy_v0 — these fixtures + * exercise the dispatcher). + */ +function capsuleWithProfile(args: { + profile: { id: string; version: number } + extensions?: Array> + extraSigners?: Array<{ keys: { privateKey: string; publicKey: string }; mode: CoreSignature['mode'] }> + mutateCoreForCountersig?: (core: WrHandshakeCore) => WrHandshakeCore +}) { + const { capsule, keypair } = buildInitiate() + const { wr_canonical_v3: _drop, ...v2 } = capsule as unknown as Record + const core = buildCoreForCapsule(v2, { + initiator: party(alice), + responder: null, + createdAt: capsule.timestamp, + nonce: capsule.nonce, + extensions: (args.extensions ?? []) as any, + }) + ;(core as any).profile = { ...args.profile } + const signatures: CoreSignature[] = [ + signCore(core, keypair.privateKey, keypair.publicKey, 'initiator', 'canonical_bytes'), + ] + for (const extra of args.extraSigners ?? []) { + const target = args.mutateCoreForCountersig ? args.mutateCoreForCountersig(core) : core + signatures.push(signCore(target, extra.keys.privateKey, extra.keys.publicKey, 'responder', extra.mode)) + } + const wire = { ...v2, wr_canonical_v3: { v: WR_CANONICAL_SCHEMA_VERSION, core, signatures } } + return { wire, keypair, handshakeId: capsule.handshake_id as string } +} + +describe('Phase 3 — acceptance 1: unknown-profile refusal [VII.4.2]', () => { + beforeEach(() => { + _resetEmailSendFn() + setEmailSendFn(vi.fn().mockResolvedValue({ success: true, messageId: 'm1' })) + }) + + it('registry dispatch is fail-closed: unknown id and unsupported version both refuse, naming the profile', () => { + const unknown = resolveProfile('conjured_profile', 1) + expect(unknown.ok).toBe(false) + if (!unknown.ok) { + expect(unknown.reason).toBe('unknown_profile') + expect(unknown.profileId).toBe('conjured_profile') + } + const badVersion = resolveProfile('legacy_v0', 99) + expect(badVersion.ok).toBe(false) + if (!badVersion.ok) expect(badVersion.reason).toBe('unsupported_profile_version') + + // The five briefed records plus the Phase-4 (Q9) `internal_device` + // profile (same-principal Cross-Device pairing). + expect(listProfileRecords().map((r) => r.id).sort()).toEqual([ + 'internal_device', + 'legacy_v0', + 'org_cross', + 'org_internal', + 'pbeap_publisher', + 'private_personal', + ]) + }) + + it('envelope with an unknown profile id is refused with the profile named', () => { + const { wire, keypair } = capsuleWithProfile({ profile: { id: 'conjured_profile', version: 1 } }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) { + expect(verdict.reason).toBe('unknown_profile:conjured_profile@1') + expect(verdict.refusedProfile).toEqual({ id: 'conjured_profile', version: 1 }) + } + }) + + it('envelope with an unsupported profile version is refused with the profile named', () => { + const { wire, keypair } = capsuleWithProfile({ profile: { id: 'private_personal', version: 42 } }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) { + expect(verdict.reason).toBe('unsupported_profile_version:private_personal@42') + expect(verdict.refusedProfile).toEqual({ id: 'private_personal', version: 42 }) + } + }) + + it('pipeline: unknown profile dies pre-visibility with UNKNOWN_PROFILE and the profile in evidence', async () => { + const db = makeDb() + const { wire, handshakeId } = capsuleWithProfile({ profile: { id: 'conjured_profile', version: 1 } }) + + const result = await ingest(JSON.stringify(wire), db, bob) + expect(result.success).toBe(false) + + const denial = auditDenial(db, handshakeId) + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.UNKNOWN_PROFILE) + expect(denial!.metadata.refused_profile).toBe('conjured_profile@1') + + // No fallback path: the relationship never materialized. + expect(db.prepare('SELECT 1 FROM handshakes WHERE handshake_id = ?').get(handshakeId)).toBeUndefined() + }) +}) + +describe('Phase 3 — acceptance 2: schema-level attestation rejection [VII.4.5]', () => { + beforeEach(() => { + _resetEmailSendFn() + setEmailSendFn(vi.fn().mockResolvedValue({ success: true, messageId: 'm1' })) + }) + + const attestationEntry = { + ns: PUBLISHER_ATTESTATION_NS, + version: 1, + critical: false, + payload: { stamp: 'publisher-stamp' }, + } + + it('private_personal core carrying a publisher_attestation block is rejected by schema', () => { + const { wire, keypair } = capsuleWithProfile({ + profile: { id: 'private_personal', version: 1 }, + extensions: [attestationEntry], + }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) { + expect(verdict.reason).toBe('attestation_forbidden_for_profile:private_personal') + expect(verdict.refusedProfile).toEqual({ id: 'private_personal', version: 1 }) + } + }) + + it('pbeap_publisher core WITHOUT an attestation block is rejected (mandatory)', () => { + const { wire, keypair } = capsuleWithProfile({ profile: { id: 'pbeap_publisher', version: 1 } }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toBe('attestation_missing_for_profile:pbeap_publisher') + }) + + it('pipeline: forbidden attestation maps to PROFILE_SCHEMA_VIOLATION pre-visibility', async () => { + const db = makeDb() + const { wire, handshakeId } = capsuleWithProfile({ + profile: { id: 'private_personal', version: 1 }, + extensions: [attestationEntry], + }) + const result = await ingest(JSON.stringify(wire), db, bob) + expect(result.success).toBe(false) + + const denial = auditDenial(db, handshakeId) + expect(denial).toBeTruthy() + expect(denial!.reason_code).toBe(ReasonCode.PROFILE_SCHEMA_VIOLATION) + expect(db.prepare('SELECT 1 FROM handshakes WHERE handshake_id = ?').get(handshakeId)).toBeUndefined() + }) +}) + +describe('Phase 3 — acceptance 3: countersignature gate [VII.3.2]', () => { + it('org_internal with a single signature does not count as established (cardinality 2)', () => { + const { wire, keypair } = capsuleWithProfile({ profile: { id: 'org_internal', version: 1 } }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) { + expect(verdict.reason).toBe('signature_cardinality_unmet:1<2') + expect(verdict.refusedProfile).toEqual({ id: 'org_internal', version: 1 }) + } + }) + + it('org_internal doubly signed over the byte-identical core verifies', () => { + const responderKeys = mkKeys() + const { wire, keypair } = capsuleWithProfile({ + profile: { id: 'org_internal', version: 1 }, + extraSigners: [{ keys: responderKeys, mode: 'canonical_hash' }], + }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + if (!verdict.ok) throw new Error(`refused: ${verdict.reason}`) + expect(verdict.ok).toBe(true) + }) + + it('org_cross doubly signed over the byte-identical core verifies', () => { + const responderKeys = mkKeys() + const { wire, keypair } = capsuleWithProfile({ + profile: { id: 'org_cross', version: 1 }, + extraSigners: [{ keys: responderKeys, mode: 'canonical_hash' }], + }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + if (!verdict.ok) throw new Error(`refused: ${verdict.reason}`) + expect(verdict.ok).toBe(true) + }) + + it('a countersignature over DIFFERING bytes is rejected', () => { + const responderKeys = mkKeys() + const { wire, keypair } = capsuleWithProfile({ + profile: { id: 'org_internal', version: 1 }, + extraSigners: [{ keys: responderKeys, mode: 'canonical_hash' }], + // Responder signs a core whose created_at differs by 1ms — NOT the + // byte-identical core the initiator signed. + mutateCoreForCountersig: (core) => ({ + ...core, + created_at: new Date(Date.parse(core.created_at) + 1).toISOString(), + }), + }) + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toBe('signature_invalid:responder:canonical_hash') + }) + + it('the same key twice does not satisfy cardinality 2 (distinct signers required)', () => { + const { wire, keypair } = capsuleWithProfile({ profile: { id: 'org_internal', version: 1 } }) + // Duplicate the initiator signature as a fake "responder" countersig. + const env = (wire as any).wr_canonical_v3 + env.signatures = [ + env.signatures[0], + { ...env.signatures[0], signer: 'responder' }, + ] + const verdict = verifyCanonicalEnvelope(wire, keypair.publicKey) + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toBe('signature_cardinality_unmet:1<2') + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4EdgeAgentFoldIn.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4EdgeAgentFoldIn.acceptance.test.ts new file mode 100644 index 000000000..7180b1f9e --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4EdgeAgentFoldIn.acceptance.test.ts @@ -0,0 +1,106 @@ +/** + * Phase 4 — Edge-agent fold-in (V8 / I3) [XI.3-I9] — acceptance test 7. + * + * The edge-agent pairing dialect (`apps/edge-agent/dist/pairingProtocol.js`, + * `edge_ingestor` records held in the agent's own encrypted state) is RETIRED + * for new formations. This codebase never contained the orchestrator-side + * counterpart that would write `edge_ingestor` ledger rows; the retirement is + * enforced structurally: + * + * 1. `edge_ingestor` is a retired dialect identifier — profile dispatch + * fails closed (`unknown_profile`), no adapter maps it to a registered + * profile, and the registry marks it retired. + * 2. New same-principal device pairings form exclusively through the one + * pipeline under the `internal_device` profile. + * 3. Structural absence: no production source in the Electron app or the + * shared packages reads or writes `edge_ingestor` records. Legacy agent + * pairings stay readable by the agent's own dist (read-only transition + * window, untouched here). + */ + +import { describe, it, expect } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' +import { + resolveProfile, + listProfileRecords, + RETIRED_FORMATION_DIALECTS, +} from '@repo/ingestion-core' + +describe('Phase 4 — edge-agent fold-in (V8/I3)', () => { + it('edge_ingestor is a retired dialect: fail-closed refusal, never registered', () => { + expect(RETIRED_FORMATION_DIALECTS).toContain('edge_ingestor') + + for (const retired of RETIRED_FORMATION_DIALECTS) { + // Fail-closed dispatch: retired dialects resolve to a visible refusal. + const res = resolveProfile(retired, 1) + expect(res.ok).toBe(false) + if (!res.ok) expect(res.reason).toBe('unknown_profile') + + // And the registry itself never carries them under any version. + expect(listProfileRecords().some((r) => r.id === retired)).toBe(false) + } + }) + + it('internal_device is the one mechanism for same-principal device pairing', () => { + const res = resolveProfile('internal_device', 1) + expect(res.ok).toBe(true) + if (res.ok) { + expect(res.record.same_principal).toBe(true) + expect(res.record.mutual_consent_required).toBe(true) + } + }) + + it('structural absence: no production source references edge_ingestor', () => { + // Repo root: apps/electron-vite-project/electron/main/handshake/__tests__ → up 6. + const repoRoot = resolve(__dirname, '..', '..', '..', '..', '..', '..') + const scanRoots = [ + join(repoRoot, 'apps', 'electron-vite-project', 'electron'), + join(repoRoot, 'apps', 'electron-vite-project', 'src'), + join(repoRoot, 'apps', 'extension-chromium', 'src'), + join(repoRoot, 'packages'), + ] + const offenders: string[] = [] + + const walk = (dir: string): void => { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const entry of entries) { + const p = join(dir, entry) + if ( + entry === 'node_modules' || + entry === 'dist' || + entry === 'dist-electron' || + entry === '__tests__' || + entry === '.git' + ) { + continue + } + let st + try { + st = statSync(p) + } catch { + continue + } + if (st.isDirectory()) { + walk(p) + continue + } + if (!/\.(ts|tsx|js|mjs|cjs)$/.test(entry) || /\.(test|spec)\./.test(entry)) continue + const text = readFileSync(p, 'utf8') + if (text.includes('edge_ingestor')) { + // The retired-dialect registry marker is the single permitted mention. + const isRegistryMarker = p.split(sep).join('/').endsWith('packages/ingestion-core/src/profileRegistry.ts') + if (!isRegistryMarker) offenders.push(p) + } + } + } + + for (const root of scanRoots) walk(root) + expect(offenders).toEqual([]) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4OneFormationPipeline.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4OneFormationPipeline.acceptance.test.ts new file mode 100644 index 000000000..b08105a7a --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4OneFormationPipeline.acceptance.test.ts @@ -0,0 +1,411 @@ +/** + * Phase 4 — One formation pipeline — acceptance tests 1, 2, 3, 4, 6. + * + * 1. No formation outside capture + consent [IX.12.1]: inbound initiates + * produce staging entries only; the relationship store has a single + * consent-gated writer (structural). + * 2. Offer suppression [IX.3.1 rule 2]: verification failure → no Connect + * offer reachable, no override control exists (structural absence). + * 3. Ingress-path neutrality [VII.4.6]: no semantic branch on `ingress_path` + * or capture-method values; different paths yield semantically identical + * relationships (same profile → same rights). + * 4. `handshake_type` elimination: the discriminator is gone from the record + * model and no production code branches on it outside the declared wire + * compat boundaries (grep-level structural absence). + * 6. Provenance + Hash-Pinned Consent [IX.3.1 rule 5, IX.3.4]: new formations + * carry capture provenance in the signed contract; consent records resolve + * to their three hashes; a consent whose preview hash does not resolve is + * invalid. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' +import Database from 'better-sqlite3' +import { + setConnectOfferDbProvider, + stageInboundInitiate, + listConnectOffers, + prepareFormationConsent, + ingressMappingForSource, +} from '../formationPipeline' +import { + stageConnectOffer, + getConsentableOffer, + listPendingConnectOffers, + buildConnectOfferPreview, + consentRecordResolves, + expireStaleOffers, + type ConnectOfferRow, +} from '../connectOfferStaging' +import { buildFormationCore, CAPTURE_PROVENANCE_NS, type FormationMeta } from '../coreStore' +import { buildActiveHandshakeRecord } from './helpers' + +// ── Shared source scanner ───────────────────────────────────────────────────── + +// Repo root: .../electron/main/handshake/__tests__ → up 6. +const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..', '..') +const PRODUCTION_ROOTS = [ + join(REPO_ROOT, 'apps', 'electron-vite-project', 'electron'), + join(REPO_ROOT, 'apps', 'electron-vite-project', 'src'), + join(REPO_ROOT, 'apps', 'extension-chromium', 'src'), + join(REPO_ROOT, 'packages'), +] + +function* productionSources(): Generator<{ path: string; rel: string; text: string }> { + const walk = function* (dir: string): Generator { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const entry of entries) { + if ( + entry === 'node_modules' || entry === 'dist' || entry === 'dist-electron' || + entry === '__tests__' || entry === '.git' || entry === 'coverage' + ) continue + const p = join(dir, entry) + let st + try { st = statSync(p) } catch { continue } + if (st.isDirectory()) { yield* walk(p); continue } + if (!/\.(ts|tsx)$/.test(entry) || /\.(test|spec)\./.test(entry) || entry.endsWith('.d.ts')) continue + yield p + } + } + for (const root of PRODUCTION_ROOTS) { + for (const p of walk(root)) { + yield { path: p, rel: p.split(sep).join('/').slice(REPO_ROOT.length + 1), text: readFileSync(p, 'utf8') } + } + } +} + +// ── Staging DB fixture ──────────────────────────────────────────────────────── + +let stagingDb: InstanceType + +beforeEach(() => { + stagingDb = new Database(':memory:') + setConnectOfferDbProvider(() => stagingDb) +}) + +afterEach(() => { + setConnectOfferDbProvider(null) + try { stagingDb.close() } catch { /* noop */ } +}) + +const CAPSULE = { + capsule_type: 'handshake-initiate', + handshake_id: 'hs-p4-01', + context_scopes: ['availability', 'projects'], + external_processing: 'none', + reciprocal_allowed: true, +} + +function stage(overrides?: Partial[0]>) { + return stageInboundInitiate({ + handshake_id: 'hs-p4-01', + capsule: CAPSULE, + capsule_hash: 'hash-p4-01', + sender_email: 'peer@example.com', + sender_iss: 'https://auth.wrdesk.com', + sender_sub: 'sub-peer', + sender_wrdesk_user_id: 'peer-user', + receiver_email: 'me@example.com', + source_type: 'email', + ...overrides, + }) +} + +// ── 1. No formation outside capture + consent ──────────────────────────────── + +describe('acceptance 1 — no formation outside capture + consent [IX.12.1]', () => { + it('inbound initiate produces a staging entry only — relationship store untouched', () => { + const relDb = new Database(':memory:') + try { + // stageInboundInitiate does not even receive a relationship DB handle — + // it writes exclusively to the staging store. + const r = stage() + expect(r.staged).toBe(true) + expect(listPendingConnectOffers(stagingDb).length).toBe(1) + const tables = relDb + .prepare(`SELECT name FROM sqlite_master WHERE type='table'`) + .all() as Array<{ name: string }> + expect(tables.length).toBe(0) + } finally { + relDb.close() + } + }) + + it('structural: the relationship store has consent-gated writers only', () => { + // insertHandshakeRecord is THE single write entry point for new + // relationship rows. Its production callers are exactly the one pipeline + // (initiator consent) and the consent-gated enforcement ingest. + const allowed = new Set([ + 'apps/electron-vite-project/electron/main/handshake/db.ts', // definition + 'apps/electron-vite-project/electron/main/handshake/formationPipeline.ts', + 'apps/electron-vite-project/electron/main/handshake/enforcement.ts', + ]) + const offenders: string[] = [] + for (const f of productionSources()) { + if (/insertHandshakeRecord\s*\(/.test(f.text) && !allowed.has(f.rel)) offenders.push(f.rel) + } + expect(offenders).toEqual([]) + }) + + it('structural: deleted dialects stay deleted', () => { + for (const f of productionSources()) { + expect(f.rel.endsWith('initiatorPersist.ts'), `${f.rel} must not exist`).toBe(false) + expect(f.rel.endsWith('recipientPersist.ts'), `${f.rel} must not exist`).toBe(false) + } + }) +}) + +// ── 2. Offer suppression ────────────────────────────────────────────────────── + +describe('acceptance 2 — offer suppression [IX.3.1 rule 2]', () => { + it('verification failure → offer unreachable from every read surface', () => { + const r = stageConnectOffer(stagingDb, { + handshake_id: 'hs-p4-bad', + capsule: CAPSULE, + capsule_hash: 'hash-p4-bad', + profile_id: 'private_personal', + ingress_path: 'beap_invitation', + verification: { ok: false, reason: 'signature_invalid' }, + }) + expect(r.staged).toBe(true) + if (r.staged) { + expect(r.suppressed).toBe(true) + + // Not listable, not consentable — structurally unreachable. + expect(listPendingConnectOffers(stagingDb).length).toBe(0) + expect(listConnectOffers().length).toBe(0) + expect(getConsentableOffer(stagingDb, r.offerId)).toBeNull() + const prep = prepareFormationConsent({ offerId: r.offerId, actorWrdeskUserId: 'me' }) + expect(prep.ok).toBe(false) + if (!prep.ok) expect(prep.reason).toBe('OFFER_NOT_CONSENTABLE') + + // But it IS a logged record: the row persists with the failure reason. + const row = stagingDb + .prepare(`SELECT verification_status, verification_reason, suppressed FROM wr_connect_offers WHERE offer_id = ?`) + .get(r.offerId) as { verification_status: string; verification_reason: string; suppressed: number } + expect(row.verification_status).toBe('failed') + expect(row.verification_reason).toBe('signature_invalid') + expect(row.suppressed).toBe(1) + } + }) + + it('structural absence: no override control, single staging read surface', () => { + for (const f of productionSources()) { + // Documentation phrases like `there is no "connect anyway"` are fine; + // an affirmative control name/label is not. + const overrideLines = f.text + .split('\n') + .filter((l) => /connect[\s_-]?anyway/i.test(l) && !/no\s+["'“`]?connect/i.test(l)) + expect(overrideLines, `${f.rel} must not offer a "connect anyway" override`).toEqual([]) + // The staging tables have exactly one production read/write surface; + // no second module can build an alternate (unsuppressed) listing. + if (f.text.includes('wr_connect_offers')) { + expect(f.rel).toBe('apps/electron-vite-project/electron/main/handshake/connectOfferStaging.ts') + } + } + }) +}) + +// ── 3. Ingress-path neutrality ──────────────────────────────────────────────── + +describe('acceptance 3 — ingress-path neutrality [VII.4.6]', () => { + it('lint: no production code compares ingress_path or capture_method to a literal value', () => { + const forbidden = /(ingress_path|capture_method)\s*[!=]==?\s*['"`]/ + const offenders: string[] = [] + for (const f of productionSources()) { + const lines = f.text.split('\n') + lines.forEach((line, i) => { + if (forbidden.test(line)) offenders.push(`${f.rel}:${i + 1}: ${line.trim()}`) + }) + } + expect(offenders).toEqual([]) + }) + + it('formation via different paths yields semantically identical relationships', () => { + const record = buildActiveHandshakeRecord() + const mk = (ingress: string, capture: string): FormationMeta => ({ + profile_id: 'private_personal', + profile_version: 1, + ingress_path: ingress, + capture_method: capture, + source_reference: null, + consent_id: 'c-1', + nonce: 'n-1', + }) + const viaEmail = buildFormationCore(record, mk('beap_invitation', 'assisted_email')) + const viaFile = buildFormationCore(record, mk('optirando.ingress.file_import', 'manual_entry')) + + // Same profile → same rights: everything except the log-only ingress path + // and the capture-provenance declaration payload is identical. + expect(viaEmail.profile).toEqual(viaFile.profile) + expect(viaEmail.initiator_id).toEqual(viaFile.initiator_id) + expect(viaEmail.responder_id).toEqual(viaFile.responder_id) + expect(viaEmail.declarations.map((d) => d.ns)).toEqual(viaFile.declarations.map((d) => d.ns)) + expect(viaEmail.ingress_path).not.toBe(viaFile.ingress_path) + }) + + it('Q4 mapping is total: every transport source resolves to a recordable pair', () => { + for (const source of ['email', 'file_upload', 'internal', 'p2p', 'relay_pull', 'coordination_ws', 'never_seen_before']) { + const m = ingressMappingForSource(source) + expect(typeof m.ingress_path).toBe('string') + expect(typeof m.capture_method).toBe('string') + } + }) +}) + +// ── 4. handshake_type elimination ───────────────────────────────────────────── + +describe('acceptance 4 — handshake_type elimination (grep-level structural absence)', () => { + it('the record model no longer declares handshake_type', () => { + const typesSrc = readFileSync( + join(REPO_ROOT, 'apps', 'electron-vite-project', 'electron', 'main', 'handshake', 'types.ts'), + 'utf8', + ) + const recordBlock = typesSrc.slice(typesSrc.indexOf('interface HandshakeRecord')) + const firstClose = recordBlock.indexOf('\n}\n') + expect(recordBlock.slice(0, firstClose)).not.toMatch(/^\s*handshake_type/m) + }) + + it('no production code branches on handshake_type outside the wire compat boundaries', () => { + // Every remaining comparison is a WIRE/COLUMN boundary, not record logic: + // - samePrincipalWire.ts — THE single legacy-wire reader + // - db.ts — frozen legacy column read/write + // - p2pTransport.ts — internal relay envelope field parse (wire) + // - coordination-service — relay-server wire parse/log (separate svc) + const allowed = new Set([ + 'apps/electron-vite-project/electron/main/handshake/samePrincipalWire.ts', + 'apps/electron-vite-project/electron/main/handshake/db.ts', + 'apps/electron-vite-project/electron/main/handshake/p2pTransport.ts', + 'packages/coordination-service/src/server.ts', + ]) + const branchPattern = /[.?]\s*handshake_type\s*(?:[!=]==?|\.trim\(\)\s*[!=]==?)/ + const offenders: string[] = [] + for (const f of productionSources()) { + const lines = f.text.split('\n') + lines.forEach((line, i) => { + if (branchPattern.test(line) && !allowed.has(f.rel)) { + offenders.push(`${f.rel}:${i + 1}: ${line.trim()}`) + } + }) + } + expect(offenders).toEqual([]) + }) + + it("no production code writes handshake_type: 'standard' anywhere", () => { + const offenders: string[] = [] + for (const f of productionSources()) { + if (/handshake_type\s*:\s*'standard'/.test(f.text)) { + // Type unions ("'internal' | 'standard'") are declarations, not writes. + const lines = f.text.split('\n') + lines.forEach((line, i) => { + if (/handshake_type\s*:\s*'standard'/.test(line) && !line.includes('|')) { + offenders.push(`${f.rel}:${i + 1}: ${line.trim()}`) + } + }) + } + } + expect(offenders).toEqual([]) + }) +}) + +// ── 6. Provenance + Hash-Pinned Consent ─────────────────────────────────────── + +describe('acceptance 6 — provenance + hash-pinned consent [IX.3.1 rule 5, IX.3.4]', () => { + it('new formations carry capture provenance in the signed contract', () => { + const record = buildActiveHandshakeRecord() + const core = buildFormationCore(record, { + profile_id: 'private_personal', + profile_version: 1, + ingress_path: 'beap_invitation', + capture_method: 'assisted_email', + source_reference: 'imap:msg-42', + consent_id: 'consent-42', + nonce: 'nonce-42', + }) + const prov = core.declarations.find((d) => d.ns === CAPTURE_PROVENANCE_NS) + expect(prov).toBeDefined() + expect((prov!.payload as any).method).toBe('assisted_email') + expect((prov!.payload as any).source_reference).toBe('imap:msg-42') + expect((prov!.payload as any).consent_id).toBe('consent-42') + }) + + it('consent resolves to its three hashes; tampered staged material invalidates it', () => { + const r = stage() + expect(r.staged).toBe(true) + const offerId = (r as { offerId: string }).offerId + + const prep = prepareFormationConsent({ offerId, actorWrdeskUserId: 'me', sourceReference: 'email:inbox' }) + expect(prep.ok).toBe(true) + if (!prep.ok) return + + expect(prep.consent.preview_hash).toMatch(/^[0-9a-f]{64}$/) + expect(prep.consent.bound_definition_hash).toMatch(/^[0-9a-f]{64}$/) + expect(prep.consent.contract_state_hash).toBe('hash-p4-01') + expect(prep.consentRef.formation.capture_method).toBe('assisted_email') + expect(prep.consentRef.formation.ingress_path).toBe('beap_invitation') + + // Valid while the staged material matches what was presented … + expect(consentRecordResolves(stagingDb, prep.consent)).toEqual({ valid: true }) + + // … and INVALID the moment the staged material differs from the pin. + stagingDb + .prepare(`UPDATE wr_connect_offers SET capsule_json = ? WHERE offer_id = ?`) + .run(JSON.stringify({ ...CAPSULE, context_scopes: ['everything'] }), offerId) + const tampered = consentRecordResolves(stagingDb, prep.consent) + expect(tampered.valid).toBe(false) + if (!tampered.valid) expect(tampered.reason).toBe('preview_hash_mismatch') + }) + + it('consent is refused when the user saw a different preview (presentation pin)', () => { + const r = stage() + const offerId = (r as { offerId: string }).offerId + const prep = prepareFormationConsent({ + offerId, + actorWrdeskUserId: 'me', + expectedPreviewHash: 'f'.repeat(64), + }) + expect(prep.ok).toBe(false) + if (!prep.ok) expect(prep.reason).toBe('PREVIEW_HASH_MISMATCH') + }) + + it('Q7: staged offers expire after the 7-day window and stop being consentable', () => { + const r = stage() + const offerId = (r as { offerId: string }).offerId + const row = stagingDb + .prepare(`SELECT staged_at, expires_at FROM wr_connect_offers WHERE offer_id = ?`) + .get(offerId) as { staged_at: string; expires_at: string } + const windowMs = Date.parse(row.expires_at) - Date.parse(row.staged_at) + expect(windowMs).toBe(7 * 24 * 60 * 60 * 1000) + + const after = new Date(Date.parse(row.expires_at) + 1000) + expect(expireStaleOffers(stagingDb, after)).toBe(1) + expect(getConsentableOffer(stagingDb, offerId)).toBeNull() + const prep = prepareFormationConsent({ offerId, actorWrdeskUserId: 'me' }) + expect(prep.ok).toBe(false) + }) + + it('preview is client-generated from verified material only (no counterparty free text)', () => { + const r = stageConnectOffer(stagingDb, { + handshake_id: 'hs-p4-ft', + capsule: { + ...CAPSULE, + handshake_id: 'hs-p4-ft', + free_text_message: 'CLICK HERE — totally trustworthy counterparty prose', + }, + capsule_hash: 'hash-p4-ft', + sender_email: 'peer@example.com', + profile_id: 'private_personal', + ingress_path: 'beap_invitation', + verification: { ok: true }, + }) + const offer = getConsentableOffer(stagingDb, (r as { offerId: string }).offerId) as ConnectOfferRow + const preview = buildConnectOfferPreview(offer) + expect(JSON.stringify(preview.preview)).not.toContain('CLICK HERE') + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4SilentRevocation.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4SilentRevocation.acceptance.test.ts new file mode 100644 index 000000000..27fa7736d --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase4SilentRevocation.acceptance.test.ts @@ -0,0 +1,144 @@ +/** + * Phase 4 — Silent revocation (V5) [VII.10.7.2–7.4] — acceptance test 5. + * + * A. Revocation produces NO outbound capsule, bounce, or counterparty-visible + * state change: the outbound queue stays empty and revocation.ts is + * structurally free of capsule-building/enqueueing code. + * B. Post-revocation inbound transmissions die pre-visibility at the + * receiver-side ingress filter with a logged record — this is the sole + * enforcement, and it is exactly why old-build peers with a zombie ACTIVE + * record are acceptable (their sends are killed here). + * C. Q8: history/evidence survives revocation — context blocks, embeddings, + * and audit rows all persist. Content deletion is a SEPARATE explicit + * operator action (`deleteRevokedRelationshipContent`), valid only on an + * already-revoked relationship, and it never deletes audit rows. + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { migrateHandshakeTables, insertHandshakeRecord } from '../db' +import { revokeHandshake, deleteRevokedRelationshipContent } from '../revocation' +import { admitInboundDelivery } from '../ingressAdmission' +import { HandshakeState } from '../types' +import { buildActiveHandshakeRecord } from './helpers' + +const HS = 'hs-001' // buildActiveHandshakeRecord default id + +function makeDb(): any { + const db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + return db +} + +function seedContent(db: any, handshakeId: string): void { + db.prepare( + `INSERT INTO context_blocks + (sender_wrdesk_user_id, block_id, block_hash, relationship_id, handshake_id, + type, data_classification, version, source, payload, created_at) + VALUES ('sender-user-001', 'blk-1', 'hash-1', 'rel-001', ?, 'note', 'public', 1, + 'received', '{"t":"payload"}', '2025-01-01T00:00:00.000Z')`, + ).run(handshakeId) + db.prepare( + `INSERT INTO context_embeddings + (sender_wrdesk_user_id, block_id, block_hash, embedding, model_id, created_at) + VALUES ('sender-user-001', 'blk-1', 'hash-1', ?, 'model-1', '2025-01-01T00:00:00.000Z')`, + ).run(Buffer.from([1, 2, 3])) +} + +const count = (db: any, table: string): number => + (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n + +describe('Phase 4 — silent revocation (V5)', () => { + let db: any + + beforeEach(() => { + db = makeDb() + insertHandshakeRecord(db, buildActiveHandshakeRecord()) + seedContent(db, HS) + }) + + it('A: revocation enqueues no outbound capsule and marks REVOKED', async () => { + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + + const row = db.prepare('SELECT state, revocation_source FROM handshakes WHERE handshake_id=?').get(HS) + expect(row.state).toBe(HandshakeState.REVOKED) + expect(row.revocation_source).toBe('local-user') + + // No peer notification of any kind: nothing entered the outbound queue. + expect(count(db, 'outbound_capsule_queue')).toBe(0) + }) + + it('A (structural): revocation.ts contains no capsule-building or enqueue path', () => { + const src = readFileSync(join(__dirname, '..', 'revocation.ts'), 'utf8') + for (const forbidden of [ + 'buildRevokeCapsule', + 'enqueueOutboundCapsule', + 'processOutboundQueue', + 'getEffectiveRelayEndpoint', + 'internalRelayCapsuleWireOptsFromRecord', + ]) { + expect(src.includes(forbidden), `revocation.ts must not reference ${forbidden}`).toBe(false) + } + }) + + it('B: post-revocation inbound dies pre-visibility with a logged record (zombie-peer case)', async () => { + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + + // An old-build peer still holds a zombie ACTIVE record and keeps sending. + for (const kind of ['beap_message', 'handshake_capsule'] as const) { + const r = admitInboundDelivery(db, { handshakeId: HS, kind, source: 'relay_pull' }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('relationship_revoked') + } + + const blocked = db + .prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE action='INGRESS_ADMISSION_BLOCKED' AND handshake_id=?`) + .get(HS) as { n: number } + expect(blocked.n).toBe(2) + }) + + it('C: evidence and content survive revocation (Q8)', async () => { + const auditBefore = count(db, 'audit_log') + + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + + expect(count(db, 'context_blocks')).toBe(1) + expect(count(db, 'context_embeddings')).toBe(1) + // Audit only grows (revocation entry added), never shrinks. + expect(count(db, 'audit_log')).toBe(auditBefore + 1) + }) + + it('C: content deletion is a separate explicit operator action, revoked-only', async () => { + // Refused while the relationship is still ACTIVE. + const early = deleteRevokedRelationshipContent(db, HS, 'local-user-001') + expect(early.ok).toBe(false) + if (!early.ok) expect(early.reason).toBe('not_revoked') + expect(count(db, 'context_blocks')).toBe(1) + + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + const auditAfterRevoke = count(db, 'audit_log') + + const r = deleteRevokedRelationshipContent(db, HS, 'local-user-001') + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.blocks_deleted).toBe(1) + expect(r.embeddings_deleted).toBe(1) + } + expect(count(db, 'context_blocks')).toBe(0) + expect(count(db, 'context_embeddings')).toBe(0) + // Evidence persists: audit rows never deleted; the explicit action logs itself. + expect(count(db, 'audit_log')).toBe(auditAfterRevoke + 1) + + expect(deleteRevokedRelationshipContent(db, 'hs-missing').ok).toBe(false) + }) + + it('idempotent: second revoke is a no-op', async () => { + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + const auditAfter = count(db, 'audit_log') + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + expect(count(db, 'audit_log')).toBe(auditAfter) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase5GrantsEvidence.acceptance.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase5GrantsEvidence.acceptance.test.ts new file mode 100644 index 000000000..b12ed3d88 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/phase5GrantsEvidence.acceptance.test.ts @@ -0,0 +1,543 @@ +/** + * Phase 5 — Grants & Evidence acceptance tests. + * + * 1. No execution without consent tap + structural absence (no standing + * granted-tools set, no bypass API, no auto-accept control) + * 2. Intent-Hash validity [IX.19.2] — covered in depth in + * execution/__tests__/executeToolRequest.test.ts; deviation re-asserted here + * 3. Receiver-enforced scoping [VII.10.2–10.3] + * 4. Limit-extension criticality [VII.10.8.3] + * 5. Tier-L chain [IX.19.1] + * 7. Revocation history (Q8) + * + * (6 — token forward-compatibility — lives in + * packages/ingestion-core/__tests__/capabilityToken.test.ts.) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' +import Database from 'better-sqlite3' +import { migrateHandshakeTables, insertHandshakeRecord, deleteHandshakeRecord } from '../db' +import { admitInboundDelivery } from '../ingressAdmission' +import { + createGrant, + listGrants, + resolveActiveDeliveryGrant, + resolveDeliveryGrantAt, + offScopeRevokeOfferDue, + countOffScopeEvents, + OFFSCOPE_REVOKE_OFFER_THRESHOLD, +} from '../grants' +import { + setEvidenceDbProvider, + appendEvidenceRecord, + listEvidenceRecords, + verifyEvidenceChain, +} from '../evidenceChain' +import { revokeHandshake, deleteRevokedRelationshipContent } from '../revocation' +import { HandshakeState } from '../types' +import { buildActiveHandshakeRecord, buildEffectivePolicy } from './helpers' + +const HS = 'hs-001' + +let db: InstanceType + +beforeEach(() => { + db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + setEvidenceDbProvider(() => db) +}) + +afterEach(() => { + setEvidenceDbProvider(null) + try { db.close() } catch { /* noop */ } +}) + +function insertActive(scopes: string[] = ['*']): void { + insertHandshakeRecord( + db, + buildActiveHandshakeRecord({ effective_policy: buildEffectivePolicy({ allowedScopes: scopes }) }), + ) +} + +// ── Shared production-source scanner ───────────────────────────────────────── + +const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..', '..') +const PRODUCTION_ROOTS = [ + join(REPO_ROOT, 'apps', 'electron-vite-project', 'electron'), + join(REPO_ROOT, 'apps', 'electron-vite-project', 'src'), + join(REPO_ROOT, 'apps', 'extension-chromium', 'src'), + join(REPO_ROOT, 'packages'), +] + +function* productionSources(): Generator<{ rel: string; text: string }> { + const walk = function* (dir: string): Generator { + let entries: string[] + try { entries = readdirSync(dir) } catch { return } + for (const entry of entries) { + if ( + entry === 'node_modules' || entry === 'dist' || entry === 'dist-electron' || + entry === '__tests__' || entry === '.git' || entry === 'coverage' + ) continue + const p = join(dir, entry) + let st + try { st = statSync(p) } catch { continue } + if (st.isDirectory()) { yield* walk(p); continue } + if (!/\.(ts|tsx)$/.test(entry) || /\.(test|spec)\./.test(entry) || entry.endsWith('.d.ts')) continue + yield p + } + } + for (const root of PRODUCTION_ROOTS) { + for (const p of walk(root)) { + yield { rel: p.split(sep).join('/').slice(REPO_ROOT.length + 1), text: readFileSync(p, 'utf8') } + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Acceptance 1 — no execution without consent tap [VII.10.1 / VII.14.6] +// ═════════════════════════════════════════════════════════════════════════════ + +describe('acceptance 1 — execution grants deleted, per-tap consent only', () => { + it('structural absence: no standing granted-tools set anywhere in production code', () => { + const offenders: string[] = [] + for (const f of productionSources()) { + if (/GRANTED_TOOLS/.test(f.text)) offenders.push(f.rel) + } + expect(offenders).toEqual([]) + }) + + it('structural absence: no auto-accept / bypass control on the consent path', () => { + const offenders: string[] = [] + for (const f of productionSources()) { + const lines = f.text.split('\n') + lines.forEach((line, i) => { + if (/skipConsent|auto_?accept|autoApprove|batch_?approve/i.test(line)) { + offenders.push(`${f.rel}:${i + 1}: ${line.trim()}`) + } + }) + } + expect(offenders).toEqual([]) + }) + + it('structural: the consent store has a single writer (executionConsent.ts) and a single consumer (executeToolRequest.ts)', () => { + for (const f of productionSources()) { + if (f.text.includes('wr_execution_consents')) { + expect(f.rel).toBe('apps/electron-vite-project/electron/main/execution/executionConsent.ts') + } + if (/consumeExecutionConsent\s*\(/.test(f.text) && !f.rel.endsWith('execution/executionConsent.ts')) { + expect(f.rel).toBe('apps/electron-vite-project/electron/main/execution/executeToolRequest.ts') + } + } + }) + + it('every execution path requires a fresh consent record with Intent Hash and produces a PoAE record', async () => { + // Behavioral depth lives in executeToolRequest.test.ts; assert the gate + // shape here: no consent_ref → CONSENT_REQUIRED before any handler lookup. + insertActive() + const { executeToolRequest } = await import('../../execution/executeToolRequest') + const refused = await executeToolRequest(db, { + request_id: 'req-a1', + handshake_id: HS, + tool_name: 'anything', + parameters: {}, + requested_at: new Date().toISOString(), + origin: 'local_ui', + }) + expect(refused.success).toBe(false) + if (!refused.success) expect(refused.reason).toBe('CONSENT_REQUIRED') + expect(listEvidenceRecords(db, HS).filter((r) => r.record_type === 'poae')).toEqual([]) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// Acceptance 3 — receiver-enforced scoping [VII.10.2–10.3] +// ═════════════════════════════════════════════════════════════════════════════ + +describe('acceptance 3 — receiver-enforced grant scoping', () => { + it('off-scope delivery is blocked pre-visibility, logged, and surfaces a revoke offer after repetition', () => { + insertActive(['availability']) + const g = createGrant(db, { + handshakeId: HS, + grantType: 'delivery', + direction: 'inbound', + scopes: ['availability'], + consentId: 'consent-g1', + }) + expect(g.ok).toBe(true) + + for (let i = 0; i < OFFSCOPE_REVOKE_OFFER_THRESHOLD; i++) { + const r = admitInboundDelivery(db, { + handshakeId: HS, + kind: 'beap_message', + source: 'relay_pull', + scope: 'finances', + }) + expect(r.admitted).toBe(false) + if (!r.admitted) expect(r.reason).toBe('grant_scope_violation') + } + + // Pre-visibility death leaves a logged record … + const blocked = db + .prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE action = 'INGRESS_ADMISSION_BLOCKED' AND handshake_id = ?`) + .get(HS) as { n: number } + expect(blocked.n).toBe(OFFSCOPE_REVOKE_OFFER_THRESHOLD) + // … evidence records for the blocked admissions … + const poacBlocks = listEvidenceRecords(db, HS) + .filter((r) => r.record_type === 'poac') + .filter((r) => JSON.parse(r.payload_json).kind === 'admission') + expect(poacBlocks.length).toBe(OFFSCOPE_REVOKE_OFFER_THRESHOLD) + // … and repetition surfaces the one-tap revoke offer [VII.10.2]. + expect(countOffScopeEvents(db, HS)).toBe(OFFSCOPE_REVOKE_OFFER_THRESHOLD) + expect(offScopeRevokeOfferDue(db, HS)).toBe(true) + }) + + it('in-scope delivery is admitted and carries the grant reference [VII.10.3]', () => { + insertActive(['availability']) + const g = createGrant(db, { + handshakeId: HS, + grantType: 'delivery', + direction: 'inbound', + scopes: ['availability'], + consentId: 'consent-g1', + }) + expect(g.ok).toBe(true) + if (!g.ok) return + + const r = admitInboundDelivery(db, { + handshakeId: HS, + kind: 'beap_message', + source: 'relay_pull', + scope: 'availability', + }) + expect(r.admitted).toBe(true) + if (r.admitted) expect(r.grantRef).toBe(g.grant.grant_id) + + // Every delivered item resolves its grant reference (read-time resolver + // for rows without a stored ref). + const resolved = resolveDeliveryGrantAt(db, HS, new Date().toISOString()) + expect(resolved?.grant_id).toBe(g.grant.grant_id) + }) + + it('legacy relationships are lazily backfilled from the flattened policy — never a fabricated consent', () => { + insertActive(['availability', 'projects']) + // No grant rows yet (pre-Phase-5 relationship). + expect(listGrants(db, HS)).toEqual([]) + + const r = admitInboundDelivery(db, { handshakeId: HS, kind: 'beap_message', source: 'email' }) + expect(r.admitted).toBe(true) + + const grants = listGrants(db, HS) + expect(grants.length).toBe(1) + expect(grants[0].backfilled).toBe(1) + expect(grants[0].consent_id).toBeNull() + expect(JSON.parse(grants[0].scopes_json)).toEqual(['availability', 'projects']) + }) + + it('a consented formation creates the initial delivery grant behind the consent event', () => { + insertHandshakeRecord(db, buildActiveHandshakeRecord(), { + profile_id: 'private_personal', + profile_version: 1, + ingress_path: 'beap_invitation', + capture_method: 'assisted_email', + source_reference: null, + consent_id: 'consent-form-1', + nonce: 'n-1', + }) + const grants = listGrants(db, HS) + expect(grants.length).toBe(1) + expect(grants[0].grant_type).toBe('delivery') + expect(grants[0].consent_id).toBe('consent-form-1') + expect(grants[0].backfilled).toBe(0) + + // PoAC evidence: formation + grant creation on the contract chain. + const kinds = listEvidenceRecords(db, HS).map((r) => ({ + type: r.record_type, + kind: JSON.parse(r.payload_json).kind ?? JSON.parse(r.payload_json).note?.slice(0, 7), + })) + expect(kinds.some((k) => k.type === 'poac' && k.kind === 'formation')).toBe(true) + expect(kinds.some((k) => k.type === 'poac' && k.kind === 'grant_created')).toBe(true) + }) + + it('the grant type system has no execute variant (structural)', () => { + const src = readFileSync(join(__dirname, '..', 'grants.ts'), 'utf8') + expect(src).toMatch(/GrantType = 'delivery' \| 'preparation'/) + expect(src.includes("'execute'")).toBe(false) + const bad = createGrant(db, { + handshakeId: HS, + grantType: 'execute' as any, + scopes: [], + consentId: 'c', + }) + expect(bad.ok).toBe(false) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// Acceptance 4 — limit-extension criticality [VII.10.8.3] +// ═════════════════════════════════════════════════════════════════════════════ + +describe('acceptance 4 — limit-extension criticality', () => { + it('a grant carrying an ununderstood limit extension is refused, never accepted as unlimited', () => { + insertActive() + const r = createGrant(db, { + handshakeId: HS, + grantType: 'delivery', + scopes: ['availability'], + limitExtensions: [{ ns: 'optirando.grant.max_invocations', payload: { n: 3 } }], + consentId: 'consent-x', + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('ununderstood_limit_extension') + expect(listGrants(db, HS)).toEqual([]) + }) + + it('absence of limit extensions = unlimited-until-revoke ground state', () => { + insertActive() + const r = createGrant(db, { + handshakeId: HS, + grantType: 'delivery', + scopes: ['availability'], + consentId: 'consent-y', + }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.grant.limit_extensions_json).toBeNull() + expect(r.grant.revoked_at).toBeNull() + } + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// Acceptance 5 — Tier-L chain [IX.19.1] +// ═════════════════════════════════════════════════════════════════════════════ + +describe('acceptance 5 — Tier-L evidence chain', () => { + function seedChain(n: number): void { + for (let i = 0; i < n; i++) { + appendEvidenceRecord(db, { + chainId: HS, + recordType: 'poac', + payload: { kind: 'admission', i }, + }) + } + } + + it('chain starts with an explicit genesis record referencing the cutover timestamp', () => { + seedChain(1) + const rows = listEvidenceRecords(db, HS) + expect(rows[0].seq).toBe(0) + expect(rows[0].record_type).toBe('genesis') + const genesis = JSON.parse(rows[0].payload_json) + expect(typeof genesis.cutover_at).toBe('string') + expect(genesis.note).toContain('no continuity is claimed for pre-cutover records') + expect(verifyEvidenceChain(db, HS)).toEqual({ valid: true, length: 2 }) + }) + + it('per-contract sequence is strictly monotonic and contiguous', () => { + seedChain(5) + const rows = listEvidenceRecords(db, HS) + expect(rows.map((r) => r.seq)).toEqual([0, 1, 2, 3, 4, 5]) + expect(verifyEvidenceChain(db, HS).valid).toBe(true) + }) + + it('the store is append-only: UPDATE and DELETE are aborted by trigger', () => { + seedChain(2) + expect(() => + db.prepare(`UPDATE wr_evidence_chain SET payload_json = '{}' WHERE chain_id = ? AND seq = 1`).run(HS), + ).toThrow(/append-only/) + expect(() => + db.prepare(`DELETE FROM wr_evidence_chain WHERE chain_id = ? AND seq = 1`).run(HS), + ).toThrow(/append-only/) + }) + + // Note: verifyEvidenceChain re-ensures the schema (and thus the guard + // triggers), so an attacker simulation must re-drop them before each + // tampering step — exactly what raw file access would allow. + const dropGuards = (): void => { + db.exec( + 'DROP TRIGGER IF EXISTS trg_wr_evidence_no_update; DROP TRIGGER IF EXISTS trg_wr_evidence_no_delete;', + ) + } + + it('removal, reorder, or insertion of a post-genesis record is detected', () => { + seedChain(4) + + // Removal → sequence gap. + dropGuards() + db.prepare(`DELETE FROM wr_evidence_chain WHERE chain_id = ? AND seq = 2`).run(HS) + let v = verifyEvidenceChain(db, HS) + expect(v.valid).toBe(false) + if (!v.valid) expect(v.reason).toBe('sequence_gap') + + // Insertion (re-adding a forged record in the gap) → hash mismatch. + dropGuards() + db.prepare( + `INSERT INTO wr_evidence_chain (chain_id, seq, record_type, payload_json, prev_hash, record_hash, created_at) + VALUES (?, 2, 'poac', '{"forged":true}', ?, ?, '2026-01-01T00:00:00Z')`, + ).run(HS, 'f'.repeat(64), 'deadbeef'.repeat(8)) + v = verifyEvidenceChain(db, HS) + expect(v.valid).toBe(false) + if (!v.valid) expect(['prev_hash_mismatch', 'record_hash_mismatch']).toContain(v.reason) + + // Reorder (swap payloads of two records) → hash mismatch. + dropGuards() + db.prepare(`DELETE FROM wr_evidence_chain WHERE chain_id = ? AND seq = 2`).run(HS) + const r3 = db.prepare(`SELECT * FROM wr_evidence_chain WHERE chain_id = ? AND seq = 3`).get(HS) as any + const r4 = db.prepare(`SELECT * FROM wr_evidence_chain WHERE chain_id = ? AND seq = 4`).get(HS) as any + dropGuards() + db.prepare(`UPDATE wr_evidence_chain SET payload_json = ? WHERE chain_id = ? AND seq = 3`).run(r4.payload_json, HS) + db.prepare(`UPDATE wr_evidence_chain SET payload_json = ? WHERE chain_id = ? AND seq = 4`).run(r3.payload_json, HS) + v = verifyEvidenceChain(db, HS) + expect(v.valid).toBe(false) + }) + + it('tampering with a record body is detected (record hash covers payload)', () => { + seedChain(3) + dropGuards() + db.prepare(`UPDATE wr_evidence_chain SET payload_json = '{"kind":"admission","i":999}' WHERE chain_id = ? AND seq = 2`).run(HS) + const v = verifyEvidenceChain(db, HS) + expect(v.valid).toBe(false) + if (!v.valid) expect(v.reason).toBe('record_hash_mismatch') + }) + + it('audit_log rows are read-only: UPDATE and DELETE are refused (forensic freeze)', () => { + insertActive() + db.prepare( + `INSERT INTO audit_log (timestamp, action, handshake_id) VALUES (?, 'TEST_ROW', ?)`, + ).run(new Date().toISOString(), HS) + expect(() => db.prepare(`UPDATE audit_log SET action = 'TAMPERED' WHERE handshake_id = ?`).run(HS)).toThrow( + /read-only/, + ) + expect(() => db.prepare(`DELETE FROM audit_log WHERE handshake_id = ?`).run(HS)).toThrow(/read-only/) + }) + + it('pre-cutover audit_log rows are outside the chain and survive relationship deletion', async () => { + insertActive() + seedChain(2) + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + const auditBefore = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n + expect(auditBefore).toBeGreaterThan(0) + + const del = deleteHandshakeRecord(db, HS) + expect(del.success).toBe(true) + + // H1 hygiene: audit rows are NEVER deleted with the relationship. + const auditAfter = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n + expect(auditAfter).toBe(auditBefore) + // Evidence chain untouched by relationship deletion. + expect(verifyEvidenceChain(db, HS).valid).toBe(true) + }) + + it('structural: retention has an explicit carve-out excluding the chain (H5)', async () => { + const { RETENTION_TABLES, RETENTION_EXCLUDED_TABLES } = await import('../../retention/retentionJob') + expect(RETENTION_EXCLUDED_TABLES).toContain('wr_evidence_chain') + expect(RETENTION_EXCLUDED_TABLES).toContain('audit_log') + expect(RETENTION_TABLES).not.toContain('wr_evidence_chain') + const src = readFileSync( + join(__dirname, '..', '..', 'retention', 'retentionJob.ts'), + 'utf8', + ) + expect(src.includes('DELETE FROM wr_evidence_chain')).toBe(false) + expect(src.includes('DELETE FROM audit_log')).toBe(false) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// Acceptance 7 — revocation history (Q8) +// ═════════════════════════════════════════════════════════════════════════════ + +describe('acceptance 7 — revocation kills rights, evidence survives', () => { + it('revoke → all grants dead via receiver filter; evidence + digests intact', async () => { + insertActive(['availability']) + const g = createGrant(db, { + handshakeId: HS, + grantType: 'delivery', + scopes: ['availability'], + consentId: 'consent-g1', + }) + expect(g.ok).toBe(true) + + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + + // Rights dead: no active grant remains. + expect(resolveActiveDeliveryGrant(db, HS)).toBeNull() + const grants = listGrants(db, HS) + expect(grants.every((x) => x.revoked_at !== null)).toBe(true) + + // Receiver filter blocks (relationship_revoked precedes grant checks). + const r = admitInboundDelivery(db, { handshakeId: HS, kind: 'beap_message', source: 'relay_pull' }) + expect(r.admitted).toBe(false) + + // Evidence: grant_created + grant_revoked PoAC records on an intact chain. + const kinds = listEvidenceRecords(db, HS) + .filter((x) => x.record_type === 'poac') + .map((x) => JSON.parse(x.payload_json).kind) + expect(kinds).toContain('grant_created') + expect(kinds).toContain('grant_revoked') + expect(verifyEvidenceChain(db, HS).valid).toBe(true) + }) + + it('separate content-deletion action exists and is PoAC-recorded', async () => { + insertActive() + db.prepare( + `INSERT INTO context_blocks + (sender_wrdesk_user_id, block_id, block_hash, relationship_id, handshake_id, + type, data_classification, version, source, payload, created_at) + VALUES ('sender-user-001', 'blk-1', 'hash-1', 'rel-001', ?, 'note', 'public', 1, + 'received', '{"t":"payload"}', '2025-01-01T00:00:00.000Z')`, + ).run(HS) + + await revokeHandshake(db, HS, 'local-user', 'local-user-001') + const r = deleteRevokedRelationshipContent(db, HS, 'local-user-001') + expect(r.ok).toBe(true) + + const kinds = listEvidenceRecords(db, HS) + .filter((x) => x.record_type === 'poac') + .map((x) => JSON.parse(x.payload_json).kind) + expect(kinds).toContain('revoked_content_deleted') + expect(verifyEvidenceChain(db, HS).valid).toBe(true) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// Ledger repurposing (Q10) — structural +// ═════════════════════════════════════════════════════════════════════════════ + +describe('Q10 — ledger as Tier-L evidence home', () => { + it('the evidence store is the only production writer on wr_evidence_chain', () => { + for (const f of productionSources()) { + if (/INSERT INTO wr_evidence_chain/.test(f.text)) { + expect(f.rel).toBe('apps/electron-vite-project/electron/main/handshake/evidenceChain.ts') + } + } + }) + + it('BER record class is representable now; writers arrive in Phase 6', () => { + const r = appendEvidenceRecord(db, { + chainId: 'wr:local', + recordType: 'ber', + payload: { + kind: 'boundary_crossing', + governing_ref: 'esa-placeholder', + governing_version: 1, + direction: 'egress', + capability: 'test', + data_class_digests: [], + counterparty: 'svc', + channel: 'https', + decision_ref: 'dec-1', + }, + }) + expect(r.ok).toBe(true) + expect(verifyEvidenceChain(db, 'wr:local').valid).toBe(true) + // No production BER writer yet (Phase 6). + for (const f of productionSources()) { + if (/recordType:\s*'ber'/.test(f.text)) { + expect(f.rel).toBe('apps/electron-vite-project/electron/main/handshake/evidenceChain.ts') + } + } + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/postAcceptContextSync.ingestPaths.regression.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/postAcceptContextSync.ingestPaths.regression.test.ts index 5603027a1..24d25d66b 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/postAcceptContextSync.ingestPaths.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/postAcceptContextSync.ingestPaths.regression.test.ts @@ -28,7 +28,8 @@ import { createHandshakeTestDb } from './handshakeTestDb' import { migrateIngestionTables } from '../../ingestion/persistenceDb' import { handleIngestionRPC } from '../../ingestion/ipc' import { getHandshakeRecord } from '../db' -import { persistInitiatorHandshakeRecord } from '../initiatorPersist' +import { formInitiatorRelationship, setConnectOfferDbProvider } from '../formationPipeline' +import Database from 'better-sqlite3' import { processCoordinationInboundCapsuleForTest } from '../../p2p/coordinationWs' import { getNextStateAfterInboundContextSync } from '../contextSyncActiveGate' import type { SSOSession } from '../types' @@ -73,26 +74,35 @@ describe('post-accept initial context_sync — ingest path regressions', () => { let senderDb: ReturnType let receiverDb: ReturnType let vaultStatusSpy: ReturnType + let stagingDb: any beforeEach(() => { senderDb = createHandshakeTestDb() receiverDb = createHandshakeTestDb() migrateIngestionTables(senderDb) migrateIngestionTables(receiverDb) + stagingDb = new Database(':memory:') + setConnectOfferDbProvider(() => stagingDb) vaultStatusSpy = vi.spyOn(vaultService, 'getStatus').mockReturnValue({ isUnlocked: true } as any) }) afterEach(() => { vaultStatusSpy.mockRestore() + setConnectOfferDbProvider(null) + try { stagingDb?.close() } catch { /* noop */ } }) - /** Two machines: initiator row on senderDb via persistInitiatorHandshakeRecord; receiver ingests initiate on receiverDb. */ + /** Two machines: initiator row on senderDb via the ONE formation pipeline; receiver ingests initiate on receiverDb (staged as a Connect offer). */ async function seedCrossPrincipalInitiate(sender: SSOSession, receiver: SSOSession) { const { capsule: initiate, keypair } = buildInitiateCapsuleWithKeypair(sender, { receiverUserId: receiver.wrdesk_user_id, receiverEmail: receiver.email, }) - const persisted = persistInitiatorHandshakeRecord(senderDb, initiate, sender, [], keypair) + const persisted = formInitiatorRelationship(senderDb, initiate, sender, [], keypair, { + capture_method: 'assisted_email', + ingress_path: 'beap_invitation', + source_reference: receiver.email, + }) expect(persisted.success).toBe(true) const recvRes = await submitCapsuleJson(JSON.stringify(initiate), receiverDb, receiver) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/regressionMatrix.relayHandshakeSandbox.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/regressionMatrix.relayHandshakeSandbox.test.ts index 7ce1ca2c9..dc4eb7727 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/regressionMatrix.relayHandshakeSandbox.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/regressionMatrix.relayHandshakeSandbox.test.ts @@ -16,7 +16,7 @@ * Constraints: no tests here mutate X25519/ML-KEM agreement, ACTIVE gate invariants, or accept/build capsule crypto. */ -import { describe, test, expect } from 'vitest' +import { describe, test, expect, vi } from 'vitest' import { mapSendResultToQueueOutcome, type SendCapsuleSuccessShape, @@ -30,6 +30,11 @@ import { getNextStateAfterInboundContextSync } from '../contextSyncActiveGate' import { assertVaultOwnerMatchesSession, VAULT_ACCOUNT_ERROR } from '../../vault/vaultOwnerIdentity' import { extractBeapRedirectSourceFromRow } from '../../email/beapRedirectSource' +vi.mock('../../orchestrator/orchestratorModeStore', async (importOriginal) => { + const a = await importOriginal() + return { ...a, getInstanceId: () => 'dev-host-1' } +}) + const sessionUserA: SSOSession = { wrdesk_user_id: 'user-a', email: 'a@example.com', @@ -54,16 +59,28 @@ function baseInternalHostSandboxRecord(overrides: Partial = {}) return { handshake_id: 'hs-sbx-1', state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, local_role: 'initiator', initiator_device_role: 'host', acceptor_device_role: 'sandbox', + initiator_coordination_device_id: 'dev-host-1', + acceptor_coordination_device_id: 'dev-sand-1', internal_coordination_identity_complete: true, p2p_endpoint: 'https://coord.example/beap', local_x25519_public_key_b64: 'dGVzdC1sb2NhbC14MjU1MTktcHViLWtleQ==', relationship_id: 'rel-1', - initiator: { email: 'a@example.com', wrdesk_user_id: 'user-a' }, - acceptor: { email: 'a@example.com', wrdesk_user_id: 'user-a' }, + initiator: { + email: 'a@example.com', + wrdesk_user_id: 'user-a', + iss: 'https://id.example', + sub: 'sub-a', + }, + acceptor: { + email: 'a@example.com', + wrdesk_user_id: 'user-a', + iss: 'https://id.example', + sub: 'sub-a', + }, ...overrides, } as HandshakeRecord } @@ -125,7 +142,7 @@ describe('regressionMatrix — same-principal skip (§7)', () => { computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: 'x', - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'DEV-A', localDeviceId: 'DEV-A', }), @@ -134,7 +151,7 @@ describe('regressionMatrix — same-principal skip (§7)', () => { computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: 'x', - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'DEV-SANDBOX', localDeviceId: 'DEV-HOST', }), diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/revokeRepair.rig.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/revokeRepair.rig.test.ts index d17172e29..5d438f022 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/revokeRepair.rig.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/revokeRepair.rig.test.ts @@ -15,7 +15,7 @@ * Run under Electron's Node ABI: `pnpm test:native-db `. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' import Database from 'better-sqlite3' import { startRelayHarness, type RelayHarness } from './rig/coordinationRelayHarness' @@ -23,6 +23,7 @@ import { driveCrossPrincipalToActive } from './rig/pairingFlow' import { migrateHandshakeTables, deleteHandshakeRecord } from '../db' import { migrateIngestionTables } from '../../ingestion/persistenceDb' import { handleIngestionRPC } from '../../ingestion/ipc' +import { installInMemoryConnectOffers, uninstallInMemoryConnectOffers } from './connectOfferConsentTestKit' import { setEmailSendFn, _resetEmailSendFn } from '../emailTransport' import { buildRevokeCapsule } from '../capsuleBuilder' import { revokeHandshake } from '../revocation' @@ -63,8 +64,13 @@ describe('revoke → refused → re-pair (two real instances, real relay)', () = relay.resetState() _resetEmailSendFn() setEmailSendFn(vi.fn().mockResolvedValue({ success: true, messageId: 'm1' })) + // Phase 4 [IX.3.1]: pairingFlow drives initiates through the Connect-offer + // consent gate, which needs the in-memory staging store. + installInMemoryConnectOffers() }) + afterEach(() => uninstallInMemoryConnectOffers()) + function ingest(capsuleJson: string, db: any, asSession: SSOSession) { return handleIngestionRPC( 'ingestion.ingest', @@ -123,7 +129,7 @@ describe('revoke → refused → re-pair (two real instances, real relay)', () = }) // 2b. Bob revokes locally via the real production path. - await revokeHandshake(bobDb, hsId, 'local-user', bob.wrdesk_user_id, bob) + await revokeHandshake(bobDb, hsId, 'local-user', bob.wrdesk_user_id) const bobDiag = diagnoseHandshakeInactive(bobDb, hsId, now) expect(bobDiag.active).toBe(false) expect((bobDiag as { reason: string }).reason).toContain('REVOKED') diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/rig/pairingFlow.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/rig/pairingFlow.ts index c6418dff0..a996bc618 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/rig/pairingFlow.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/rig/pairingFlow.ts @@ -14,7 +14,7 @@ import { updateHandshakeCounterpartyKey, updateHandshakeContextSyncEnqueued, } from '../../db' -import { handleIngestionRPC } from '../../../ingestion/ipc' +import { submitCapsuleThroughConsentGate } from '../connectOfferConsentTestKit' import { buildInitiateCapsuleWithKeypair, buildAcceptCapsule, @@ -44,17 +44,11 @@ export interface PairToActiveResult { bobKeys: RigKeypair } +// Phase 4 [IX.3.1]: inbound initiates stage a Connect offer; the kit consents +// and re-runs the one pipeline behind the consent gate. Caller must install +// the in-memory staging store (connectOfferConsentTestKit). function ingest(capsuleJson: string, db: any, asSession: SSOSession) { - return handleIngestionRPC( - 'ingestion.ingest', - { - rawInput: { body: capsuleJson, mime_type: 'application/vnd.beap+json' }, - sourceType: 'email', - transportMeta: { channel_id: 'relay:test', mime_type: 'application/vnd.beap+json' }, - }, - db, - asSession, - ) + return submitCapsuleThroughConsentGate(capsuleJson, db, asSession, { channelId: 'relay:test' }) } async function relayPost(relay: RelayHarness, capsule: any, senderToken: string): Promise { diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/structuralAbsence.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/structuralAbsence.test.ts new file mode 100644 index 000000000..e09da00da --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/structuralAbsence.test.ts @@ -0,0 +1,76 @@ +/** + * Structural-absence checks — Phase 1 dead-path removal acceptance + * [VII.10.5.5]: no auto-accept / consent-skip control may be representable in + * schema or UI, and the removed dead paths must leave no references behind. + * + * Scans repository SOURCE files (not build artifacts, docs, or analysis + * reports) for the forbidden tokens. + */ +import { describe, test, expect } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'fs' +import { join, resolve } from 'path' +import { fileURLToPath } from 'url' + +const here = fileURLToPath(new URL('.', import.meta.url)) +const repoRoot = resolve(here, '../../../../../..') + +const SOURCE_ROOTS = [ + 'apps/electron-vite-project/electron', + 'apps/electron-vite-project/src', + 'apps/extension-chromium/src', + 'packages', +] + +const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|json|sql)$/ +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', 'out', '.git', 'coverage']) +// This test file names the forbidden tokens on purpose. +const SELF = 'structuralAbsence.test.ts' + +function* walk(dir: string): Generator { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry) || entry.startsWith('build0')) continue + const full = join(dir, entry) + let st + try { + st = statSync(full) + } catch { + continue + } + if (st.isDirectory()) { + yield* walk(full) + } else if (SOURCE_EXT.test(entry) && !full.endsWith(SELF)) { + yield full + } + } +} + +function findReferences(token: string): string[] { + const hits: string[] = [] + for (const root of SOURCE_ROOTS) { + for (const file of walk(join(repoRoot, root))) { + const content = readFileSync(file, 'utf8') + if (content.includes(token)) hits.push(file) + } + } + return hits +} + +describe('structural absence — Phase 1 dead-path removal', () => { + test('no skipConsentForAutomation anywhere in source [VII.10.5.5]', () => { + expect(findReferences('skipConsentForAutomation')).toEqual([]) + }) + + test('no verifyContextVersions (deleted no-op pipeline step, A12)', () => { + expect(findReferences('verifyContextVersions')).toEqual([]) + }) + + test('no handshakeVerification module references (deleted unused verifier, A11)', () => { + expect(findReferences('verifyHandshakeCapsule')).toEqual([]) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.delegationCallback.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.delegationCallback.test.ts index 84017c3e8..590bc5c4c 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.delegationCallback.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.delegationCallback.test.ts @@ -42,7 +42,7 @@ function activeInternalRecord( const isInitiator = true // local is always initiator in these tests for simplicity return { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, internal_coordination_identity_complete: true, local_role: isInitiator ? 'initiator' : 'acceptor', @@ -84,7 +84,7 @@ describe('topologyAutoWire — delegation callback (UX-1 D4)', () => { // Sandbox accepted: local_role=acceptor, acceptor=sandbox, initiator=host const record = { handshake_id: 'hs-xyz', - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, internal_coordination_identity_complete: true, local_role: 'acceptor', @@ -105,7 +105,7 @@ describe('topologyAutoWire — delegation callback (UX-1 D4)', () => { const record = { handshake_id: 'hs-pending', - handshake_type: 'internal', + same_principal: true, state: HandshakeState.INITIATED, internal_coordination_identity_complete: true, local_role: 'initiator', diff --git a/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.test.ts b/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.test.ts index e39499a49..ebc7a53b7 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.test.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/__tests__/topologyAutoWire.test.ts @@ -39,7 +39,7 @@ vi.mock('../db', () => ({ function makeRecord(overrides: Partial<{ handshake_id: string state: HandshakeState - handshake_type: string + same_principal: boolean local_role: 'initiator' | 'acceptor' initiator_device_role: 'host' | 'sandbox' | null acceptor_device_role: 'host' | 'sandbox' | null @@ -48,7 +48,7 @@ function makeRecord(overrides: Partial<{ return { handshake_id: 'hs-test-1', state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, local_role: 'initiator' as const, initiator_device_role: 'host' as const, acceptor_device_role: 'sandbox' as const, @@ -129,7 +129,7 @@ describe('topologyAutoWire — autoWireTopologyForHandshake', () => { const { autoWireTopologyForHandshake } = await import('../topologyAutoWire') const { addLinkedTopologyEntry } = await import('../../orchestrator/orchestratorModeStore') - autoWireTopologyForHandshake(makeRecord({ handshake_type: 'standard' })) + autoWireTopologyForHandshake(makeRecord({ same_principal: false })) expect(addLinkedTopologyEntry).not.toHaveBeenCalled() }) diff --git a/code/apps/electron-vite-project/electron/main/handshake/activeHandshakeHealth.ts b/code/apps/electron-vite-project/electron/main/handshake/activeHandshakeHealth.ts index baaf28683..46e3ba898 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/activeHandshakeHealth.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/activeHandshakeHealth.ts @@ -24,7 +24,7 @@ export function hasP2pTokenForLog(r: HandshakeRecord): boolean { } export function coordinationCompleteForLog(r: HandshakeRecord): boolean { - if (r.handshake_type === 'internal') { + if (r.same_principal === true) { return r.internal_coordination_identity_complete === true } return true diff --git a/code/apps/electron-vite-project/electron/main/handshake/aiProviders.ts b/code/apps/electron-vite-project/electron/main/handshake/aiProviders.ts index 26e99eaa9..8b579e82d 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/aiProviders.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/aiProviders.ts @@ -61,6 +61,11 @@ export interface GenerateChatOptions { * caller's client-side timeout. */ maxTokens?: number + /** + * Art. 50: optional out-param filled with provenance already attached at stream/chat + * aggregation (exactly once). Callers must not re-mint. + */ + provenanceOut?: { value?: import('../../../../../packages/shared/src/aiProvenance').AiProvenance } } export interface AIProvider { @@ -279,7 +284,9 @@ export class OllamaProvider implements AIProvider { autosortDiagLog('OllamaProvider.generateChat:stream-start', { model, promptCharsApprox: _pc }) } try { - return await streamOllamaChat(model, systemMsg, userMsg, send, this.baseUrl) + const streamed = await streamOllamaChat(model, systemMsg, userMsg, send, this.baseUrl) + if (options?.provenanceOut) options.provenanceOut.value = streamed.provenance + return streamed.content } catch (e: unknown) { logOllamaProviderError({ lane: this.lane, @@ -405,6 +412,18 @@ export class OllamaProvider implements AIProvider { if (extracted.usedReasoningFallback) { console.warn(`[LLM] reasoning_content_fallback model=${model} content_empty=true`) } + // Art. 50: when caller requests provenanceOut, attach exactly once here (non-stream path). + if (options?.provenanceOut) { + const { attachAndLogProvenance } = await import('../aiProvenance/attachProvenance') + const { extractUpstreamMarking } = await import('../../../../../packages/shared/src/aiProvenance/generate') + const attached = attachAndLogProvenance(extracted.content, { + model_id: model, + provider: 'local', + upstream_marking: extractUpstreamMarking(data), + }) + options.provenanceOut.value = attached.provenance + return attached.content + } return extracted.content } catch (e: unknown) { const name = e && typeof e === 'object' && 'name' in e ? String((e as Error).name) : '' @@ -575,21 +594,41 @@ export class CloudAIProvider implements AIProvider { } const signal = options?.signal + const provenanceOut = options?.provenanceOut if (provider === 'openai') { - return this._chatOpenAI(messages, model, apiKey, stream, send, signal, options?.temperature) + return this._chatOpenAI(messages, model, apiKey, stream, send, signal, options?.temperature, provenanceOut) } if (provider === 'anthropic') { - return this._chatAnthropic(messages, model, apiKey, stream, send, signal) + return this._chatAnthropic(messages, model, apiKey, stream, send, signal, provenanceOut) } if (provider === 'google') { - return this._chatGoogle(messages, model, apiKey, stream, send, signal) + return this._chatGoogle(messages, model, apiKey, stream, send, signal, provenanceOut) } if (provider === 'xai') { - return this._chatXai(messages, model, apiKey, stream, send, signal) + return this._chatXai(messages, model, apiKey, stream, send, signal, provenanceOut) } throw new Error(`Unsupported cloud provider: ${provider}`) } + private async _attachCloudProvenanceIfRequested( + content: string, + model: string, + providerLabel: import('../../../../../packages/shared/src/aiProvenance').AiProvenanceProvider, + provenanceOut: GenerateChatOptions['provenanceOut'], + upstreamBody?: unknown, + ): Promise { + if (!provenanceOut) return content + const { attachAndLogProvenance } = await import('../aiProvenance/attachProvenance') + const { extractUpstreamMarking } = await import('../../../../../packages/shared/src/aiProvenance/generate') + const attached = attachAndLogProvenance(content, { + model_id: model, + provider: providerLabel, + upstream_marking: extractUpstreamMarking(upstreamBody), + }) + provenanceOut.value = attached.provenance + return attached.content + } + private async _chatOpenAI( messages: Message[], model: string, @@ -598,12 +637,15 @@ export class CloudAIProvider implements AIProvider { send: StreamSender, signal?: AbortSignal, temperature?: number, + provenanceOut?: GenerateChatOptions['provenanceOut'], ): Promise { if (stream && send) { const { streamOpenAIChat } = await import('./llmStream') const systemMsg = messages.find(m => m.role === 'system')?.content ?? '' const userMsg = messages.find(m => m.role === 'user')?.content ?? '' - return streamOpenAIChat(model, systemMsg, userMsg, apiKey, send) + const streamed = await streamOpenAIChat(model, systemMsg, userMsg, apiKey, send) + if (provenanceOut) provenanceOut.value = streamed.provenance + return streamed.content } const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -617,7 +659,8 @@ export class CloudAIProvider implements AIProvider { }) if (!res.ok) throw new Error(`OpenAI ${res.status}: ${await res.text()}`) const data = await res.json() - return data.choices?.[0]?.message?.content ?? 'No response from model.' + const content = data.choices?.[0]?.message?.content ?? 'No response from model.' + return this._attachCloudProvenanceIfRequested(content, model, 'cloud:openai', provenanceOut, data) } private async _chatAnthropic( @@ -627,13 +670,16 @@ export class CloudAIProvider implements AIProvider { stream: boolean, send: StreamSender, signal?: AbortSignal, + provenanceOut?: GenerateChatOptions['provenanceOut'], ): Promise { const systemMsg = messages.find(m => m.role === 'system')?.content ?? '' const userMsg = messages.find(m => m.role === 'user')?.content ?? '' if (stream && send) { const { streamAnthropicChat } = await import('./llmStream') - return streamAnthropicChat(model, systemMsg, userMsg, apiKey, send) + const streamed = await streamAnthropicChat(model, systemMsg, userMsg, apiKey, send) + if (provenanceOut) provenanceOut.value = streamed.provenance + return streamed.content } const combined = systemMsg ? `${systemMsg}\n\n${userMsg}` : userMsg const res = await fetch('https://api.anthropic.com/v1/messages', { @@ -648,7 +694,8 @@ export class CloudAIProvider implements AIProvider { }) if (!res.ok) throw new Error(`Anthropic ${res.status}: ${await res.text()}`) const data = await res.json() - return data.content?.[0]?.text ?? 'No response from model.' + const content = data.content?.[0]?.text ?? 'No response from model.' + return this._attachCloudProvenanceIfRequested(content, model, 'cloud:anthropic', provenanceOut, data) } private async _chatGoogle( @@ -658,13 +705,16 @@ export class CloudAIProvider implements AIProvider { stream: boolean, send: StreamSender, signal?: AbortSignal, + provenanceOut?: GenerateChatOptions['provenanceOut'], ): Promise { const systemMsg = messages.find(m => m.role === 'system')?.content ?? '' const userMsg = messages.find(m => m.role === 'user')?.content ?? '' if (stream && send) { const { streamGoogleChat } = await import('./llmStream') - return streamGoogleChat(model, systemMsg, userMsg, apiKey, send) + const streamed = await streamGoogleChat(model, systemMsg, userMsg, apiKey, send) + if (provenanceOut) provenanceOut.value = streamed.provenance + return streamed.content } const combined = systemMsg ? `${systemMsg}\n\n${userMsg}` : userMsg const res = await fetch( @@ -681,7 +731,8 @@ export class CloudAIProvider implements AIProvider { ) if (!res.ok) throw new Error(`Gemini ${res.status}: ${await res.text()}`) const data = await res.json() - return data.candidates?.[0]?.content?.parts?.[0]?.text ?? 'No response from model.' + const content = data.candidates?.[0]?.content?.parts?.[0]?.text ?? 'No response from model.' + return this._attachCloudProvenanceIfRequested(content, model, 'cloud:gemini', provenanceOut, data) } private async _chatXai( @@ -691,12 +742,15 @@ export class CloudAIProvider implements AIProvider { stream: boolean, send: StreamSender, signal?: AbortSignal, + provenanceOut?: GenerateChatOptions['provenanceOut'], ): Promise { if (stream && send) { const { streamXaiChat } = await import('./llmStream') const systemMsg = messages.find(m => m.role === 'system')?.content ?? '' const userMsg = messages.find(m => m.role === 'user')?.content ?? '' - return streamXaiChat(model, systemMsg, userMsg, apiKey, send) + const streamed = await streamXaiChat(model, systemMsg, userMsg, apiKey, send) + if (provenanceOut) provenanceOut.value = streamed.provenance + return streamed.content } const res = await fetch('https://api.x.ai/v1/chat/completions', { method: 'POST', @@ -706,7 +760,8 @@ export class CloudAIProvider implements AIProvider { }) if (!res.ok) throw new Error(`xAI ${res.status}: ${await res.text()}`) const data = await res.json() - return data.choices?.[0]?.message?.content ?? 'No response from model.' + const content = data.choices?.[0]?.message?.content ?? 'No response from model.' + return this._attachCloudProvenanceIfRequested(content, model, 'cloud:xai', provenanceOut, data) } /** Check if embeddings are available (OpenAI key present). */ diff --git a/code/apps/electron-vite-project/electron/main/handshake/antiRollback.ts b/code/apps/electron-vite-project/electron/main/handshake/antiRollback.ts new file mode 100644 index 000000000..0f3b388a3 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/antiRollback.ts @@ -0,0 +1,113 @@ +/** + * Generic anti-rollback high-water store (Phase 2 — G4) [IX.4.2, X.7.8] + * + * One reusable high-water-version store keyed by (object class, object + * identity). A validly signed object whose version is BELOW the persisted + * high-water mark is rejected fail-closed as a rollback — signature validity + * never overrides version regression. Consumers arrive over Phases 3–6 + * (core-record versions, policies, admissions); the store and its semantics + * land now. + * + * ── Backup/restore semantics (decision, risk register) ────────────────────── + * The `wr_high_water_versions` table lives IN THE SAME DATABASE as the + * objects it guards (vault DB / ledger DB via the shared migration chain), + * inside the same WAL boundary. Consequences, by design: + * + * 1. Restoring an older DB snapshot restores objects AND their high-water + * marks together, coherently. Legitimate objects are therefore never + * mass-rejected after a restore (the mark travels with the data), which + * is the primary failure mode the risk register names. + * 2. The trade-off is stated, not hidden: a whole-DB restore IS a rollback + * of the guarded object classes, and the store intentionally cannot + * detect it from inside the restored file. Cross-restore rollback + * visibility requires an anchor OUTSIDE the backup boundary; that anchor + * is the hash-chained evidence store of Phase 5 (Tier-L chain, Q10) — + * recorded there as an open item, not silently claimed here [X.0.1 + * claims discipline: we do not claim rollback protection across + * operator-initiated whole-DB restores]. + * 3. A restore procedure therefore: (a) restores the DB file as a unit — + * never merges a foreign high-water table into a live DB; (b) treats the + * restored marks as authoritative from that point on; (c) leaves an + * operator audit_log entry (RESTORE_MARKER) so evidence readers can see + * the discontinuity. `recordRestoreMarker` implements (c). + * + * The restore scenario is exercised in antiRollback.test.ts. + */ + +export interface HighWaterAccept { + ok: true + /** True when this call raised (or created) the mark. */ + raised: boolean + highWater: number +} + +export interface HighWaterReject { + ok: false + reason: 'rollback' + highWater: number + presented: number +} + +export type HighWaterResult = HighWaterAccept | HighWaterReject + +/** + * Fail-closed high-water check-and-raise. Rejects versions strictly below + * the persisted mark; accepts equal versions (idempotent redelivery of the + * same object version is not a rollback) and raises on higher versions. + * The read+write runs in one transaction — single writer discipline. + */ +export function enforceHighWater( + db: any, + objectClass: string, + objectId: string, + version: number, +): HighWaterResult { + if (!Number.isSafeInteger(version) || version < 0) { + // Malformed version input is treated as a rollback attempt — fail closed. + return { ok: false, reason: 'rollback', highWater: Number.MAX_SAFE_INTEGER, presented: version } + } + const tx = db.transaction((): HighWaterResult => { + const row = db + .prepare( + 'SELECT high_water_version FROM wr_high_water_versions WHERE object_class = ? AND object_id = ?', + ) + .get(objectClass, objectId) as { high_water_version: number } | undefined + const current = row?.high_water_version + if (current !== undefined && version < current) { + return { ok: false, reason: 'rollback', highWater: current, presented: version } + } + if (current === undefined || version > current) { + db.prepare( + `INSERT INTO wr_high_water_versions (object_class, object_id, high_water_version, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(object_class, object_id) DO UPDATE SET + high_water_version = excluded.high_water_version, + updated_at = excluded.updated_at`, + ).run(objectClass, objectId, version, new Date().toISOString()) + return { ok: true, raised: true, highWater: version } + } + return { ok: true, raised: false, highWater: current } + }) + return tx() +} + +/** Read-only peek at the current mark (diagnostics / tests). */ +export function getHighWater(db: any, objectClass: string, objectId: string): number | null { + const row = db + .prepare( + 'SELECT high_water_version FROM wr_high_water_versions WHERE object_class = ? AND object_id = ?', + ) + .get(objectClass, objectId) as { high_water_version: number } | undefined + return row?.high_water_version ?? null +} + +/** + * Restore-procedure step (c): record the discontinuity in the audit log so + * evidence readers can distinguish an operator restore from silent rollback. + */ +export function recordRestoreMarker(db: any, detail: { restoredFrom: string; operator: string }): void { + db.prepare( + `INSERT INTO audit_log (timestamp, action, reason_code, metadata) + VALUES (?, 'HIGH_WATER_RESTORE_MARKER', 'operator_restore', ?)`, + ).run(new Date().toISOString(), JSON.stringify(detail)) +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/auditLog.ts b/code/apps/electron-vite-project/electron/main/handshake/auditLog.ts index 77e83e2c4..3ef95a567 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/auditLog.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/auditLog.ts @@ -6,11 +6,19 @@ import type { AuditLogEntry, VerifiedCapsuleInput, HandshakeRecord, ReasonCode } from './types' +/** + * Version-gated wire marker (Phase 2): capsules verified under legacy v≤2 + * rules are marked 'legacy_v2' in evidence records; capsules whose canonical + * v3 envelope verified are marked 'canonical_v3'. + */ +export type WireFormatMarker = 'legacy_v2' | 'canonical_v3' + export function buildSuccessAuditEntry( input: VerifiedCapsuleInput, record: HandshakeRecord, durationMs: number, blocksCount: number, + wireFormat: WireFormatMarker = 'legacy_v2', ): AuditLogEntry { return { timestamp: new Date().toISOString(), @@ -27,6 +35,7 @@ export function buildSuccessAuditEntry( sharing_mode: record.sharing_mode, state: record.state, seq: input.seq, + wire_format: wireFormat, }, } } @@ -36,6 +45,7 @@ export function buildDenialAuditEntry( reason: ReasonCode, failedStep: string, durationMs: number, + wireFormat: WireFormatMarker = 'legacy_v2', ): AuditLogEntry { return { timestamp: new Date().toISOString(), @@ -48,6 +58,7 @@ export function buildDenialAuditEntry( actor_wrdesk_user_id: input.sender_wrdesk_user_id, metadata: { seq: input.seq, + wire_format: wireFormat, }, } } diff --git a/code/apps/electron-vite-project/electron/main/handshake/canonicalCore.ts b/code/apps/electron-vite-project/electron/main/handshake/canonicalCore.ts new file mode 100644 index 000000000..72a51d0a6 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/canonicalCore.ts @@ -0,0 +1,509 @@ +/** + * Canonical core — THE hash/signature entry point for new-format (v3) + * handshake objects (Phase 2 — A8, A1–A7) [VII.3.1–3.2, VII.6.1.3]. + * + * Wire strategy (version-gated, dual-format): + * - Outbound capsules keep the complete legacy v2 surface (schema_version 2, + * subset capsule_hash + sender_signature) so old peers' allowlist rebuild + * keeps verifying what we send [risk register: cross-version handshakes]. + * - Additionally every outbound capsule carries `wr_canonical_v3`: a frozen + * signed core record [VII.3.1] whose declarations container embeds the + * COMPLETE capsule content under `optirando.decl.capsule`. Its signature + * covers the complete canonical form of the core (domain tag + * `wr.handshake.core` v3) — scopes, policy, tier signals, keys and routing + * included, which the v2 subset hash never covered (A8). + * - Receivers that understand v3 verify BOTH: legacy rules keep running + * (dedup, chain, pinning), then the canonical envelope is verified + * fail-closed. Capsules without an envelope verify under legacy rules + * alone and are marked `legacy` in evidence records. + * + * Large payload fields (context_blocks, context_blocks_sealed) are covered by + * their SHA-256 inside the signed declaration instead of byte-duplication, so + * the dual format stays inside the 64KB Gate-2 input cap. Full coverage is + * preserved: the hash binds the bytes. + * + * Countersignatures [Q3]: mode 'canonical_hash' signs the canonical-form + * hash under the same domain tag — both signatures cover the same referenced + * bytes. The ordered signature list lands now; per-profile cardinality + * enforcement arrives with the profile registry (Phase 3). + */ + +import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto' +import { + canonicalJsonBytes, + canonicalJsonString, + domainTag, + signingBytes, + evaluateContainerCriticality, + parseCanonicalEnvelope, + parseContainer, + resolveProfile, + checkProfileContainerRules, + WR_CORE_OBJECT_TYPE, + WR_CANONICAL_SCHEMA_VERSION, +} from '@repo/ingestion-core' +import type { + CanonicalJsonValue, + ContainerEntry, + CorePartyId, + CoreSignature, + WrCanonicalEnvelope, + WrHandshakeCore, +} from '@repo/ingestion-core' +import { isWeakEd25519PublicKey } from '../security/ed25519WeakKey' + +export const CAPSULE_DECLARATION_NS = 'optirando.decl.capsule' + +/** + * Phase-2 emissions are still produced by the legacy formation dialects; the + * registered `legacy_v0` profile blesses this signature discipline [Q2]. + * Real profile assignment arrives with the one pipeline (Phase 4). + */ +export const PHASE2_EMISSION_PROFILE = Object.freeze({ id: 'legacy_v0', version: 1 }) + +// ── Ed25519 over canonical bytes ────────────────────────────────────────────── + +/** PKCS#8 DER prefix for a raw 32-byte Ed25519 seed (RFC 8410). */ +const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex') + +function privateKeyFromHex(privateKeyHex: string) { + if (!/^[a-f0-9]+$/i.test(privateKeyHex) || privateKeyHex.length < 64) { + throw new Error('privateKey must be hex (64-char seed or PKCS#8 DER)') + } + if (privateKeyHex.length === 64) { + // Raw seed → wrap in PKCS#8. NOTE: generateKeyPairSync('ed25519', { seed }) + // silently IGNORES the seed option (Node has no such option) and returns a + // random keypair — signing with it produces signatures that never verify. + const der = Buffer.concat([ED25519_PKCS8_PREFIX, Buffer.from(privateKeyHex, 'hex')]) + return createPrivateKey({ key: der, format: 'der', type: 'pkcs8' }) + } + return createPrivateKey({ key: Buffer.from(privateKeyHex, 'hex'), format: 'der', type: 'pkcs8' }) +} + +function rawPubKeyToSpki(rawHex: string): Buffer { + const key = Buffer.from(rawHex, 'hex') + if (key.length !== 32) throw new Error('publicKey must be 32 bytes') + const oid = Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]) + const algSeq = Buffer.concat([Buffer.from([0x30, 0x05]), oid]) + const bitStr = Buffer.concat([Buffer.from([0x03, 0x21, 0x00]), key]) + const elements = Buffer.concat([algSeq, bitStr]) + return Buffer.concat([Buffer.from([0x30, elements.length]), elements]) +} + +/** sha256(canonicalBytes(core)) — the referenced bytes countersignatures bind [Q3]. */ +export function canonicalCoreHash(core: WrHandshakeCore): Buffer { + return createHash('sha256') + .update(canonicalJsonBytes(core as unknown as CanonicalJsonValue)) + .digest() +} + +function bytesForMode(core: WrHandshakeCore, mode: CoreSignature['mode']): Buffer { + if (mode === 'canonical_bytes') { + return Buffer.from( + signingBytes(WR_CORE_OBJECT_TYPE, WR_CANONICAL_SCHEMA_VERSION, core as unknown as CanonicalJsonValue), + ) + } + // canonical_hash [Q3]: domain tag || sha256(canonical bytes) + return Buffer.concat([ + Buffer.from(domainTag(WR_CORE_OBJECT_TYPE, WR_CANONICAL_SCHEMA_VERSION)), + canonicalCoreHash(core), + ]) +} + +/** + * Sign the COMPLETE core record (minus nothing — the signature list is + * detached). There is intentionally no way to sign a field subset. + */ +export function signCore( + core: WrHandshakeCore, + privateKeyHex: string, + publicKeyHex: string, + signer: CoreSignature['signer'], + mode: CoreSignature['mode'] = 'canonical_bytes', +): CoreSignature { + const sig = sign(null, bytesForMode(core, mode), privateKeyFromHex(privateKeyHex)) + return { signer, alg: 'ed25519', mode, public_key: publicKeyHex.toLowerCase(), sig: sig.toString('hex') } +} + +export function verifyCoreSignature(core: WrHandshakeCore, signature: CoreSignature): boolean { + try { + if (isWeakEd25519PublicKey(new Uint8Array(Buffer.from(signature.public_key, 'hex')))) return false + const publicKey = createPublicKey({ key: rawPubKeyToSpki(signature.public_key), format: 'der', type: 'spki' }) + return verify(null, bytesForMode(core, signature.mode), publicKey, Buffer.from(signature.sig, 'hex')) + } catch { + return false + } +} + +// ── Capsule → signed core (emission) ───────────────────────────────────────── + +/** Fields that are signatures themselves or the envelope — never inside the signed content. */ +const SIGNATURE_SURFACE_FIELDS: ReadonlySet = new Set([ + 'sender_signature', + 'countersigned_hash', + 'wr_canonical_v3', +]) + +/** Large fields covered by SHA-256 reference instead of byte duplication. */ +const HASH_COVERED_FIELDS: ReadonlySet = new Set(['context_blocks', 'context_blocks_sealed']) + +/** + * Stable projection of a hash-covered field before hashing. The Gate-2 + * rebuild normalizes optional block fields to explicit nulls; hashing the + * same projection on BOTH sides keeps the reference byte-stable across that + * normalization (absent and null collapse to null; key order is handled by + * the canonical serializer). + */ +function projectHashCoveredField(field: string, value: unknown): CanonicalJsonValue { + if (field === 'context_blocks' && Array.isArray(value)) { + return value.map((b) => { + const block = b as Record + return { + block_id: (block.block_id as string) ?? null, + block_hash: (block.block_hash as string) ?? null, + scope_id: (block.scope_id as string | null | undefined) ?? null, + type: (block.type as string) ?? null, + content: (block.content as CanonicalJsonValue | undefined) ?? null, + } + }) + } + if (field === 'context_blocks_sealed' && value && typeof value === 'object' && !Array.isArray(value)) { + const e = value as Record + return { + envelope_type: (e.envelope_type as string) ?? null, + schema_version: (e.schema_version as number) ?? null, + handshake_id: (e.handshake_id as string) ?? null, + sender_device_id: (e.sender_device_id as string) ?? null, + receiver_device_id: (e.receiver_device_id as string) ?? null, + sender_ephemeral_x25519_pub_b64: (e.sender_ephemeral_x25519_pub_b64 as string) ?? null, + salt_b64: (e.salt_b64 as string) ?? null, + nonce_b64: (e.nonce_b64 as string) ?? null, + ciphertext_b64: (e.ciphertext_b64 as string) ?? null, + } + } + return value as CanonicalJsonValue +} + +/** + * The complete capsule content as it enters the signed declaration payload: + * every field except the signature surface, with large fields replaced by + * `{ __sha256 }` references over their stable projection. Full coverage — + * nothing else is dropped. + */ +export function capsuleContentForSigning(capsule: Record): Record { + const out: Record = {} + for (const key of Object.keys(capsule)) { + const value = capsule[key] + if (value === undefined || SIGNATURE_SURFACE_FIELDS.has(key)) continue + if (HASH_COVERED_FIELDS.has(key) && value !== null) { + out[key] = { __sha256: sha256OfJson(projectHashCoveredField(key, value)) } + continue + } + out[key] = value as CanonicalJsonValue + } + return out +} + +function sha256OfJson(value: unknown): string { + return createHash('sha256') + .update(canonicalJsonBytes(value as CanonicalJsonValue)) + .digest('hex') +} + +export interface BuildCoreOptions { + initiator: CorePartyId + responder: CorePartyId | null + /** ISO instant — the capsule timestamp. */ + createdAt: string + /** 64-hex freshness nonce — the capsule nonce [VII.3.1]. */ + nonce: string + /** Extra extension entries (none in Phase 2 emissions). */ + extensions?: ContainerEntry[] + /** + * Phase 4: real profile assignment by the one pipeline. Emissions without + * one stay `legacy_v0` (Q2-blessed legacy signature discipline). + */ + profile?: { id: string; version: number } + /** Phase 4 (Q4): recorded on new formations by the one pipeline; log-only. */ + ingressPath?: string | null + /** Phase 4 [IX.3.1 rule 5]: capture provenance etc. as signed declarations. */ + extraDeclarations?: ContainerEntry[] +} + +/** + * Build the frozen signed core for an outbound capsule. `ingress_path` is + * null on every Phase-2 emission (values are recorded by the one pipeline + * from Phase 4 per Q4) and is log-only forever [VII.4.6]. + */ +export function buildCoreForCapsule( + capsule: Record, + opts: BuildCoreOptions, +): WrHandshakeCore { + return { + profile: opts.profile ? { ...opts.profile } : { ...PHASE2_EMISSION_PROFILE }, + initiator_id: opts.initiator, + responder_id: opts.responder, + ingress_path: opts.ingressPath ?? null, + declarations: [ + { + ns: CAPSULE_DECLARATION_NS, + version: 1, + critical: true, + payload: capsuleContentForSigning(capsule), + }, + ...(opts.extraDeclarations ?? []), + ], + extensions: opts.extensions ?? [], + created_at: opts.createdAt, + nonce: opts.nonce, + } +} + +/** + * Dual-format emission helper: attach the signed canonical envelope to a + * fully built v2 capsule. Returns a NEW capsule object; the v2 surface is + * untouched (old peers' allowlist rebuild keeps verifying it). + */ +export function attachCanonicalEnvelope>( + capsule: T, + opts: BuildCoreOptions & { + privateKeyHex: string + publicKeyHex: string + signer: CoreSignature['signer'] + }, +): T & { wr_canonical_v3: WrCanonicalEnvelope } { + const core = buildCoreForCapsule(capsule, opts) + const signature = signCore(core, opts.privateKeyHex, opts.publicKeyHex, opts.signer, 'canonical_bytes') + return { ...capsule, wr_canonical_v3: { v: WR_CANONICAL_SCHEMA_VERSION, core, signatures: [signature] } } +} + +// ── Verification (receive) ──────────────────────────────────────────────────── + +export type EnvelopeVerification = + | { + ok: true + envelope: WrCanonicalEnvelope + /** Namespaces of preserved-and-ignored unknown non-critical entries. */ + ignoredNamespaces: string[] + } + | { + ok: false + reason: string + refusedNamespace?: string + /** Set on profile-dispatch refusals — named in the visible refusal [VII.4.2]. */ + refusedProfile?: { id: string; version: number } + } + +/** + * Fields cross-checked between the pipeline's capsule view and the signed + * capsule declaration. A value present on the received capsule that is + * missing from or different in the signed content ⇒ the wire was altered or + * the sender under-signed ⇒ fail closed. + */ +const BINDING_FIELDS: readonly string[] = [ + 'schema_version', + 'capsule_type', + 'handshake_id', + 'relationship_id', + 'sender_id', + 'sender_wrdesk_user_id', + 'sender_email', + 'receiver_id', + 'receiver_email', + 'capsule_hash', + 'context_hash', + 'context_commitment', + 'nonce', + 'timestamp', + 'seq', + 'external_processing', + 'reciprocal_allowed', + 'wrdesk_policy_hash', + 'wrdesk_policy_version', + 'sharing_mode', + 'prev_hash', + 'sender_public_key', + 'sender_x25519_public_key_b64', + 'sender_mlkem768_public_key_b64', + 'handshake_type', + 'sender_device_id', + 'receiver_device_id', + 'sender_device_role', + 'receiver_device_role', + 'receiver_pairing_code', + 'p2p_endpoint', + 'p2p_auth_token', + 'senderIdentity', + 'receiverIdentity', + 'tierSignals', + 'context_block_proofs', +] + +function canonicalEq(a: unknown, b: unknown): boolean { + try { + return ( + canonicalJsonString(a as CanonicalJsonValue) === canonicalJsonString(b as CanonicalJsonValue) + ) + } catch { + return false + } +} + +/** + * Verify a received capsule's canonical envelope, fail-closed [VII.4.2]: + * 1. structural parse (containers preserved byte-faithfully), + * 2. at least one valid full-coverage 'canonical_bytes' signature whose key + * is the capsule's pinned sender key, + * 3. every additional signature in the ordered list must verify, + * 4. container criticality: unknown/reserved CRITICAL namespace → visible + * refusal naming the namespace [VII.3.5], + * 5. binding cross-check: the signed capsule declaration must match the + * received capsule on every consumed field (under-signing rejection). + */ +export function verifyCanonicalEnvelope( + capsule: Record, + expectedSenderPublicKeyHex: string, +): EnvelopeVerification { + const parsed = parseCanonicalEnvelope(capsule.wr_canonical_v3) + if (!parsed.ok) return { ok: false, reason: `envelope_parse:${parsed.reason}` } + const { envelope } = parsed + + // 1b — profile dispatch, FAIL-CLOSED [VII.4.2]: unknown profile id or + // unsupported profile version → visible refusal naming the profile; no + // fallback path exists. Profiles are registry records (Phase 3), never + // code branches; the record parameterizes the checks below. + const profileRef = envelope.core.profile + const resolution = resolveProfile(profileRef.id, profileRef.version) + if (!resolution.ok) { + return { + ok: false, + reason: `${resolution.reason}:${profileRef.id}@${profileRef.version}`, + refusedProfile: { id: profileRef.id, version: profileRef.version }, + } + } + const profile = resolution.record + + // 2+3 — signatures. The full-coverage signature must be bound to the same + // key the legacy surface pins (TOFU / counterparty pinning applies to both). + // Countersignature discipline [VII.3.2, Q3]: every signature in the ordered + // list verifies over the SAME core value (byte-identical by construction of + // bytesForMode); a countersignature over differing bytes cannot verify. + let boundFullCoverage = false + const distinctValidKeys = new Set() + for (const signature of envelope.signatures) { + if (!verifyCoreSignature(envelope.core, signature)) { + return { ok: false, reason: `signature_invalid:${signature.signer}:${signature.mode}` } + } + distinctValidKeys.add(signature.public_key.toLowerCase()) + if ( + signature.mode === 'canonical_bytes' && + signature.public_key === expectedSenderPublicKeyHex.toLowerCase() + ) { + boundFullCoverage = true + } + } + if (!boundFullCoverage) { + return { ok: false, reason: 'no_full_coverage_signature_from_sender_key' } + } + + // Per-profile signature cardinality (registry-parameterized): 2-sig + // profiles count as established only when DOUBLY signed over the identical + // core [VII.3.2]. Distinct keys, not list length — the same signer twice + // is one signature. + if (distinctValidKeys.size < profile.signature_cardinality) { + return { + ok: false, + reason: `signature_cardinality_unmet:${distinctValidKeys.size}<${profile.signature_cardinality}`, + refusedProfile: { id: profile.id, version: profile.version }, + } + } + + // 4 — container criticality (both containers; order preserved). + const declarations = parseContainer((envelope.core as unknown as Record).declarations, 'declarations') + const extensions = parseContainer((envelope.core as unknown as Record).extensions, 'extensions') + if (!declarations.ok) return { ok: false, reason: declarations.reason } + if (!extensions.ok) return { ok: false, reason: extensions.reason } + const ignoredNamespaces: string[] = [] + for (const entries of [declarations.entries, extensions.entries]) { + const verdict = evaluateContainerCriticality(entries) + if (!verdict.ok) { + return { + ok: false, + reason: `unknown_critical_namespace:${verdict.refusedNamespace}`, + refusedNamespace: verdict.refusedNamespace, + } + } + ignoredNamespaces.push(...verdict.ignoredNonCritical) + } + + // 4b — profile container rules AT SCHEMA LEVEL [VII.4.5]: e.g. a + // `private_personal` core carrying a publisher_attestation block is + // rejected here, not by UI; `pbeap_publisher` requires one. + const allNamespaces = [...declarations.entries, ...extensions.entries].map((e) => e.ns) + const containerVerdict = checkProfileContainerRules(profile, allNamespaces) + if (!containerVerdict.ok) { + return { + ok: false, + reason: `${containerVerdict.reason}:${profile.id}`, + refusedProfile: { id: profile.id, version: profile.version }, + } + } + + // 5 — binding cross-check against the signed capsule declaration. + const capsuleDecl = declarations.entries.find((e) => e.ns === CAPSULE_DECLARATION_NS) + if (!capsuleDecl || !capsuleDecl.payload || typeof capsuleDecl.payload !== 'object' || Array.isArray(capsuleDecl.payload)) { + return { ok: false, reason: 'missing_capsule_declaration' } + } + const signedContent = capsuleDecl.payload as Record + for (const field of BINDING_FIELDS) { + const wireValue = capsule[field] + if (wireValue === undefined || wireValue === null) continue + const signedValue = signedContent[field] + if (signedValue === undefined) { + return { ok: false, reason: `under_signed_field:${field}` } + } + if (!canonicalEq(wireValue, signedValue)) { + return { ok: false, reason: `binding_mismatch:${field}` } + } + } + // Hash-covered large fields: verify the reference when the wire carries bytes. + for (const field of HASH_COVERED_FIELDS) { + const wireValue = capsule[field] + if (wireValue === undefined || wireValue === null) continue + const ref = signedContent[field] as { __sha256?: string } | undefined + if (!ref || typeof ref.__sha256 !== 'string') { + return { ok: false, reason: `under_signed_field:${field}` } + } + if (ref.__sha256 !== sha256OfJson(projectHashCoveredField(field, wireValue))) { + return { ok: false, reason: `binding_mismatch:${field}` } + } + } + + // Party binding [VII.3.8]: the capsule's sender identity must full-claim + // match one of the signed core parties (initiator on initiate — the only + // capsule the initiator can send before a responder is bound). + const senderIdentity = capsule.senderIdentity as Record | undefined + if (senderIdentity && typeof senderIdentity === 'object') { + const matchesParty = (party: CorePartyId | null): boolean => + !!party && + party.sub === senderIdentity.sub && + party.iss === senderIdentity.iss && + party.email === senderIdentity.email && + party.wrdesk_user_id === senderIdentity.wrdesk_user_id + const core = envelope.core + const senderIsParty = + capsule.capsule_type === 'initiate' + ? matchesParty(core.initiator_id) + : matchesParty(core.initiator_id) || matchesParty(core.responder_id) + if (!senderIsParty) { + return { ok: false, reason: 'sender_identity_not_bound_to_core_party' } + } + } + + return { ok: true, envelope, ignoredNamespaces } +} + +/** Whether a capsule carries the new canonical form. */ +export function hasCanonicalEnvelope(capsule: Record | null | undefined): boolean { + return !!capsule && typeof capsule === 'object' && capsule.wr_canonical_v3 !== undefined +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/canonicalRebuild.ts b/code/apps/electron-vite-project/electron/main/handshake/canonicalRebuild.ts index 2e893e797..5af782a83 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/canonicalRebuild.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/canonicalRebuild.ts @@ -22,7 +22,10 @@ */ import { normalizeNFC, stripControlChars, isValidEmail } from './sanitize' +import { wireDeclaresSamePrincipal } from './samePrincipalWire' import { validateInternalEndpointPairDistinct } from '../../../../../packages/shared/src/handshake/internalEndpointValidation' +import { parseCanonicalEnvelope } from '@repo/ingestion-core' +import type { WrCanonicalEnvelope } from '@repo/ingestion-core' /** * Mirrors `SEALED_SERVICE_RPC_ENVELOPE_TYPE` / `SEALED_SERVICE_RPC_SCHEMA_VERSION` @@ -128,6 +131,12 @@ export interface HandshakeCapsuleCanonical { * not part of capsule_hash / context_hash inputs (advisory routing field). */ readonly receiver_pairing_code?: string + /** + * Canonical v3 signed core envelope (Phase 2) — preserve-unknown + * passthrough, verified fail-closed in enforcement.ts. Never stripped, + * never reordered [VII.3.4–3.6]. + */ + readonly wr_canonical_v3?: WrCanonicalEnvelope } export interface CanonicalContextBlock { @@ -444,7 +453,7 @@ function rebuildContextBlockProofs(raw: unknown): { ok: true; proofs: ContextBlo function validateInternalHandshakeWireIfNeeded( canonical: Record, ): RebuildResult | null { - if (canonical.handshake_type !== 'internal') { + if (!wireDeclaresSamePrincipal(canonical)) { return null } @@ -695,8 +704,8 @@ export function canonicalRebuild(raw: unknown): RebuildResult { } // Internal / coordination routing (optional — preserved for relay + ledger). - // `receiver_pairing_code` is included so the new pairing-code initiate model - // survives Gate-2 rebuild and reaches `recipientPersist` / + // `receiver_pairing_code` is included so the pairing-code initiate model + // survives Gate-2 rebuild and reaches the formation pipeline / // `validateInternalHandshakeWireIfNeeded`. for (const coordField of [ 'handshake_type', @@ -777,6 +786,22 @@ export function canonicalRebuild(raw: unknown): RebuildResult { canonical.context_blocks_sealed = sealedResult.envelope } + // Canonical v3 envelope (Phase 2 — version-gated wire) [VII.3, VII.6.1.3]. + // NEW FORMAT ≠ allowlist-strip: the envelope is structurally validated via + // the preserve-unknown parser and passed through BYTE-FAITHFULLY (original + // object, original container entries) so signature verification recomputes + // over exactly what the sender signed. Unknown container entries survive; + // criticality is enforced downstream in enforcement.ts, not here. + // A malformed envelope rejects the capsule (fail-closed) rather than being + // silently stripped — stripping would downgrade v3 capsules to legacy. + if ('wr_canonical_v3' in obj && obj.wr_canonical_v3 !== undefined && obj.wr_canonical_v3 !== null) { + const envelopeResult = parseCanonicalEnvelope(obj.wr_canonical_v3) + if (!envelopeResult.ok) { + return { ok: false, reason: `Invalid wr_canonical_v3 envelope: ${envelopeResult.reason}`, field: 'wr_canonical_v3' } + } + canonical.wr_canonical_v3 = obj.wr_canonical_v3 + } + return { ok: true, capsule: canonical as unknown as HandshakeCapsuleCanonical } } diff --git a/code/apps/electron-vite-project/electron/main/handshake/capsuleBuilder.ts b/code/apps/electron-vite-project/electron/main/handshake/capsuleBuilder.ts index 1cb8222de..3e7bb9d16 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/capsuleBuilder.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/capsuleBuilder.ts @@ -31,6 +31,9 @@ import { randomUUID } from 'crypto' import type { SSOSession, SharingMode, TierSignals, ReceiverIdentity } from './types' import type { ContextBlockProof } from './canonicalRebuild' import { computeCapsuleHash, type CapsuleHashInput } from './capsuleHash' +import { attachCanonicalEnvelope } from './canonicalCore' +import type { CorePartyId, WrCanonicalEnvelope } from '@repo/ingestion-core' +import type { PartyIdentity } from './types' import { computeContextHash, generateNonce, type ContextHashInput } from './contextHash' import { computeContextCommitment, stripContentFromBlocks, type ContextBlockForCommitment, type ContextBlockWireProof } from './contextCommitment' import { computePolicyHash, DEFAULT_POLICY_DESCRIPTOR, type PolicyDescriptor } from './policyHash' @@ -259,6 +262,69 @@ export interface HandshakeCapsuleWire { * `receiver_device_id` instead. */ readonly receiver_pairing_code?: string; + /** + * Phase 2 (version-gated wire): canonical v3 signed core envelope. The v2 + * surface above stays byte-compatible for old peers; new receivers verify + * this envelope fail-closed on top [VII.3, VII.6.1.3]. + */ + readonly wr_canonical_v3?: WrCanonicalEnvelope; +} + +// ── Canonical v3 emission (Phase 2) ── + +function sessionToCoreParty(session: SSOSession): CorePartyId { + return { + sub: session.sub, + iss: session.iss, + email: session.email, + wrdesk_user_id: session.wrdesk_user_id, + } +} + +interface V3EmissionArgs { + session: SSOSession + /** Sender's role on the HANDSHAKE (not the message). */ + localRole: 'initiator' | 'acceptor' + /** Counterparty full-claim identity when known; required when localRole is acceptor. */ + counterpartyIdentity?: PartyIdentity | null + privateKeyHex: string + publicKeyHex: string +} + +/** + * Attach the canonical v3 envelope to a fully built v2 capsule (dual-format + * emission). When the required party identities are not available (legacy + * call sites that have not plumbed the counterparty identity yet), the + * capsule is emitted legacy-only — never with fabricated identity claims. + * Canonicalization failures also fall back to legacy-only emission (logged): + * the transitional dual format must not break v2 interop. + */ +function attachV3IfPossible(capsule: HandshakeCapsuleWire, args: V3EmissionArgs): HandshakeCapsuleWire { + try { + const localParty = sessionToCoreParty(args.session) + let initiator: CorePartyId + let responder: CorePartyId | null + if (args.localRole === 'initiator') { + initiator = localParty + responder = args.counterpartyIdentity ?? null + } else { + if (!args.counterpartyIdentity) return capsule + initiator = args.counterpartyIdentity + responder = localParty + } + return attachCanonicalEnvelope(capsule as unknown as Record, { + initiator, + responder, + createdAt: capsule.timestamp, + nonce: capsule.nonce, + privateKeyHex: args.privateKeyHex, + publicKeyHex: args.publicKeyHex, + signer: args.localRole === 'initiator' ? 'initiator' : 'responder', + }) as unknown as HandshakeCapsuleWire + } catch (e: any) { + console.error('[CAPSULE-BUILD] canonical v3 emission failed — sending legacy-only:', e?.message) + return capsule + } } // ── Options types ── @@ -360,6 +426,12 @@ export interface AcceptOptions { /** Internal accept: wire receiver_* (initiator peer). Required with isInternalHandshake. */ receiverDeviceRole?: 'host' | 'sandbox'; receiverComputerName?: string; + /** + * Phase 2 (canonical v3 emission): initiator's full-claim identity from the + * received initiate capsule. Required to bind initiator_id in the signed + * core; without it the accept is emitted legacy-only (never fabricated). + */ + initiatorIdentity?: PartyIdentity | null; } /** @@ -415,6 +487,10 @@ export interface RefreshOptions { receiverComputerName?: string; /** Local P2P Bearer for the peer to store (symmetric auth). */ p2p_auth_token?: string | null; + /** Phase 2 (canonical v3 emission): sender's handshake role. */ + localHandshakeRole?: 'initiator' | 'acceptor'; + /** Phase 2 (canonical v3 emission): counterparty full-claim identity when known. */ + counterpartyIdentity?: PartyIdentity | null; } export interface RevokeOptions { @@ -446,6 +522,10 @@ export interface RevokeOptions { senderComputerName?: string; receiverComputerName?: string; p2p_auth_token?: string | null; + /** Phase 2 (canonical v3 emission): sender's handshake role. */ + localHandshakeRole?: 'initiator' | 'acceptor'; + /** Phase 2 (canonical v3 emission): counterparty full-claim identity when known. */ + counterpartyIdentity?: PartyIdentity | null; } /** Options for context_sync — first post-activation capsule delivering context blocks. */ @@ -490,6 +570,8 @@ export interface ContextSyncOptions { peerX25519PublicKeyB64?: string | null; /** This device's role on the handshake — used only as an AAD label for content sealing. */ localRole?: 'initiator' | 'acceptor'; + /** Phase 2 (canonical v3 emission): counterparty full-claim identity when known. */ + counterpartyIdentity?: PartyIdentity | null; } // ── Builder functions ── @@ -607,7 +689,16 @@ function buildInitiateCapsuleCore( ...(opts.sender_mlkem768_public_key_b64 ? { sender_mlkem768_public_key_b64: opts.sender_mlkem768_public_key_b64 } : {}), ...internalWire, } - return { capsule, keypair } + return { + capsule: attachV3IfPossible(capsule, { + session, + localRole: 'initiator', + counterpartyIdentity: null, + privateKeyHex: keypair.privateKey, + publicKeyHex: keypair.publicKey, + }), + keypair, + } } /** @@ -777,7 +868,16 @@ export function buildAcceptCapsule( ...(opts.sender_mlkem768_public_key_b64 ? { sender_mlkem768_public_key_b64: opts.sender_mlkem768_public_key_b64 } : {}), ...internalCoord, } - return { capsule, keypair } + return { + capsule: attachV3IfPossible(capsule, { + session, + localRole: 'acceptor', + counterpartyIdentity: opts.initiatorIdentity ?? null, + privateKeyHex: keypair.privateKey, + publicKeyHex: keypair.publicKey, + }), + keypair, + } } /** Result of buildAcceptCapsule including keypair for persistence (same shape for accept) */ @@ -896,6 +996,15 @@ export function buildRefreshCapsule( }), ...(opts.p2p_auth_token ? { p2p_auth_token: opts.p2p_auth_token } : {}), } + if (opts.localHandshakeRole) { + return attachV3IfPossible(wire, { + session, + localRole: opts.localHandshakeRole, + counterpartyIdentity: opts.counterpartyIdentity ?? null, + privateKeyHex: opts.local_private_key, + publicKeyHex: opts.local_public_key, + }) + } return wire } @@ -961,7 +1070,7 @@ export function buildContextSyncCapsule( const capsuleHash = computeCapsuleHash(hashInput) const senderSignature = signCapsuleHash(capsuleHash, opts.local_private_key) - return { + const contextSyncWire: HandshakeCapsuleWire = { schema_version: 2, capsule_type: 'context_sync', handshake_id: opts.handshake_id, @@ -1005,6 +1114,16 @@ export function buildContextSyncCapsule( }), ...(opts.p2p_auth_token ? { p2p_auth_token: opts.p2p_auth_token } : {}), } + if (opts.localRole) { + return attachV3IfPossible(contextSyncWire, { + session, + localRole: opts.localRole, + counterpartyIdentity: opts.counterpartyIdentity ?? null, + privateKeyHex: opts.local_private_key, + publicKeyHex: opts.local_public_key, + }) + } + return contextSyncWire } /** @@ -1042,10 +1161,23 @@ export function buildContextSyncCapsuleWithContent( if (!sealed.ok) { throw new Error(`CONTEXT_SYNC_SEAL_FAILED: ${sealed.code}: ${sealed.message}`) } - return { - ...base, + // Re-attach the canonical envelope so it covers context_blocks_sealed + // (full coverage — the envelope signed by buildContextSyncCapsule predates + // the sealed field and must not survive on the extended capsule). + const { wr_canonical_v3: _staleEnvelope, ...baseWithoutEnvelope } = base as HandshakeCapsuleWire & { + wr_canonical_v3?: unknown + } + const withSealed = { + ...(baseWithoutEnvelope as HandshakeCapsuleWire), context_blocks_sealed: sealed.envelope, } + return attachV3IfPossible(withSealed, { + session, + localRole, + counterpartyIdentity: opts.counterpartyIdentity ?? null, + privateKeyHex: opts.local_private_key, + publicKeyHex: opts.local_public_key, + }) } /** diff --git a/code/apps/electron-vite-project/electron/main/handshake/connectOfferStaging.ts b/code/apps/electron-vite-project/electron/main/handshake/connectOfferStaging.ts new file mode 100644 index 000000000..65e5b5742 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/connectOfferStaging.ts @@ -0,0 +1,689 @@ +/** + * Connect-offer staging store (Phase 4 — V2, Q1) [IX.3.1] + * + * Inbound invitations (email / relay / WS initiate capsules, .beap file + * imports) NO LONGER create relationship rows. They land here — a staging + * store that is deliberately NOT the relationship store — until: + * + * verification chain → client-generated Connect offer → consent + * → only then does the ONE formation pipeline create a core record. + * + * Rules [IX.3.1 rules 1–4]: + * - Failed verification SUPPRESSES the offer entirely: the row is kept as a + * logged record but is never listable and can never be consented to. + * There is no "connect anyway". + * - The Connect-offer preview is CLIENT-GENERATED from verified capsule + * material — never counterparty free text — and canonically hashable at + * presentation time (Intent-Hash substrate for Phase 5). + * - Staged offers keep the 7-day timeout (Q7). + * + * Consent records are Hash-Pinned [IX.3.4]: preview hash + bound-definition + * hash + contract-state hash. A consent record whose hashes do not resolve + * against the staged material is invalid. + * + * Persistence: this store lives in its OWN SQLite file (connect-offers.db), + * outside the handshake migration chain. The handshake ledger is frozen at + * v74 (Phase 3) and the staging store must exist regardless of which + * relationship DB handle is active, so it never shares either handle. + */ + +import { createHash, randomUUID } from 'node:crypto' +import { canonicalJsonString, domainTag, type CanonicalJsonValue } from '@repo/ingestion-core' +import { INPUT_LIMITS } from './types' + +// ── Schema ──────────────────────────────────────────────────────────────────── + +export const CONNECT_OFFER_SCHEMA_VERSION = 1 + +export function ensureConnectOfferSchema(db: any): void { + db.exec(` + CREATE TABLE IF NOT EXISTS wr_connect_offers ( + offer_id TEXT PRIMARY KEY, + handshake_id TEXT NOT NULL, + capsule_json TEXT NOT NULL, + capsule_hash TEXT NOT NULL, + sender_email TEXT, + sender_iss TEXT, + sender_sub TEXT, + sender_wrdesk_user_id TEXT, + receiver_email TEXT, + profile_id TEXT NOT NULL, + ingress_path TEXT NOT NULL, + invitation_class TEXT NOT NULL DEFAULT 'public_bearer', + verification_status TEXT NOT NULL CHECK (verification_status IN ('verified', 'failed')), + verification_reason TEXT, + suppressed INTEGER NOT NULL DEFAULT 0, + staged_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + consumed_action TEXT CHECK (consumed_action IN ('consented', 'declined', 'expired')), + consent_id TEXT, + -- Phase 4 (4B): WR-code resolution output. Every one of these is sourced + -- from the resolved, dual-channel-validated material, NEVER from carrier + -- bytes -- the carrier may say anything and is not a party to the offer. + wr_code_canonical TEXT, + publisher_part TEXT, + entry_local_part TEXT, + umbrella_handshake_id TEXT, + entry_status TEXT, + resolution_mode TEXT CHECK (resolution_mode IS NULL OR resolution_mode IN ('public', 'session_bound')), + session_bound_expires_at TEXT, + -- Delta v1.1 Phase-4 additions: EVP-first-render material + audit link. + evp_ref TEXT, + value_statement TEXT, + catalog_epoch INTEGER, + audit_url TEXT, + UNIQUE (handshake_id, capsule_hash) + ); + CREATE INDEX IF NOT EXISTS idx_wr_connect_offers_pending + ON wr_connect_offers (suppressed, consumed_at, expires_at); + + CREATE TABLE IF NOT EXISTS wr_consent_records ( + consent_id TEXT PRIMARY KEY, + offer_id TEXT, + handshake_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('initiator', 'acceptor')), + preview_hash TEXT NOT NULL, + bound_definition_hash TEXT NOT NULL, + contract_state_hash TEXT NOT NULL, + capture_method TEXT NOT NULL, + ingress_path TEXT NOT NULL, + source_reference TEXT, + actor_wrdesk_user_id TEXT NOT NULL, + consented_at TEXT NOT NULL, + -- Phase 4 (4B): what the operator consented to includes HOW it resolved. + resolution_mode TEXT + ); + CREATE INDEX IF NOT EXISTS idx_wr_consent_records_handshake + ON wr_consent_records (handshake_id); + + CREATE TABLE IF NOT EXISTS wr_connect_offer_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `) + // `CREATE TABLE IF NOT EXISTS` does nothing for a database that already has + // the table, so Phase-4 columns are added explicitly and idempotently. + addMissingColumns(db, 'wr_connect_offers', [ + ['wr_code_canonical', 'TEXT'], + ['publisher_part', 'TEXT'], + ['entry_local_part', 'TEXT'], + ['umbrella_handshake_id', 'TEXT'], + ['entry_status', 'TEXT'], + // No CHECK on the added column: SQLite cannot add a constrained column to + // an existing table, and the value is written only from resolution output. + ['resolution_mode', 'TEXT'], + ['session_bound_expires_at', 'TEXT'], + ['evp_ref', 'TEXT'], + ['value_statement', 'TEXT'], + ['catalog_epoch', 'INTEGER'], + ['audit_url', 'TEXT'], + ]) + addMissingColumns(db, 'wr_consent_records', [['resolution_mode', 'TEXT']]) + + db.prepare( + `INSERT OR IGNORE INTO wr_connect_offer_meta (key, value) VALUES ('schema_version', ?)`, + ).run(String(CONNECT_OFFER_SCHEMA_VERSION)) +} + +function addMissingColumns(db: any, table: string, columns: Array<[string, string]>): void { + let existing: Set + try { + const info = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string }> + existing = new Set(info.map((c) => String(c.name))) + } catch { + return + } + for (const [name, type] of columns) { + if (existing.has(name)) continue + try { + db.prepare(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`).run() + } catch { + /* another process added it concurrently — idempotent by intent */ + } + } +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ConnectOfferRow { + offer_id: string + handshake_id: string + capsule_json: string + capsule_hash: string + sender_email: string | null + sender_iss: string | null + sender_sub: string | null + sender_wrdesk_user_id: string | null + receiver_email: string | null + profile_id: string + ingress_path: string + invitation_class: string + verification_status: 'verified' | 'failed' + verification_reason: string | null + suppressed: number + staged_at: string + expires_at: string + consumed_at: string | null + consumed_action: 'consented' | 'declined' | 'expired' | null + consent_id: string | null + // Phase 4 (4B) — resolution output. Null on offers staged before Phase 4 and + // on any offer that did not come from a WR code. + wr_code_canonical?: string | null + publisher_part?: string | null + entry_local_part?: string | null + umbrella_handshake_id?: string | null + entry_status?: string | null + resolution_mode?: WrResolutionMode | null + session_bound_expires_at?: string | null + evp_ref?: string | null + value_statement?: string | null + catalog_epoch?: number | null + audit_url?: string | null +} + +/** How the entry resolved. Part of what the operator consents to (4B). */ +export type WrResolutionMode = 'public' | 'session_bound' + +/** + * Resolution-derived offer material. Every field here comes from the verified + * resolution chain — the registry claim after dual-channel validation, the + * verified head, and the verified EVP. None of it may be read off the carrier. + */ +export interface WrCodeOfferResolution { + wr_code_canonical: string + publisher_part: string + entry_local_part: string + umbrella_handshake_id?: string | null + entry_status: string + resolution_mode: WrResolutionMode + session_bound_expires_at?: string | null + /** Delta v1.1: EVP-first-render material. */ + evp_ref?: string | null + value_statement?: string | null + catalog_epoch?: number | null + audit_url?: string | null + /** Whether the publisher domain completed dual-channel validation. */ + publisher_domain_verified: boolean +} + +export interface StageConnectOfferInput { + handshake_id: string + /** Full initiate capsule as validated (verified material only). */ + capsule: Record + capsule_hash: string + sender_email?: string | null + sender_iss?: string | null + sender_sub?: string | null + sender_wrdesk_user_id?: string | null + receiver_email?: string | null + profile_id: string + ingress_path: string + invitation_class?: string + /** Verification chain verdict. `ok: false` suppresses the offer entirely. */ + verification: { ok: true } | { ok: false; reason: string } + /** Phase 4 (4B): resolution output for WR-code offers. Absent otherwise. */ + wr_code?: WrCodeOfferResolution +} + +export interface ConsentRecordRow { + consent_id: string + offer_id: string | null + handshake_id: string + role: 'initiator' | 'acceptor' + preview_hash: string + bound_definition_hash: string + contract_state_hash: string + capture_method: string + ingress_path: string + source_reference: string | null + actor_wrdesk_user_id: string + consented_at: string +} + +// ── Staging ─────────────────────────────────────────────────────────────────── + +export type StageConnectOfferResult = + | { staged: true; offerId: string; suppressed: boolean } + | { staged: false; reason: 'duplicate'; offerId: string } + +/** + * Stage an inbound invitation. Failed verification stores a SUPPRESSED row + * (logged record [VII.2.7-adjacent]; never listable, never consentable). + */ +export function stageConnectOffer(db: any, input: StageConnectOfferInput): StageConnectOfferResult { + ensureConnectOfferSchema(db) + const existing = db + .prepare(`SELECT offer_id FROM wr_connect_offers WHERE handshake_id = ? AND capsule_hash = ?`) + .get(input.handshake_id, input.capsule_hash) as { offer_id: string } | undefined + if (existing) { + return { staged: false, reason: 'duplicate', offerId: existing.offer_id } + } + const offerId = randomUUID() + const now = Date.now() + const suppressed = input.verification.ok ? 0 : 1 + db.prepare( + `INSERT INTO wr_connect_offers ( + offer_id, handshake_id, capsule_json, capsule_hash, + sender_email, sender_iss, sender_sub, sender_wrdesk_user_id, receiver_email, + profile_id, ingress_path, invitation_class, + verification_status, verification_reason, suppressed, + staged_at, expires_at, + wr_code_canonical, publisher_part, entry_local_part, umbrella_handshake_id, + entry_status, resolution_mode, session_bound_expires_at, + evp_ref, value_statement, catalog_epoch, audit_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + offerId, + input.handshake_id, + JSON.stringify(input.capsule), + input.capsule_hash, + input.sender_email ?? null, + input.sender_iss ?? null, + input.sender_sub ?? null, + input.sender_wrdesk_user_id ?? null, + input.receiver_email ?? null, + input.profile_id, + input.ingress_path, + input.invitation_class ?? 'public_bearer', + input.verification.ok ? 'verified' : 'failed', + input.verification.ok ? null : input.verification.reason, + suppressed, + new Date(now).toISOString(), + new Date(now + INPUT_LIMITS.PENDING_TIMEOUT_MS).toISOString(), + input.wr_code?.wr_code_canonical ?? null, + input.wr_code?.publisher_part ?? null, + input.wr_code?.entry_local_part ?? null, + input.wr_code?.umbrella_handshake_id ?? null, + input.wr_code?.entry_status ?? null, + input.wr_code?.resolution_mode ?? null, + input.wr_code?.session_bound_expires_at ?? null, + input.wr_code?.evp_ref ?? null, + input.wr_code?.value_statement ?? null, + input.wr_code?.catalog_epoch ?? null, + input.wr_code?.audit_url ?? null, + ) + if (suppressed) { + console.warn('[CONNECT_OFFER] Offer suppressed (verification failed):', { + offer_id: offerId, + handshake_id: input.handshake_id, + reason: (input.verification as { reason: string }).reason, + }) + } else { + console.log('[CONNECT_OFFER] Offer staged:', { + offer_id: offerId, + handshake_id: input.handshake_id, + ingress_path: input.ingress_path, + }) + } + return { staged: true, offerId, suppressed: suppressed === 1 } +} + +/** + * Pending = verified, not suppressed, not consumed, not expired. Suppressed + * rows are structurally unreachable from here — the ONLY read surface for + * offer listings. + */ +export function listPendingConnectOffers(db: any, now: Date = new Date()): ConnectOfferRow[] { + ensureConnectOfferSchema(db) + return db + .prepare( + `SELECT * FROM wr_connect_offers + WHERE suppressed = 0 AND verification_status = 'verified' + AND consumed_at IS NULL AND expires_at > ? + ORDER BY staged_at DESC`, + ) + .all(now.toISOString()) as ConnectOfferRow[] +} + +/** Consentable = same predicate as listPendingConnectOffers, single row. */ +export function getConsentableOffer(db: any, offerId: string, now: Date = new Date()): ConnectOfferRow | null { + ensureConnectOfferSchema(db) + const row = db + .prepare( + `SELECT * FROM wr_connect_offers + WHERE offer_id = ? AND suppressed = 0 AND verification_status = 'verified' + AND consumed_at IS NULL AND expires_at > ?`, + ) + .get(offerId, now.toISOString()) as ConnectOfferRow | undefined + return row ?? null +} + +export function findPendingOfferByHandshakeId( + db: any, + handshakeId: string, + now: Date = new Date(), +): ConnectOfferRow | null { + ensureConnectOfferSchema(db) + const row = db + .prepare( + `SELECT * FROM wr_connect_offers + WHERE handshake_id = ? AND suppressed = 0 AND verification_status = 'verified' + AND consumed_at IS NULL AND expires_at > ? + ORDER BY staged_at DESC LIMIT 1`, + ) + .get(handshakeId, now.toISOString()) as ConnectOfferRow | undefined + return row ?? null +} + +export function markOfferConsumed( + db: any, + offerId: string, + action: 'consented' | 'declined', + consentId?: string, +): void { + db.prepare( + `UPDATE wr_connect_offers SET consumed_at = ?, consumed_action = ?, consent_id = ? WHERE offer_id = ?`, + ).run(new Date().toISOString(), action, consentId ?? null, offerId) +} + +/** Q7: sweep past-timeout offers into consumed_action='expired' (idempotent). */ +export function expireStaleOffers(db: any, now: Date = new Date()): number { + ensureConnectOfferSchema(db) + const res = db + .prepare( + `UPDATE wr_connect_offers + SET consumed_at = ?, consumed_action = 'expired' + WHERE consumed_at IS NULL AND expires_at <= ?`, + ) + .run(now.toISOString(), now.toISOString()) + return res.changes as number +} + +// ── Client-generated preview + Hash-Pinned consent [IX.3.4] ────────────────── + +const PREVIEW_DOMAIN = 'wr.connect_offer.preview' +const BOUND_DEF_DOMAIN = 'wr.handshake.bound_definition' + +function sha256Hex(domain: string, canonical: string): string { + return createHash('sha256').update(domainTag(domain, 1)).update(canonical, 'utf8').digest('hex') +} + +export interface ConnectOfferPreview { + /** Canonical preview object — built ONLY from verified capsule material. */ + preview: Record + preview_hash: string + bound_definition_hash: string + /** Contract state at presentation time = the staged capsule hash. */ + contract_state_hash: string +} + +/** + * Build the client-generated Connect-offer preview from a staged offer. + * The preview never contains counterparty free text — only the verified, + * structured identity/profile/scope material — and is canonically hashable + * at presentation time (this is the Intent-Hash substrate Phase 5 reuses). + */ +export function buildConnectOfferPreview(offer: ConnectOfferRow): ConnectOfferPreview { + const capsule = JSON.parse(offer.capsule_json) as Record + const scopes = Array.isArray(capsule?.context_scopes) + ? capsule.context_scopes.filter((s: unknown) => typeof s === 'string') + : [] + const boundDefinition: Record = { + sender_email: offer.sender_email ?? '', + sender_iss: offer.sender_iss ?? '', + sender_sub: offer.sender_sub ?? '', + sender_wrdesk_user_id: offer.sender_wrdesk_user_id ?? '', + receiver_email: offer.receiver_email ?? '', + profile_id: offer.profile_id, + // 4B: whether the publisher domain completed dual-channel validation is + // part of WHO this offer binds, not decoration around it. + publisher_domain_verified: offer.publisher_part != null, + } + + // 4B + delta O2 extension: the preview hash covers the resolved entry, the + // resolution mode, and the EVP material the operator is shown. Consenting to + // a value promise the publisher signed means the hash has to cover that + // promise; otherwise two offers showing different value statements would be + // indistinguishable at consent time. + const entryContext: Record = { + wr_code_canonical: offer.wr_code_canonical ?? '', + publisher_part: offer.publisher_part ?? '', + entry_local_part: offer.entry_local_part ?? '', + entry_status: offer.entry_status ?? '', + umbrella_handshake_id: offer.umbrella_handshake_id ?? '', + catalog_epoch: typeof offer.catalog_epoch === 'number' ? offer.catalog_epoch : 0, + evp_ref: offer.evp_ref ?? '', + value_statement: offer.value_statement ?? '', + } + const preview: Record = { + offer_id: offer.offer_id, + handshake_id: offer.handshake_id, + bound_definition: boundDefinition, + scopes: [...scopes].sort(), + external_processing: typeof capsule?.external_processing === 'string' ? capsule.external_processing : 'none', + reciprocal_allowed: capsule?.reciprocal_allowed === true, + ingress_path: offer.ingress_path, + staged_at: offer.staged_at, + expires_at: offer.expires_at, + entry: entryContext, + resolution_mode: offer.resolution_mode ?? '', + session_bound_expires_at: offer.session_bound_expires_at ?? '', + } + const previewHash = sha256Hex(PREVIEW_DOMAIN, canonicalJsonString(preview)) + const boundDefinitionHash = sha256Hex(BOUND_DEF_DOMAIN, canonicalJsonString(boundDefinition)) + return { + preview, + preview_hash: previewHash, + bound_definition_hash: boundDefinitionHash, + contract_state_hash: offer.capsule_hash, + } +} + +export interface InsertConsentInput { + offer_id: string | null + handshake_id: string + role: 'initiator' | 'acceptor' + preview_hash: string + bound_definition_hash: string + contract_state_hash: string + capture_method: string + ingress_path: string + source_reference?: string | null + actor_wrdesk_user_id: string + /** 4B: how the entry resolved, recorded with the consent it belongs to. */ + resolution_mode?: WrResolutionMode | null +} + +export function insertConsentRecord(db: any, input: InsertConsentInput): ConsentRecordRow { + ensureConnectOfferSchema(db) + const row: ConsentRecordRow = { + consent_id: randomUUID(), + offer_id: input.offer_id, + handshake_id: input.handshake_id, + role: input.role, + preview_hash: input.preview_hash, + bound_definition_hash: input.bound_definition_hash, + contract_state_hash: input.contract_state_hash, + capture_method: input.capture_method, + ingress_path: input.ingress_path, + source_reference: input.source_reference ?? null, + actor_wrdesk_user_id: input.actor_wrdesk_user_id, + consented_at: new Date().toISOString(), + } + db.prepare( + `INSERT INTO wr_consent_records ( + consent_id, offer_id, handshake_id, role, + preview_hash, bound_definition_hash, contract_state_hash, + capture_method, ingress_path, source_reference, + actor_wrdesk_user_id, consented_at, resolution_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + row.consent_id, + row.offer_id, + row.handshake_id, + row.role, + row.preview_hash, + row.bound_definition_hash, + row.contract_state_hash, + row.capture_method, + row.ingress_path, + row.source_reference, + row.actor_wrdesk_user_id, + row.consented_at, + input.resolution_mode ?? null, + ) + return row +} + +export function getConsentRecordForHandshake(db: any, handshakeId: string): ConsentRecordRow | null { + ensureConnectOfferSchema(db) + const row = db + .prepare(`SELECT * FROM wr_consent_records WHERE handshake_id = ? ORDER BY consented_at ASC LIMIT 1`) + .get(handshakeId) as ConsentRecordRow | undefined + return row ?? null +} + +/** + * Hash-Pinned validity [IX.3.4]: a consent record is valid only if all three + * hashes resolve against the material it claims to bind. For acceptor-side + * consents the offer must still exist (suppressed offers cannot resolve — + * they were never presentable). + */ +export function consentRecordResolves( + db: any, + consent: ConsentRecordRow, +): { valid: true } | { valid: false; reason: string } { + if (consent.offer_id) { + const offer = db + .prepare(`SELECT * FROM wr_connect_offers WHERE offer_id = ?`) + .get(consent.offer_id) as ConnectOfferRow | undefined + if (!offer) return { valid: false, reason: 'offer_not_found' } + if (offer.suppressed) return { valid: false, reason: 'offer_suppressed' } + const rebuilt = buildConnectOfferPreview(offer) + if (rebuilt.preview_hash !== consent.preview_hash) { + return { valid: false, reason: 'preview_hash_mismatch' } + } + if (rebuilt.bound_definition_hash !== consent.bound_definition_hash) { + return { valid: false, reason: 'bound_definition_hash_mismatch' } + } + if (rebuilt.contract_state_hash !== consent.contract_state_hash) { + return { valid: false, reason: 'contract_state_hash_mismatch' } + } + return { valid: true } + } + // Initiator-side self-consent: hashes bind the outgoing contract; nothing + // staged to resolve against beyond non-empty pins. + if (!consent.preview_hash || !consent.bound_definition_hash || !consent.contract_state_hash) { + return { valid: false, reason: 'missing_hash_pin' } + } + return { valid: true } +} + +/** + * O6 status re-validation for a staged WR-code offer at consent time. + * + * Non-WR-code offers (no `publisher_part` on the row) are unaffected: they have + * no resolution layers to re-check, so they pass through. For WR-code offers + * the A6 composition is recomputed from the row and admission must still hold. + */ +export function revalidateOfferStatusForConsent( + db: any, + offerId: string, +): { ok: true } | { ok: false; reason: string; error?: string } { + let row: + | { publisher_part?: string | null; entry_status?: string | null; session_bound_expires_at?: string | null } + | undefined + try { + row = db + .prepare( + `SELECT publisher_part, entry_status, session_bound_expires_at + FROM wr_connect_offers WHERE offer_id = ?`, + ) + .get(offerId) as typeof row + } catch { + // Pre-Phase-4 schema: nothing to re-validate. + return { ok: true } + } + if (!row?.publisher_part) return { ok: true } + + if (row.session_bound_expires_at) { + const expires = Date.parse(row.session_bound_expires_at) + if (Number.isFinite(expires) && Date.now() >= expires) { + return { + ok: false, + reason: 'OFFER_RESOLUTION_EXPIRED', + error: 'This offer’s session-bound resolution has expired. Capture the code again.', + } + } + } + + if (row.entry_status && row.entry_status !== 'published') { + return { + ok: false, + reason: 'ENTRY_NOT_PUBLISHED', + error: `This entry is no longer offered (${row.entry_status}).`, + } + } + + return { ok: true } +} + +/** + * Delta v1.1 Phase-5 addition to O6: consent-time CatalogHead re-check. + * + * The status re-check above reads what was recorded at staging. This one is + * about the head itself — epoch, freshness, and platform suspension — because + * between staging and consent a publisher can publish a new epoch, a head can + * go stale, or the platform can suspend the object, and none of those change + * the staged row. + * + * Pure over its inputs: the caller supplies the freshly re-resolved head state + * and the epoch floor. Keeping the network out of here means the rule is + * testable and the fetch policy stays with the resolution client. + */ +export interface ConsentHeadRecheckInput { + /** Epoch recorded on the offer when it was staged. */ + stagedEpoch: number | null + /** Epoch of the head re-resolved at consent time. */ + currentEpoch: number + /** Persisted anti-rollback floor for this publisher. */ + epochFloor: number | null + /** Whether the re-resolved head is inside its freshness window. */ + fresh: boolean + /** Platform suspension observed at consent time, if any. */ + suspended: boolean +} + +export type ConsentHeadRecheckResult = + | { ok: true } + | { ok: false; reason: string; error: string } + +export function recheckCatalogHeadForConsent( + input: ConsentHeadRecheckInput, +): ConsentHeadRecheckResult { + // Anti-rollback first: a lower epoch than the floor is an attack shape, not + // staleness, and must not be reported as merely out of date. + if (input.epochFloor !== null && input.currentEpoch < input.epochFloor) { + return { + ok: false, + reason: 'CATALOG_EPOCH_ROLLBACK', + error: 'The publisher catalog moved backwards. Consent refused.', + } + } + if (input.suspended) { + return { + ok: false, + reason: 'ENTRY_SUSPENDED_AT_CONSENT', + error: 'This entry was suspended by the platform. Consent refused.', + } + } + if (!input.fresh) { + return { + ok: false, + reason: 'CATALOG_HEAD_STALE', + error: 'The publisher catalog is out of date. Try again once it refreshes.', + } + } + // A NEW epoch is not itself a refusal — publishing is normal — but the offer + // was built from the old one, so what the operator saw may no longer be what + // they would get. Re-stage rather than bind them to a stale preview. + if (input.stagedEpoch !== null && input.currentEpoch !== input.stagedEpoch) { + return { + ok: false, + reason: 'CATALOG_EPOCH_MOVED', + error: 'The publisher updated this entry. Review the new offer before consenting.', + } + } + return { ok: true } +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/contextSyncEnqueue.ts b/code/apps/electron-vite-project/electron/main/handshake/contextSyncEnqueue.ts index d9d9d90af..a72aaea8a 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/contextSyncEnqueue.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/contextSyncEnqueue.ts @@ -106,7 +106,7 @@ export function tryEnqueueContextSync( localCoordId = '' } const internalRelayWire = internalRelayCapsuleWireOptsFromRecord(record, localCoordId) - if (record.handshake_type === 'internal' && !internalRelayWire) { + if (record.same_principal === true && !internalRelayWire) { console.warn('[ContextSync] INTERNAL_RELAY_ENDPOINTS_INCOMPLETE:', handshakeId) // Do not let callers treat this as "our context_sync is out" — ownSent in // buildContextSyncRecord uses last_seq_sent >= 1, not `context_sync_pending` alone, for the ACTIVE gate. @@ -222,6 +222,7 @@ export function tryEnqueueContextSync( local_private_key: localPriv, peerX25519PublicKeyB64: record.peer_x25519_public_key_b64, localRole: record.local_role, + counterpartyIdentity: record.local_role === 'initiator' ? record.acceptor : record.initiator, ...(record.local_p2p_auth_token?.trim() ? { p2p_auth_token: record.local_p2p_auth_token.trim() } : {}), ...(internalRelayWire ?? {}), }) @@ -298,6 +299,7 @@ export function maybeEnqueueInitialContextSyncAfterInboundAccept( if (ct !== 'accept') return const r = args.handshakeResult.handshakeRecord + if (!r) return const logLine = (payload: Record): void => { console.log('[POST_ACCEPT_CONTEXT_SYNC]', JSON.stringify(payload)) } @@ -377,7 +379,7 @@ export function retryDeferredInitialContextSyncForInternalHandshake( if (!db || !session) return const r0 = getHandshakeRecord(db, handshakeId) if (!r0) return - if (r0.handshake_type !== 'internal' || r0.state !== 'ACCEPTED' || !r0.context_sync_pending) { + if (r0.same_principal !== true || r0.state !== 'ACCEPTED' || !r0.context_sync_pending) { return } const result = tryEnqueueContextSync(db, handshakeId, session, { diff --git a/code/apps/electron-vite-project/electron/main/handshake/coreStore.ts b/code/apps/electron-vite-project/electron/main/handshake/coreStore.ts new file mode 100644 index 000000000..c22a3bcb2 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/coreStore.ts @@ -0,0 +1,414 @@ +/** + * WR Handshake core store + runtime split (Phase 3 — G1–G3) [XI.LB§6 seam] + * + * Two new tables via the existing migration runner (v75, NEVER applied to a + * frozen ledger handle — see LEDGER_SCHEMA_FREEZE_VERSION in db.ts): + * + * - `wr_handshake_core` — APPEND-ONLY, immutable, hash-stable. One row per + * relationship: the frozen signed core (canonical JSON + detached + * signature list). SQLite triggers abort every UPDATE/DELETE, so + * immutability is enforced by the store itself, not by writer discipline. + * - `wr_handshake_runtime` — mutable operational state (seq counters, + * tokens, endpoints, policy resolution, repair flags), keyed by + * handshake and referencing the core row by hash. + * + * Transition window (documented rollback plan in phase-3 report): + * - The legacy `handshakes` table REMAINS THE READ AUTHORITY. Existing + * dialects keep writing it; a thin adapter (called from the single + * insert/update writers in db.ts) dual-writes the core + runtime rows + * whenever this store exists on the handle. Eliminating the dialects and + * flipping the read authority is Phase 4+. + * - Rollback = stop consulting the new tables; `handshakes` never stopped + * being complete. The core store is additive and append-only, so rolling + * back loses nothing and corrupts nothing. + * + * Backfill (G2): one synthetic core record per existing row, marked + * `legacy_v0` (Q2), `ingress_path = null`, capture provenance + * `unknown_legacy` — NEVER fabricated signatures, countersignatures, or + * provenance (the signature list is empty; `backfilled = 1`). + * + * Anti-rollback (Phase 2 → Phase 3 consumer): every core insert passes the + * generic high-water gate under object class 'wr.handshake.core'. + */ + +import { createHash } from 'node:crypto' +import { canonicalJsonString, canonicalJsonBytes } from '@repo/ingestion-core' +import type { CanonicalJsonValue, CorePartyId, CoreSignature, WrHandshakeCore } from '@repo/ingestion-core' +import { enforceHighWater } from './antiRollback' +import type { HandshakeRecord, PartyIdentity } from './types' + +export const WR_CORE_OBJECT_CLASS = 'wr.handshake.core' + +/** Non-critical declaration namespace marking a backfilled legacy core. */ +export const LEGACY_BACKFILL_NS = 'optirando.decl.legacy_backfill' + +// ── Store presence ──────────────────────────────────────────────────────────── + +/** True when the core store exists on this handle (post-v75, non-frozen). */ +export function hasWrCoreStore(db: any): boolean { + try { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'wr_handshake_core'") + .get() + return !!row + } catch { + return false + } +} + +// ── Hash-stable identity ────────────────────────────────────────────────────── + +/** sha256(canonical bytes) — the store key; stable across restarts/migrations. */ +export function computeCoreStoreHash(core: WrHandshakeCore): string { + return createHash('sha256') + .update(canonicalJsonBytes(core as unknown as CanonicalJsonValue)) + .digest('hex') +} + +// ── Synthetic legacy core (backfill + transition adapter) ───────────────────── + +function partyToCoreId(party: PartyIdentity | null | undefined): CorePartyId | null { + if (!party) return null + return { + sub: party.sub ?? '', + iss: party.iss ?? '', + email: party.email ?? '', + wrdesk_user_id: party.wrdesk_user_id ?? '', + } +} + +/** + * Build the synthetic `legacy_v0` core for an existing relationship row. + * Deterministic over the row's IMMUTABLE identity fields only (parties, + * relationship id, creation instant) so the hash is stable across re-runs; + * mutable state lives in the runtime row. `nonce` is empty — a legacy row + * has no recorded formation nonce and none is fabricated. + */ +export function buildSyntheticLegacyCore(record: HandshakeRecord): WrHandshakeCore { + return { + profile: { id: 'legacy_v0', version: 1 }, + initiator_id: partyToCoreId(record.initiator), + responder_id: partyToCoreId(record.acceptor), + ingress_path: null, + declarations: [ + { + ns: LEGACY_BACKFILL_NS, + version: 1, + critical: false, + payload: { + handshake_id: record.handshake_id, + relationship_id: record.relationship_id, + created_at: record.created_at, + }, + }, + ], + extensions: [], + created_at: record.created_at, + nonce: '', + } as unknown as WrHandshakeCore +} + +// ── Formation core (Phase 4 — the one pipeline) ────────────────────────────── + +/** Declaration namespace carrying capture provenance [IX.3.1 rule 5]. */ +export const CAPTURE_PROVENANCE_NS = 'optirando.decl.capture_provenance' + +/** + * Formation metadata recorded by the ONE pipeline on NEW formations only. + * Backfilled rows keep `unknown_legacy` provenance and a null ingress path — + * provenance is never fabricated. + */ +export interface FormationMeta { + profile_id: string + profile_version: number + /** Recordable ingress registry identifier (Q4 mapping; log-only downstream). */ + ingress_path: string + capture_method: string + source_reference?: string | null + /** Hash-pinned consent record id [IX.3.4]. */ + consent_id?: string | null + nonce?: string +} + +/** + * Build the REAL core for a new formation: profile from the registry, + * ingress_path recorded (log-only), capture provenance as a signed contract + * declaration rendered in the consent preview and recorded in evidence. + */ +export function buildFormationCore(record: HandshakeRecord, formation: FormationMeta): WrHandshakeCore { + return { + profile: { id: formation.profile_id, version: formation.profile_version }, + initiator_id: partyToCoreId(record.initiator), + responder_id: partyToCoreId(record.acceptor), + ingress_path: formation.ingress_path, + declarations: [ + { + ns: CAPTURE_PROVENANCE_NS, + version: 1, + critical: false, + payload: { + method: formation.capture_method, + source_reference: formation.source_reference ?? null, + handshake_id: record.handshake_id, + relationship_id: record.relationship_id, + created_at: record.created_at, + ...(formation.consent_id ? { consent_id: formation.consent_id } : {}), + }, + }, + ], + extensions: [], + created_at: record.created_at, + nonce: formation.nonce ?? '', + } as unknown as WrHandshakeCore +} + +// ── Writers (single entry, called from db.ts) ──────────────────────────────── + +export interface InsertCoreArgs { + core: WrHandshakeCore + handshakeId: string + signatures: CoreSignature[] + captureProvenance: string + backfilled: boolean + /** Monotonic core version for the anti-rollback gate; 1 until supersession exists. */ + coreVersion?: number +} + +export type InsertCoreResult = + | { ok: true; coreHash: string; inserted: boolean } + | { ok: false; reason: 'rollback'; highWater: number } + +/** + * Append a core record. Idempotent per handshake: if a core row already + * exists for the handshake it is NEVER touched (append-only; re-insertion of + * the identical core is a no-op, a differing core for the same handshake is + * refused — cores are immutable, "convert" means a new handshake [VII.3.3]). + */ +export function insertCoreRecord(db: any, args: InsertCoreArgs): InsertCoreResult { + const coreVersion = args.coreVersion ?? 1 + const gate = enforceHighWater(db, WR_CORE_OBJECT_CLASS, args.handshakeId, coreVersion) + if (!gate.ok) return { ok: false, reason: 'rollback', highWater: gate.highWater } + + const existing = db + .prepare('SELECT core_hash FROM wr_handshake_core WHERE handshake_id = ?') + .get(args.handshakeId) as { core_hash: string } | undefined + const coreHash = computeCoreStoreHash(args.core) + if (existing) { + if (existing.core_hash !== coreHash) { + console.warn('[WR-CORE] Refusing differing core for existing handshake (immutable):', { + handshake_id: args.handshakeId, + }) + } + return { ok: true, coreHash: existing.core_hash, inserted: false } + } + + db.prepare( + `INSERT INTO wr_handshake_core ( + core_hash, handshake_id, profile_id, profile_version, core_version, + core_json, signatures_json, capture_provenance, backfilled, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + coreHash, + args.handshakeId, + args.core.profile.id, + args.core.profile.version, + coreVersion, + canonicalJsonString(args.core as unknown as CanonicalJsonValue), + JSON.stringify(args.signatures), + args.captureProvenance, + args.backfilled ? 1 : 0, + args.core.created_at, + ) + return { ok: true, coreHash, inserted: true } +} + +/** Mirror the mutable runtime slice of a relationship row (upsert). */ +export function upsertRuntimeFromRecord(db: any, record: HandshakeRecord, coreHash: string): void { + db.prepare( + `INSERT INTO wr_handshake_runtime ( + handshake_id, core_hash, state, sharing_mode, + last_seq_sent, last_seq_received, last_capsule_hash_sent, last_capsule_hash_received, + p2p_endpoint, local_p2p_auth_token, counterparty_p2p_token, + effective_policy_json, repair_flags_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(handshake_id) DO UPDATE SET + state = excluded.state, + sharing_mode = excluded.sharing_mode, + last_seq_sent = excluded.last_seq_sent, + last_seq_received = excluded.last_seq_received, + last_capsule_hash_sent = excluded.last_capsule_hash_sent, + last_capsule_hash_received = excluded.last_capsule_hash_received, + p2p_endpoint = excluded.p2p_endpoint, + local_p2p_auth_token = excluded.local_p2p_auth_token, + counterparty_p2p_token = excluded.counterparty_p2p_token, + effective_policy_json = excluded.effective_policy_json, + repair_flags_json = excluded.repair_flags_json, + updated_at = excluded.updated_at`, + ).run( + record.handshake_id, + coreHash, + record.state, + record.sharing_mode ?? null, + record.last_seq_sent ?? 0, + record.last_seq_received ?? 0, + record.last_capsule_hash_sent ?? null, + record.last_capsule_hash_received ?? null, + record.p2p_endpoint ?? null, + record.local_p2p_auth_token ?? null, + record.counterparty_p2p_token ?? null, + record.effective_policy ? JSON.stringify(record.effective_policy) : null, + (record as any).internal_coordination_repair_needed !== undefined + ? JSON.stringify({ internal_coordination_repair_needed: (record as any).internal_coordination_repair_needed }) + : null, + new Date().toISOString(), + ) +} + +/** + * Transition adapter — the ONE hook the legacy writers call. Produces the + * core (if absent) + mirrors runtime state. No-op on handles without the + * store (frozen ledger, pre-v75, mock DBs). + */ +export function adaptRecordToCoreStore( + db: any, + record: HandshakeRecord, + opts?: { backfilled?: boolean; formation?: FormationMeta }, +): void { + if (!hasWrCoreStore(db)) return + try { + // New formations through the one pipeline carry real formation metadata; + // everything else (updates, legacy writers) produces/keeps the synthetic + // legacy core with unfabricated provenance. + const formation = opts?.formation + const core = formation ? buildFormationCore(record, formation) : buildSyntheticLegacyCore(record) + const result = insertCoreRecord(db, { + core, + handshakeId: record.handshake_id, + signatures: [], + captureProvenance: formation + ? JSON.stringify({ + method: formation.capture_method, + source_reference: formation.source_reference ?? null, + ingress_path: formation.ingress_path, + }) + : 'unknown_legacy', + backfilled: opts?.backfilled ?? false, + coreVersion: 1, + }) + if (result.ok) upsertRuntimeFromRecord(db, record, result.coreHash) + } catch (e: any) { + // The legacy store remains the read authority during the transition — + // a core-store failure must not fail the relationship write. + console.warn('[WR-CORE] adapter write failed (legacy store unaffected):', e?.message) + } +} + +/** Delete the runtime mirror (operator delete of a relationship). Core rows survive. */ +export function deleteRuntimeRow(db: any, handshakeId: string): void { + if (!hasWrCoreStore(db)) return + try { + db.prepare('DELETE FROM wr_handshake_runtime WHERE handshake_id = ?').run(handshakeId) + } catch { /* runtime mirror only */ } +} + +// ── Readers ─────────────────────────────────────────────────────────────────── + +export interface WrCoreRow { + core_hash: string + handshake_id: string + profile_id: string + profile_version: number + core_version: number + core_json: string + signatures_json: string + capture_provenance: string + backfilled: number + created_at: string +} + +export function getCoreRow(db: any, handshakeId: string): WrCoreRow | null { + try { + return ( + (db.prepare('SELECT * FROM wr_handshake_core WHERE handshake_id = ?').get(handshakeId) as WrCoreRow | undefined) ?? + null + ) + } catch { + return null + } +} + +export function getRuntimeRow(db: any, handshakeId: string): Record | null { + try { + return ( + (db.prepare('SELECT * FROM wr_handshake_runtime WHERE handshake_id = ?').get(handshakeId) as + | Record + | undefined) ?? null + ) + } catch { + return null + } +} + +/** Recompute a stored core row's hash from its canonical JSON (integrity check). */ +export function verifyCoreRowHash(row: WrCoreRow): boolean { + try { + const core = JSON.parse(row.core_json) as WrHandshakeCore + return computeCoreStoreHash(core) === row.core_hash + } catch { + return false + } +} + +// ── Backfill (G2) ───────────────────────────────────────────────────────────── + +export interface BackfillSummary { + scanned: number + backfilled: number + alreadyPresent: number + failed: number +} + +/** + * One synthetic `legacy_v0` core + runtime row per existing relationship row + * that has none. Idempotent — re-runs skip existing cores. Runs inside one + * transaction (single-writer discipline; WAL checkpoint is the migration + * runner's job). + */ +export function backfillWrCoreStore( + db: any, + listRecords: (db: any) => HandshakeRecord[], +): BackfillSummary { + const summary: BackfillSummary = { scanned: 0, backfilled: 0, alreadyPresent: 0, failed: 0 } + if (!hasWrCoreStore(db)) return summary + const tx = db.transaction(() => { + for (const record of listRecords(db)) { + summary.scanned++ + try { + if (getCoreRow(db, record.handshake_id)) { + summary.alreadyPresent++ + continue + } + const core = buildSyntheticLegacyCore(record) + const result = insertCoreRecord(db, { + core, + handshakeId: record.handshake_id, + signatures: [], + captureProvenance: 'unknown_legacy', + backfilled: true, + coreVersion: 1, + }) + if (result.ok) { + upsertRuntimeFromRecord(db, record, result.coreHash) + summary.backfilled++ + } else { + summary.failed++ + } + } catch (e: any) { + summary.failed++ + console.warn('[WR-CORE] backfill failed for row:', record.handshake_id, e?.message) + } + } + }) + tx() + return summary +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/db.ts b/code/apps/electron-vite-project/electron/main/handshake/db.ts index 7407ddbae..ed07a15f3 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/db.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/db.ts @@ -19,6 +19,9 @@ import type { } from './types' import { finalizeInternalHandshakePersistence } from './internalPersistence' import { logHandshakeKeyBinding, warnIfCounterpartyKeySuspiciousOverwrite } from './keyBindingDebug' +import { adaptRecordToCoreStore, backfillWrCoreStore, deleteRuntimeRow, hasWrCoreStore, type FormationMeta } from './coreStore' +import { appendEvidenceBestEffort, poacFormationPayload } from './evidenceChain' +import { createGrant } from './grants' // ── Migration ── @@ -1228,8 +1231,196 @@ const HANDSHAKE_MIGRATIONS: Array<{ `UPDATE p2p_config SET coordination_ws_url = 'wss://relay.optirando.com/beap/ws' WHERE coordination_ws_url IN ('wss://relay.wrdesk.com/beap/ws', 'wss://coordination.wrdesk.com/beap/ws')`, ], }, + { + version: 73, + description: + 'Schema v73 (WR Handshake Phase 2, G6): key extraction — private key material moves out of relationship ' + + 'rows into the dedicated handshake_key_store. Copy-before-null inside one transaction (rollback-safe); ' + + 'old columns retained but nulled (no SQLite column drops pre-rebuild). The INSERT is idempotent ' + + '(ON CONFLICT DO NOTHING) so a re-run never overwrites extracted keys with the nulled columns.', + sql: [ + `CREATE TABLE IF NOT EXISTS handshake_key_store ( + handshake_id TEXT PRIMARY KEY, + local_private_key TEXT, + local_x25519_private_key_b64 TEXT, + local_mlkem768_secret_key_b64 TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `INSERT INTO handshake_key_store ( + handshake_id, local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64, + created_at, updated_at + ) + SELECT handshake_id, local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64, + datetime('now'), datetime('now') + FROM handshakes + WHERE local_private_key IS NOT NULL + OR local_x25519_private_key_b64 IS NOT NULL + OR local_mlkem768_secret_key_b64 IS NOT NULL + ON CONFLICT(handshake_id) DO NOTHING`, + `UPDATE handshakes + SET local_private_key = NULL, + local_x25519_private_key_b64 = NULL, + local_mlkem768_secret_key_b64 = NULL + WHERE local_private_key IS NOT NULL + OR local_x25519_private_key_b64 IS NOT NULL + OR local_mlkem768_secret_key_b64 IS NOT NULL`, + ], + }, + { + version: 74, + description: + 'Schema v74 (WR Handshake Phase 2, G4 + A1): generic anti-rollback high-water store keyed by ' + + '(object_class, object_id) [IX.4.2, X.7.8] and the core nonce store for freshness/replay checks ' + + '[VII.3.1]. Consumers arrive over Phases 3–6; the stores land now. Both tables live in the same DB ' + + 'as the objects they guard so a coherent snapshot restore keeps store and data consistent ' + + '(backup/restore semantics: phase-2 report §5).', + sql: [ + `CREATE TABLE IF NOT EXISTS wr_high_water_versions ( + object_class TEXT NOT NULL, + object_id TEXT NOT NULL, + high_water_version INTEGER NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (object_class, object_id) + )`, + `CREATE TABLE IF NOT EXISTS wr_core_nonces ( + scope TEXT NOT NULL, + nonce TEXT NOT NULL, + bound_hash TEXT, + seen_at TEXT NOT NULL, + PRIMARY KEY (scope, nonce) + )`, + ], + }, + { + version: 75, + description: + 'Schema v75 (WR Handshake Phase 3, G1–G3): core store + runtime split [XI.LB§6]. wr_handshake_core is ' + + 'APPEND-ONLY (UPDATE/DELETE aborted by triggers — immutability is store-enforced, not writer discipline); ' + + 'wr_handshake_runtime carries the mutable operational slice keyed by handshake. NEVER an in-place ALTER of ' + + 'handshakes: the legacy table stays the read authority during the transition window and becomes read-only ' + + 'in Phase 4. This migration is NOT applied to the frozen ledger handle (G5 — LEDGER_SCHEMA_FREEZE_VERSION).', + sql: [ + `CREATE TABLE IF NOT EXISTS wr_handshake_core ( + core_hash TEXT PRIMARY KEY, + handshake_id TEXT NOT NULL UNIQUE, + profile_id TEXT NOT NULL, + profile_version INTEGER NOT NULL, + core_version INTEGER NOT NULL DEFAULT 1, + core_json TEXT NOT NULL, + signatures_json TEXT NOT NULL, + capture_provenance TEXT NOT NULL DEFAULT 'unknown_legacy', + backfilled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )`, + `CREATE TRIGGER IF NOT EXISTS trg_wr_core_no_update + BEFORE UPDATE ON wr_handshake_core + BEGIN + SELECT RAISE(ABORT, 'wr_handshake_core is append-only'); + END`, + `CREATE TRIGGER IF NOT EXISTS trg_wr_core_no_delete + BEFORE DELETE ON wr_handshake_core + BEGIN + SELECT RAISE(ABORT, 'wr_handshake_core is append-only'); + END`, + `CREATE TABLE IF NOT EXISTS wr_handshake_runtime ( + handshake_id TEXT PRIMARY KEY, + core_hash TEXT NOT NULL, + state TEXT NOT NULL, + sharing_mode TEXT, + last_seq_sent INTEGER NOT NULL DEFAULT 0, + last_seq_received INTEGER NOT NULL DEFAULT 0, + last_capsule_hash_sent TEXT, + last_capsule_hash_received TEXT, + p2p_endpoint TEXT, + local_p2p_auth_token TEXT, + counterparty_p2p_token TEXT, + effective_policy_json TEXT, + repair_flags_json TEXT, + updated_at TEXT NOT NULL + )`, + ], + }, + { + version: 76, + description: + 'Schema v76 (WR Handshake Phase 5, E2–E4): grant objects [VII.10.x]. Distinct, receiver-enforced right ' + + 'objects (delivery / preparation — deliberately NO execute variant) replacing the flattened ' + + 'effective_policy + sharing_mode bit as the enforcement authority. Created only behind an explicit consent ' + + 'screen (consent_id → Hash-Pinned consent record); unlimited-until-revoke ground state; revocation kills ' + + 'all rights via the receiver-side ingress filter. NOT applied to the frozen ledger handle.', + sql: [ + `CREATE TABLE IF NOT EXISTS wr_grants ( + grant_id TEXT PRIMARY KEY, + handshake_id TEXT NOT NULL, + grant_type TEXT NOT NULL CHECK (grant_type IN ('delivery', 'preparation')), + direction TEXT NOT NULL DEFAULT 'inbound' CHECK (direction IN ('inbound', 'outbound')), + scopes_json TEXT NOT NULL, + limit_extensions_json TEXT, + consent_id TEXT, + backfilled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + revoked_at TEXT, + revoke_reason TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_wr_grants_handshake ON wr_grants (handshake_id, grant_type, revoked_at)`, + `CREATE TABLE IF NOT EXISTS wr_grant_offscope_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + handshake_id TEXT NOT NULL, + grant_id TEXT, + scope TEXT, + kind TEXT NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_wr_grant_offscope_handshake ON wr_grant_offscope_events (handshake_id)`, + ], + }, + { + version: 77, + description: + 'Schema v77 (WR Code Phase 3 / A3): wrc_publisher_epoch_floor — per-publisher ' + + 'anti-rollback floor for WRC CatalogHead epochs. This is TRUST state, not cache: ' + + 'the rest of the resolved record may be evicted freely, but the floor must never ' + + 'move down, and deleting or editing a userData file must not be able to reset it. ' + + 'It therefore lives in the native DB rather than the plain-JSON resolved-record ' + + 'store. Insert-or-raise only; there is no lowering path in schema or code.', + sql: [ + `CREATE TABLE IF NOT EXISTS wrc_publisher_epoch_floor ( + publisher_part TEXT PRIMARY KEY, + epoch_floor INTEGER NOT NULL, + updated_at TEXT NOT NULL + )`, + ], + }, ] +/** + * G5 — ledger freeze. The `handshake-ledger.db` handle stops receiving full + * handshake migrations at this version: v75+ (core store split and everything + * after) never lands on the ledger. Its repurposing as the Tier-L evidence + * home is Phase 5 (Q10). + */ +export const LEDGER_SCHEMA_FREEZE_VERSION = 74 + +/** + * Every table name the handshake migration chain (≤ maxVersion) can create. + * Source of truth for the ledger hygiene audit (G5): anything on a handle + * beyond this set + the ledger-native tables is undocumented. + */ +export function documentedHandshakeTableNames(maxVersion?: number): Set { + const names = new Set(['handshake_schema_migrations']) + for (const migration of HANDSHAKE_MIGRATIONS) { + if (maxVersion !== undefined && migration.version > maxVersion) continue + for (const sql of migration.sql) { + for (const match of sql.matchAll(/CREATE TABLE(?: IF NOT EXISTS)?\s+(\w+)/gi)) { + names.add(match[1]) + } + } + } + return names +} + /** * Canonical columns for the email / inbox / sync pipeline. Repairs partial tables where * `CREATE TABLE IF NOT EXISTS` skipped full DDL (legacy or manual DB). Each ALTER is @@ -1454,7 +1645,28 @@ export function ensureEmailPipelineSchemaRepairs(db: any): void { } } -export function migrateHandshakeTables(db: any): void { +/** + * G5 — a frozen handle carries its freeze as DATA (`ledger_meta.wr_schema_freeze`, + * written by openLedger), so every migration entry point respects it — including + * lazy `migrateHandshakeTables(db)` calls that don't know which handle they got + * (e.g. the ingestion IPC layer). Handles without ledger_meta are never frozen. + */ +function detectPersistedFreezeVersion(db: any): number | undefined { + try { + const row = db + .prepare("SELECT value FROM ledger_meta WHERE key = 'wr_schema_freeze'") + .get() as { value: string } | undefined + if (row) { + const v = Number(row.value) + if (Number.isSafeInteger(v) && v > 0) return v + } + } catch { + // No ledger_meta table — not a ledger handle. + } + return undefined +} + +export function migrateHandshakeTables(db: any, options?: { freezeAtVersion?: number }): void { // Ensure migrations table exists first try { db.prepare(`CREATE TABLE IF NOT EXISTS handshake_schema_migrations ( @@ -1466,7 +1678,14 @@ export function migrateHandshakeTables(db: any): void { console.warn('[HANDSHAKE DB] Could not create migrations table:', e?.message) } + const freezeAtVersion = options?.freezeAtVersion ?? detectPersistedFreezeVersion(db) + for (const migration of HANDSHAKE_MIGRATIONS) { + // G5 — frozen handles (the ledger) never receive migrations past the + // freeze version. Fail-closed on the schema, not on the data: existing + // tables keep working; new WR core tables never appear here. + if (freezeAtVersion !== undefined && migration.version > freezeAtVersion) continue + // Check if already applied try { const row = db.prepare( @@ -1499,6 +1718,39 @@ export function migrateHandshakeTables(db: any): void { } ensureEmailPipelineSchemaRepairs(db) + + // Phase 5 (H1 hygiene) [IX.19.1]: audit_log is frozen for mutation — INSERT + // stays open (it remains the operational audit sink) but existing rows are + // read-only for forensics. Applied on every open (idempotent, not part of + // the version chain) so BOTH handles get it, including the frozen ledger. + try { + db.prepare( + `CREATE TRIGGER IF NOT EXISTS trg_audit_log_no_update + BEFORE UPDATE ON audit_log + BEGIN SELECT RAISE(ABORT, 'audit_log rows are read-only (forensic freeze)'); END`, + ).run() + db.prepare( + `CREATE TRIGGER IF NOT EXISTS trg_audit_log_no_delete + BEFORE DELETE ON audit_log + BEGIN SELECT RAISE(ABORT, 'audit_log rows are read-only (forensic freeze)'); END`, + ).run() + } catch (e: any) { + console.warn('[HANDSHAKE DB] audit_log freeze triggers warning:', e?.message) + } + + // Phase 3 (G2) — backfill: one synthetic legacy_v0 core per existing + // relationship row that has none. Idempotent; never runs on frozen handles + // (they never got the v75 tables). Never fabricates signatures/provenance. + if (freezeAtVersion === undefined && hasWrCoreStore(db)) { + try { + const summary = backfillWrCoreStore(db, (h) => listHandshakeRecords(h)) + if (summary.backfilled > 0 || summary.failed > 0) { + console.log('[WR-CORE] legacy_v0 backfill:', summary) + } + } catch (e: any) { + console.warn('[WR-CORE] backfill skipped:', e?.message) + } + } } // ── Post-migration backfill: local_x25519_public_key_b64 ────────────────────────────────────── @@ -1538,6 +1790,17 @@ export function backfillLocalX25519PublicKey( return result } + // Phase 2 (G6): post-v73 the row column is NULL — the private key lives + // in handshake_key_store. Overlay before deriving. + for (const row of rows) { + if (!row.local_x25519_private_key_b64?.trim()) { + const keys = getHandshakeKeys(db, row.handshake_id) + if (keys?.local_x25519_private_key_b64?.trim()) { + row.local_x25519_private_key_b64 = keys.local_x25519_private_key_b64 + } + } + } + for (const row of rows) { // Row-level re-check: re-read the field immediately before writing to guard against // any TOCTOU window between the batch SELECT and this UPDATE. If it was populated @@ -1665,7 +1928,9 @@ export function serializeHandshakeRecord(record: HandshakeRecord): any { local_x25519_public_key_b64: record.local_x25519_public_key_b64 ?? null, local_mlkem768_secret_key_b64: record.local_mlkem768_secret_key_b64 ?? null, local_mlkem768_public_key_b64: record.local_mlkem768_public_key_b64 ?? null, - handshake_type: record.handshake_type ?? null, + // SINGLE column-compat write (Phase 4, Q9): the frozen legacy column + // persists the profile-derived same_principal parameter. + handshake_type: record.same_principal === true ? 'internal' : null, initiator_device_name: record.initiator_device_name ?? null, acceptor_device_name: record.acceptor_device_name ?? null, initiator_device_role: record.initiator_device_role ?? null, @@ -1727,7 +1992,9 @@ export function deserializeHandshakeRecord(row: any): HandshakeRecord { local_mlkem768_public_key_b64: row.local_mlkem768_public_key_b64 ?? null, context_sync_pending: !!(row.context_sync_pending), policy_selections: parsePolicySelections(row.policy_selections), - handshake_type: row.handshake_type ?? null, + // SINGLE column-compat read (Phase 4, Q9): legacy column → profile-derived + // same_principal parameter. No other code reads the column value. + same_principal: row.handshake_type === 'internal', initiator_device_name: row.initiator_device_name ?? null, acceptor_device_name: row.acceptor_device_name ?? null, initiator_device_role: row.initiator_device_role ?? null, @@ -1791,6 +2058,112 @@ export function updateHandshakePolicySelections( } } +// ── Handshake key store (Phase 2, G6) ───────────────────────────────────────── +// Private key material lives in handshake_key_store, not in relationship rows. +// Readers overlay these values onto HandshakeRecord so every runtime consumer +// (signing, X25519 ECDH, ML-KEM decapsulation) keeps working unchanged. + +export interface HandshakeKeyMaterial { + local_private_key: string | null + local_x25519_private_key_b64: string | null + local_mlkem768_secret_key_b64: string | null +} + +/** + * Read key material for one handshake. Returns null when the store has no + * row (or does not exist yet on a pre-v73 / mock DB — callers fall back to + * the legacy row columns, which still carry keys exactly in that case). + */ +export function getHandshakeKeys(db: any, handshakeId: string): HandshakeKeyMaterial | null { + try { + const row = db.prepare( + `SELECT local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64 + FROM handshake_key_store WHERE handshake_id = ?`, + ).get(handshakeId) as HandshakeKeyMaterial | undefined + return row ?? null + } catch { + return null + } +} + +/** + * Upsert key material. Value-preserving on partial updates: a null field + * keeps the stored value (COALESCE) — keys are never silently erased through + * a record update that lacks them. + */ +export function upsertHandshakeKeys( + db: any, + handshakeId: string, + keys: Partial, +): void { + const hasAny = + keys.local_private_key != null || + keys.local_x25519_private_key_b64 != null || + keys.local_mlkem768_secret_key_b64 != null + if (!hasAny) return + try { + db.prepare( + `INSERT INTO handshake_key_store ( + handshake_id, local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64, + created_at, updated_at + ) VALUES (?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(handshake_id) DO UPDATE SET + local_private_key = COALESCE(excluded.local_private_key, handshake_key_store.local_private_key), + local_x25519_private_key_b64 = COALESCE(excluded.local_x25519_private_key_b64, handshake_key_store.local_x25519_private_key_b64), + local_mlkem768_secret_key_b64 = COALESCE(excluded.local_mlkem768_secret_key_b64, handshake_key_store.local_mlkem768_secret_key_b64), + updated_at = datetime('now')`, + ).run( + handshakeId, + keys.local_private_key ?? null, + keys.local_x25519_private_key_b64 ?? null, + keys.local_mlkem768_secret_key_b64 ?? null, + ) + } catch (e: any) { + // Pre-v73 / mock DBs without the table: keys remain on the row columns + // (serializeHandshakeRecord nulls them only when the store write works, + // see overlayKeysFromStore fallback). Surface anything else. + if (!e?.message?.includes('no such table')) throw e + } +} + +/** Overlay key material from the store onto a deserialized record. */ +function overlayKeysFromStore(db: any, record: HandshakeRecord): HandshakeRecord { + const keys = getHandshakeKeys(db, record.handshake_id) + if (!keys) return record + return { + ...record, + local_private_key: keys.local_private_key ?? record.local_private_key ?? null, + local_x25519_private_key_b64: keys.local_x25519_private_key_b64 ?? record.local_x25519_private_key_b64 ?? null, + local_mlkem768_secret_key_b64: keys.local_mlkem768_secret_key_b64 ?? record.local_mlkem768_secret_key_b64 ?? null, + } +} + +/** Batch overlay for list reads — one key-store query per call. */ +function overlayKeysFromStoreBatch(db: any, records: HandshakeRecord[]): HandshakeRecord[] { + if (records.length === 0) return records + let rows: Array + try { + rows = db.prepare( + `SELECT handshake_id, local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64 + FROM handshake_key_store`, + ).all() as Array + } catch { + return records + } + if (rows.length === 0) return records + const byId = new Map(rows.map((r) => [r.handshake_id, r])) + return records.map((record) => { + const keys = byId.get(record.handshake_id) + if (!keys) return record + return { + ...record, + local_private_key: keys.local_private_key ?? record.local_private_key ?? null, + local_x25519_private_key_b64: keys.local_x25519_private_key_b64 ?? record.local_x25519_private_key_b64 ?? null, + local_mlkem768_secret_key_b64: keys.local_mlkem768_secret_key_b64 ?? record.local_mlkem768_secret_key_b64 ?? null, + } + }) +} + export function updateHandshakeSigningKeys( db: any, handshakeId: string, @@ -1798,7 +2171,26 @@ export function updateHandshakeSigningKeys( ): void { db.prepare( 'UPDATE handshakes SET local_public_key = ?, local_private_key = ? WHERE handshake_id = ?', - ).run(keys.local_public_key, keys.local_private_key, handshakeId) + ).run(keys.local_public_key, hasKeyStore(db) ? null : keys.local_private_key, handshakeId) + upsertHandshakeKeys(db, handshakeId, { local_private_key: keys.local_private_key }) +} + +/** + * True when the dedicated key store exists on this DB handle (post-v73). + * Checked via sqlite_master (positive evidence) rather than probing the + * table itself: mock DBs used in tests return undefined instead of throwing + * for unknown tables, which a probe-style check would misread as "store + * present" and silently route keys into a void. + */ +function hasKeyStore(db: any): boolean { + try { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'handshake_key_store'") + .get() + return !!row + } catch { + return false + } } export function updateHandshakeCounterpartyKey( @@ -1823,7 +2215,7 @@ export function updateHandshakeCounterpartyKey( ).run(counterparty_public_key, handshakeId) } -export function insertHandshakeRecord(db: any, record: HandshakeRecord): void { +export function insertHandshakeRecord(db: any, record: HandshakeRecord, formation?: FormationMeta): void { logHandshakeKeyBinding({ source_function: 'insertHandshakeRecord', handshake_id: record.handshake_id, @@ -1833,7 +2225,20 @@ export function insertHandshakeRecord(db: any, record: HandshakeRecord): void { new_counterparty: record.counterparty_public_key, record, }) - const s = serializeHandshakeRecord(finalizeInternalHandshakePersistence(record)) + const finalized = finalizeInternalHandshakePersistence(record) + const s = serializeHandshakeRecord(finalized) + // Phase 2 (G6): private key material goes to handshake_key_store, never to + // relationship rows. Pre-v73 handles (no store) keep the legacy row write. + if (hasKeyStore(db)) { + upsertHandshakeKeys(db, finalized.handshake_id, { + local_private_key: finalized.local_private_key ?? null, + local_x25519_private_key_b64: finalized.local_x25519_private_key_b64 ?? null, + local_mlkem768_secret_key_b64: finalized.local_mlkem768_secret_key_b64 ?? null, + }) + s.local_private_key = null + s.local_x25519_private_key_b64 = null + s.local_mlkem768_secret_key_b64 = null + } db.prepare(`INSERT INTO handshakes ( handshake_id, relationship_id, state, initiator_json, acceptor_json, local_role, sharing_mode, reciprocal_allowed, @@ -1871,6 +2276,39 @@ export function insertHandshakeRecord(db: any, record: HandshakeRecord): void { @internal_routing_key, @internal_coordination_identity_complete, @internal_coordination_repair_needed, @topology_pairing_kind )`).run(s) + // Phase 3 (G1–G3): transition adapter — dual-write core + runtime rows when + // the split store exists on this handle. The legacy row above remains the + // read authority during the transition window. Phase 4: formations through + // the one pipeline pass FormationMeta so the core carries the real profile, + // ingress_path, and capture provenance [IX.3.1 rule 5]. + adaptRecordToCoreStore(db, finalized, formation ? { formation } : undefined) + // Phase 5: a consented formation is PoAC-class evidence [IX.19.1] and + // creates the relationship's initial inbound DELIVERY grant behind the + // same consent event (E2 — the receiver-side filter consumes it). + if (formation) { + appendEvidenceBestEffort({ + chainId: finalized.handshake_id, + recordType: 'poac', + payload: poacFormationPayload({ + handshake_id: finalized.handshake_id, + profile_id: formation.profile_id, + consent_id: formation.consent_id, + capture_method: formation.capture_method, + ingress_path: formation.ingress_path, + }), + }) + try { + createGrant(db, { + handshakeId: finalized.handshake_id, + grantType: 'delivery', + direction: 'inbound', + scopes: finalized.effective_policy?.allowedScopes ?? [], + consentId: formation.consent_id, + }) + } catch (e) { + console.warn(`[GRANTS] initial delivery grant failed handshake=${finalized.handshake_id}: ${(e as Error)?.message}`) + } + } } export function updateHandshakeRecord(db: any, record: HandshakeRecord): void { @@ -1890,7 +2328,19 @@ export function updateHandshakeRecord(db: any, record: HandshakeRecord): void { new_counterparty: record.counterparty_public_key, record: prev, }) - const s = serializeHandshakeRecord(finalizeInternalHandshakePersistence(record)) + const finalizedForUpdate = finalizeInternalHandshakePersistence(record) + const s = serializeHandshakeRecord(finalizedForUpdate) + // Phase 2 (G6): see insertHandshakeRecord — keys divert to the key store. + if (hasKeyStore(db)) { + upsertHandshakeKeys(db, finalizedForUpdate.handshake_id, { + local_private_key: finalizedForUpdate.local_private_key ?? null, + local_x25519_private_key_b64: finalizedForUpdate.local_x25519_private_key_b64 ?? null, + local_mlkem768_secret_key_b64: finalizedForUpdate.local_mlkem768_secret_key_b64 ?? null, + }) + s.local_private_key = null + s.local_x25519_private_key_b64 = null + s.local_mlkem768_secret_key_b64 = null + } db.prepare(`UPDATE handshakes SET relationship_id = @relationship_id, state = @state, initiator_json = @initiator_json, acceptor_json = @acceptor_json, @@ -1935,6 +2385,9 @@ export function updateHandshakeRecord(db: any, record: HandshakeRecord): void { internal_coordination_repair_needed = @internal_coordination_repair_needed, topology_pairing_kind = @topology_pairing_kind WHERE handshake_id = @handshake_id`).run(s) + // Phase 3 (G1–G3): mirror mutable state into wr_handshake_runtime (the core + // row is append-only and untouched by updates — hash stability, T2). + adaptRecordToCoreStore(db, finalizedForUpdate) } /** Prompt 0: persist inferred/co-located topology marker on an internal Host↔Sandbox row. */ @@ -1950,7 +2403,7 @@ export function updateHandshakeTopologyPairingKind( export function getHandshakeRecord(db: any, handshakeId: string): HandshakeRecord | null { const row = db.prepare('SELECT * FROM handshakes WHERE handshake_id = ?').get(handshakeId) as any - return row ? deserializeHandshakeRecord(row) : null + return row ? overlayKeysFromStore(db, deserializeHandshakeRecord(row)) : null } /** Resolve handshake_id when the caller presents the peer's Bearer (matches our stored counterparty_p2p_token). */ @@ -2041,7 +2494,7 @@ export function refreshInternalHandshakePersistenceFlags(db: any, handshakeId: s */ export function listHandshakeRecords( db: any, - filter?: { state?: HandshakeState; relationship_id?: string; handshake_type?: string }, + filter?: { state?: HandshakeState; relationship_id?: string; same_principal?: boolean }, ): HandshakeRecord[] { let sql = 'SELECT * FROM handshakes WHERE 1=1' const params: any[] = [] @@ -2054,21 +2507,24 @@ export function listHandshakeRecords( sql += ' AND relationship_id = ?' params.push(filter.relationship_id) } - if (filter?.handshake_type) { - sql += ' AND handshake_type = ?' - params.push(filter.handshake_type) + // Profile-derived same_principal filter maps to the frozen legacy column + // at this single persistence boundary (Phase 4, Q9). + if (filter?.same_principal === true) { + sql += " AND handshake_type = 'internal'" + } else if (filter?.same_principal === false) { + sql += " AND (handshake_type IS NULL OR handshake_type != 'internal')" } sql += ' ORDER BY created_at DESC' const rows = db.prepare(sql).all(...params) as any[] - return rows.map(deserializeHandshakeRecord) + return overlayKeysFromStoreBatch(db, rows.map(deserializeHandshakeRecord)) } export function getExistingHandshakesForLookup(db: any): HandshakeRecord[] { const rows = db.prepare( "SELECT * FROM handshakes WHERE state IN ('PENDING_ACCEPT','ACCEPTED','ACTIVE')" ).all() as any[] - return rows.map(deserializeHandshakeRecord) + return overlayKeysFromStoreBatch(db, rows.map(deserializeHandshakeRecord)) } // ── Seen Capsule Hashes ── @@ -2359,8 +2815,14 @@ export function deleteHandshakeRecord(db: any, handshakeId: string): { success: db.prepare('DELETE FROM context_store WHERE handshake_id = ?').run(handshakeId) db.prepare('DELETE FROM seen_capsule_hashes WHERE handshake_id = ?').run(handshakeId) db.prepare('DELETE FROM outbound_capsule_queue WHERE handshake_id = ?').run(handshakeId) - db.prepare('DELETE FROM audit_log WHERE handshake_id = ?').run(handshakeId) + // Phase 5 (H1 hygiene): audit rows are NEVER deleted with the relationship + // — the old audit_log is frozen for forensics, and the Tier-L evidence + // chain (wr_evidence_chain, append-only by trigger) survives regardless. + // Pre-existing purge losses from the old behavior are unrecoverable. db.prepare('DELETE FROM handshakes WHERE handshake_id = ?').run(handshakeId) + // Phase 3: the runtime mirror goes with the row; the core record is + // append-only history and survives (store triggers abort DELETE anyway). + deleteRuntimeRow(db, handshakeId) return { success: true } } catch (err: any) { return { success: false, error: err?.message ?? 'Delete failed' } diff --git a/code/apps/electron-vite-project/electron/main/handshake/enforcement.ts b/code/apps/electron-vite-project/electron/main/handshake/enforcement.ts index 1a9b7030e..901cb094c 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/enforcement.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/enforcement.ts @@ -44,18 +44,25 @@ import { getExistingHandshakesForLookup, insertAuditLogEntry, markContextBlocksInactiveByHandshake, + refreshInternalHandshakePersistenceFlags, } from './db' +import { resolveProfile } from '@repo/ingestion-core' +import { wireDeclaresSamePrincipal } from './samePrincipalWire' import { ingestContextBlocks } from './contextIngestion' import { indexCapsuleBlocks } from './capsuleBlockIndexer' -import { buildSuccessAuditEntry, buildDenialAuditEntry } from './auditLog' +import { buildSuccessAuditEntry, buildDenialAuditEntry, type WireFormatMarker } from './auditLog' import { verifyCapsuleSignature } from './signatureKeys' import { verifyCapsuleHashIntegrity } from './steps/verifyCapsuleHash' +import { admitInboundDelivery } from './ingressAdmission' +import { hasCanonicalEnvelope, verifyCanonicalEnvelope } from './canonicalCore' +import { checkAndRecordNonce, WR_CORE_NONCE_SCOPE } from './nonceStore' import { logHandshakeKeyBinding } from './keyBindingDebug' import { getNextStateAfterInboundContextSync } from './contextSyncActiveGate' export { getNextStateAfterInboundContextSync } import { getP2PConfig } from '../p2p/p2pConfig' import { registerHandshakeWithRelay } from '../p2p/relaySync' import { retryDeferredInitialContextSyncForInternalHandshake } from './contextSyncEnqueue' +import { stageInboundInitiate, completeFormationConsent, type FormationConsentRef } from './formationPipeline' /** * Map ValidatedCapsule's capsule_type to the handshake layer's CapsuleType. * internal_draft is not a handshake capsule type — it should not reach here. @@ -186,6 +193,15 @@ export function processHandshakeCapsule( validated: ValidatedCapsule, receiverPolicy: ReceiverPolicy, ssoSession: SSOSession, + opts?: { + /** + * Phase 4 (Q1) [IX.3.1]: the consent gate. Without this, an inbound + * initiate capsule that passes the FULL verification chain produces a + * staged Connect offer — never a relationship row. Only the consent + * flow (formationPipeline.prepareFormationConsent) hands one in. + */ + formationConsent?: FormationConsentRef + }, ): HandshakeProcessResult { // Runtime guard: reject any input that did not pass through the Validator. // This catches forged objects, `as ValidatedCapsule` casts, and prototype-hacked inputs. @@ -216,6 +232,25 @@ export function processHandshakeCapsule( const input = extractVerifiedInput(validated) const startTime = performance.now() + + // Stage 0: ingress admission filter [VII.2.7] — first ingress stage. + // Control-plane capsules for a REVOKED/EXPIRED relationship die here, + // pre-visibility, with an audit_log record. Formation capsules (no record + // yet) are admitted; the state machine below owns them. + const admission = admitInboundDelivery(db, { + handshakeId: input.handshake_id, + kind: 'handshake_capsule', + source: validated.provenance?.source_type ?? 'unknown', + }) + if (!admission.admitted) { + return { + success: false, + reason: ReasonCode.INVALID_STATE_TRANSITION, + failedStep: 'ingress_admission', + pipelineDurationMs: Math.round(performance.now() - startTime), + } + } + const capsuleObj = validated.capsule as Record const senderPublicKey = typeof capsuleObj?.sender_public_key === 'string' ? capsuleObj.sender_public_key : '' const senderSignature = typeof capsuleObj?.sender_signature === 'string' ? capsuleObj.sender_signature : '' @@ -298,6 +333,68 @@ export function processHandshakeCapsule( } } + // 0c. Canonical v3 envelope (Phase 2, version-gated wire) [VII.3, VII.6.1.3]. + // Capsules carrying `wr_canonical_v3` verify the full-coverage canonical + // form FAIL-CLOSED on top of the legacy rules above; capsules without it + // verify under legacy rules alone and are marked 'legacy_v2' in evidence. + const wireFormat: WireFormatMarker = hasCanonicalEnvelope(capsuleObj) ? 'canonical_v3' : 'legacy_v2' + if (wireFormat === 'canonical_v3') { + const envelopeResult = verifyCanonicalEnvelope(capsuleObj, senderPublicKey) + if (!envelopeResult.ok) { + // Profile-dispatch refusals are fail-closed and NAME the profile + // [VII.4.2]; unknown critical entries name the namespace [VII.3.5]. + const reason = envelopeResult.refusedNamespace + ? ReasonCode.UNKNOWN_CRITICAL_EXTENSION + : envelopeResult.refusedProfile + ? (envelopeResult.reason.startsWith('unknown_profile') || + envelopeResult.reason.startsWith('unsupported_profile_version') + ? ReasonCode.UNKNOWN_PROFILE + : ReasonCode.PROFILE_SCHEMA_VIOLATION) + : ReasonCode.CANONICAL_ENVELOPE_INVALID + console.error('[HANDSHAKE] Canonical envelope refused:', { + handshake_id: input.handshake_id, + capsuleType: input.capsuleType, + reason: envelopeResult.reason, + refused_namespace: envelopeResult.refusedNamespace ?? null, + refused_profile: envelopeResult.refusedProfile ?? null, + }) + try { + const entry = buildDenialAuditEntry(input, reason, 'canonical_envelope_verification', 0, wireFormat) + entry.metadata = { + ...entry.metadata, + envelope_reason: envelopeResult.reason, + ...(envelopeResult.refusedNamespace ? { refused_namespace: envelopeResult.refusedNamespace } : {}), + ...(envelopeResult.refusedProfile + ? { refused_profile: `${envelopeResult.refusedProfile.id}@${envelopeResult.refusedProfile.version}` } + : {}), + } + insertAuditLogEntry(db, entry) + } catch { /* audit must not mask */ } + return { + success: false, + reason, + failedStep: 'canonical_envelope_verification', + pipelineDurationMs: Math.round(performance.now() - startTime), + } + } + + // Freshness/replay: a seen nonce arriving with a DIFFERENT capsule hash is + // a replayed core [VII.3.1]; identical redelivery falls through to the + // duplicate-capsule dedup step. + const nonceCheck = checkAndRecordNonce(db, WR_CORE_NONCE_SCOPE, input.nonce, input.capsule_hash) + if (!nonceCheck.ok) { + try { + insertAuditLogEntry(db, buildDenialAuditEntry(input, ReasonCode.NONCE_REPLAY, 'core_nonce_replay', 0, wireFormat)) + } catch { /* audit must not mask */ } + return { + success: false, + reason: ReasonCode.NONCE_REPLAY, + failedStep: 'core_nonce_replay', + pipelineDurationMs: Math.round(performance.now() - startTime), + } + } + } + // 1. Determine mode: create or update // 2. Pre-load lookups for pipeline @@ -366,6 +463,43 @@ export function processHandshakeCapsule( const tierDecision = pipelineResult.context.tierDecision! + // 4b. Phase 4 (Q1) [IX.3.1 rules 1–4]: inbound initiate WITHOUT a consent + // event → Connect-offer staging store, NOT the relationship store. The + // full verification chain above has passed; a failed chain already + // returned (audit-logged) and therefore no offer is ever reachable for + // it. Context blocks land only after consent (pre-visibility). + if (input.capsuleType === 'handshake-initiate' && !opts?.formationConsent) { + const staging = stageInboundInitiate({ + handshake_id: input.handshake_id, + capsule: capsuleObj, + capsule_hash: input.capsule_hash, + sender_email: input.senderIdentity?.email ?? null, + sender_iss: input.senderIdentity?.iss ?? null, + sender_sub: input.senderIdentity?.sub ?? null, + sender_wrdesk_user_id: input.sender_wrdesk_user_id ?? null, + receiver_email: input.receiver_email ?? null, + source_type: validated.provenance?.source_type ?? 'api', + }) + if (!staging.staged && staging.reason !== 'duplicate') { + return { + success: false, + reason: ReasonCode.INTERNAL_ERROR, + failedStep: 'connect_offer_staging', + detail: staging.reason, + pipelineDurationMs: Math.round(performance.now() - startTime), + } + } + return { + success: true, + staged: true, + offerId: staging.offerId!, + handshakeRecord: null, + blocksStored: 0, + tierDecision, + pipelineDurationMs: Math.round(performance.now() - startTime), + } + } + // 5. Compute record mutations let record: HandshakeRecord let blocksStored = 0 @@ -409,8 +543,26 @@ export function processHandshakeCapsule( const tx = db.transaction(() => { if (input.capsuleType === 'handshake-initiate') { + // Only reachable behind the consent gate (4b staged everything else). record = buildInitiateRecord(input, ssoSession, tierDecision, effectivePolicy, senderP2PEndpoint, senderP2PAuthToken, senderPublicKey, senderX25519, senderMlkem768) - insertHandshakeRecord(db, record) + const formationMeta = opts?.formationConsent?.formation + if (formationMeta) { + // Same-principal admission is a PROFILE-REGISTRY parameter (Q9) — + // the legacy row column is written FROM the profile record at this + // single persistence boundary (replaces the deleted force-internal + // UPDATE in the .beap import dialect). + const profileRes = resolveProfile(formationMeta.profile_id, formationMeta.profile_version) + if (profileRes.ok && profileRes.record.same_principal && record.same_principal !== true) { + record = { ...record, same_principal: true } + } + } + insertHandshakeRecord(db, record, formationMeta) + if (formationMeta) { + const profileRes = resolveProfile(formationMeta.profile_id, formationMeta.profile_version) + if (profileRes.ok && profileRes.record.same_principal) { + try { refreshInternalHandshakePersistenceFlags(db, record.handshake_id) } catch { /* flags refresh is best-effort */ } + } + } } else if (input.capsuleType === 'handshake-accept') { record = buildAcceptRecord(handshakeRecord!, input, ssoSession, tierDecision, effectivePolicy, senderP2PEndpoint, senderP2PAuthToken, senderPublicKey, senderX25519, senderMlkem768) updateHandshakeRecord(db, record) @@ -580,14 +732,14 @@ export function processHandshakeCapsule( insertSeenCapsuleHash(db, input.handshake_id, input.capsule_hash) // Audit log - insertAuditLogEntry(db, buildSuccessAuditEntry(input, record!, durationMs, blocksStored)) + insertAuditLogEntry(db, buildSuccessAuditEntry(input, record!, durationMs, blocksStored, wireFormat)) }) try { tx() if (input.capsuleType === 'handshake-accept') { const r = getHandshakeRecord(db, input.handshake_id) - if (r?.handshake_type === 'internal' && r.local_role === 'initiator' && r.state === HS.ACCEPTED) { + if (r?.same_principal === true && r.local_role === 'initiator' && r.state === HS.ACCEPTED) { scheduleInternalInitiatorPostAcceptCoordinationRepair(db, input.handshake_id, ssoSession) } } @@ -630,6 +782,15 @@ export function processHandshakeCapsule( } } + // Consent-gated formation committed — consume the staged offer. + if (opts?.formationConsent) { + try { + completeFormationConsent(opts.formationConsent) + } catch (e: any) { + console.warn('[CONNECT_OFFER] completeFormationConsent failed (record committed):', e?.message) + } + } + return { success: true, handshakeRecord: record!, @@ -804,9 +965,9 @@ function buildInitiateRecord( peer_x25519_public_key_b64: senderX25519, peer_mlkem768_public_key_b64: senderMlkem768, receiver_email: input.receiver_email || null, - ...(input.handshake_type === 'internal' + ...(wireDeclaresSamePrincipal(input) ? { - handshake_type: 'internal' as const, + same_principal: true, initiator_coordination_device_id: input.sender_device_id?.trim() || null, acceptor_coordination_device_id: input.receiver_device_id?.trim() || null, initiator_device_name: input.sender_computer_name?.trim() || null, @@ -920,7 +1081,7 @@ function buildAcceptRecord( : (existing.peer_mlkem768_public_key_b64 ?? null), } - if (existing.handshake_type === 'internal' && existing.local_role === 'initiator') { + if (existing.same_principal === true && existing.local_role === 'initiator') { // Initiator row: on internal accept, wire `sender_*` is the acceptor’s coordination identity. const acceptorDev = input.sender_device_id?.trim() || undefined const acceptorRole = input.sender_device_role ?? undefined @@ -957,7 +1118,7 @@ function scheduleInternalInitiatorPostAcceptCoordinationRepair( const getToken = () => ipc.getCoordinationOidcToken() const rec = getHandshakeRecord(db, handshakeId) if (!rec) return - if (rec.handshake_type !== 'internal' || rec.local_role !== 'initiator' || rec.state !== HS.ACCEPTED) { + if (rec.same_principal !== true || rec.local_role !== 'initiator' || rec.state !== HS.ACCEPTED) { return } if (!rec.acceptor) { @@ -986,7 +1147,7 @@ function scheduleInternalInitiatorPostAcceptCoordinationRepair( acceptor_email: rec.acceptor.email, initiator_device_id: iid, acceptor_device_id: aid, - handshake_type: 'internal', + same_principal: true, }, ) if (!reg.success) { @@ -1009,7 +1170,7 @@ function scheduleInternalInitiatorPostAcceptCoordinationRepair( function inboundPeerP2pAuthTokenUpdate(existing: HandshakeRecord, input: VerifiedCapsuleInput): string | null { const tok = input.p2p_auth_token?.trim() if (!tok) return null - if (existing.handshake_type === 'internal') { + if (existing.same_principal === true) { const senderDev = input.sender_device_id?.trim() ?? '' if (!senderDev) return null const peerDev = diff --git a/code/apps/electron-vite-project/electron/main/handshake/evidenceChain.ts b/code/apps/electron-vite-project/electron/main/handshake/evidenceChain.ts new file mode 100644 index 000000000..072d91c54 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/evidenceChain.ts @@ -0,0 +1,395 @@ +/** + * Append-only hash-chained evidence store (Phase 5 — H1–H4) [IX.19.1, X.10.1] + * + * A NEW parallel store — never a retrofit of `audit_log` (which is deletable + * and incomplete). Tier L in the Annex IX sense: removal, reorder, or + * insertion of a post-genesis record is detectable. + * + * Record classes: + * - PoAC — Proof of Authorized Change: formation, grant creation / + * modification / revocation, admissions, content deletion. + * - PoAE — Proof of Authorized Execution: executions, with Intent Hash and + * consent reference [IX.19.2]. + * - BER — Boundary Event Records: SCHEMA lands here; writers arrive in + * Phase 6 [X.10]. + * + * Chain discipline: + * - One chain per contract (`chain_id` = handshake_id; `wr:local` for + * non-contract-scoped events). + * - Monotonic per-chain sequence, starting with an explicit GENESIS record + * (seq 0) that references the cutover timestamp. Continuity is NEVER + * claimed for pre-cutover rows [X.0.1] — the old `audit_log` stays outside + * the chain, read-only for forensics. + * - Every record's hash covers domainTag('wr.evidence.record', 1) + the + * canonical form of {chain_id, seq, record_type, payload, prev_hash, + * created_at}; `prev_hash` is the previous record's hash. + * + * Home (Q10): the frozen-and-swept `handshake-ledger.db` is the Tier-L chain + * home. The tables here are LEDGER-NATIVE schema (applied by `ledger.ts`), + * not part of the frozen handshake migration chain. Tests inject an + * in-memory DB via `setEvidenceDbProvider`. + */ + +import { createHash } from 'node:crypto' +import { canonicalJsonString, domainTag, type CanonicalJsonValue } from '@repo/ingestion-core' + +// ── Schema ──────────────────────────────────────────────────────────────────── + +export const EVIDENCE_RECORD_TYPES = Object.freeze(['genesis', 'poac', 'poae', 'ber'] as const) +export type EvidenceRecordType = (typeof EVIDENCE_RECORD_TYPES)[number] + +/** Chain id for local events not scoped to a single contract. */ +export const LOCAL_EVIDENCE_CHAIN = 'wr:local' + +export const EVIDENCE_SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS wr_evidence_chain ( + chain_id TEXT NOT NULL, + seq INTEGER NOT NULL, + record_type TEXT NOT NULL CHECK (record_type IN ('genesis', 'poac', 'poae', 'ber')), + payload_json TEXT NOT NULL, + prev_hash TEXT NOT NULL, + record_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (chain_id, seq) + ); + CREATE TRIGGER IF NOT EXISTS trg_wr_evidence_no_update + BEFORE UPDATE ON wr_evidence_chain + BEGIN + SELECT RAISE(ABORT, 'wr_evidence_chain is append-only'); + END; + CREATE TRIGGER IF NOT EXISTS trg_wr_evidence_no_delete + BEFORE DELETE ON wr_evidence_chain + BEGIN + SELECT RAISE(ABORT, 'wr_evidence_chain is append-only'); + END; +` + +export function ensureEvidenceSchema(db: any): void { + db.exec(EVIDENCE_SCHEMA_SQL) +} + +// ── DB handle (Q10: the ledger is the Tier-L home) ─────────────────────────── + +let _evidenceDbProvider: (() => any) | null = null + +export function setEvidenceDbProvider(provider: (() => any) | null): void { + _evidenceDbProvider = provider +} + +function getEvidenceDb(): any | null { + if (_evidenceDbProvider) { + const db = _evidenceDbProvider() + ensureEvidenceSchema(db) + return db + } + try { + // Lazy import avoids a module cycle (ledger.ts imports db.ts helpers). + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getLedgerDb } = require('./ledger') + const db = getLedgerDb() + if (!db) return null + ensureEvidenceSchema(db) + return db + } catch { + return null + } +} + +// ── Hashing ─────────────────────────────────────────────────────────────────── + +export const GENESIS_PREV_HASH = '0'.repeat(64) + +function recordHash(args: { + chain_id: string + seq: number + record_type: EvidenceRecordType + payload_json: string + prev_hash: string + created_at: string +}): string { + const canonical = canonicalJsonString({ + chain_id: args.chain_id, + seq: args.seq, + record_type: args.record_type, + payload_json: args.payload_json, + prev_hash: args.prev_hash, + created_at: args.created_at, + }) + const h = createHash('sha256') + h.update(domainTag('wr.evidence.record', 1)) + h.update(Buffer.from(canonical, 'utf8')) + return h.digest('hex') +} + +// ── Append ──────────────────────────────────────────────────────────────────── + +export interface EvidenceRecordRow { + chain_id: string + seq: number + record_type: EvidenceRecordType + payload_json: string + prev_hash: string + record_hash: string + created_at: string +} + +export interface AppendEvidenceResult { + ok: true + seq: number + record_hash: string +} + +/** + * Append one record to a chain, creating the explicit genesis record first if + * the chain does not exist yet. The genesis payload references the cutover + * timestamp — pre-cutover `audit_log` rows are outside the chain [X.0.1]. + */ +export function appendEvidenceRecord( + db: any, + args: { + chainId: string + recordType: Exclude + payload: CanonicalJsonValue + now?: Date + }, +): AppendEvidenceResult { + ensureEvidenceSchema(db) + const now = (args.now ?? new Date()).toISOString() + + let result: AppendEvidenceResult | null = null + const tx = db.transaction(() => { + const tip = db + .prepare( + `SELECT seq, record_hash FROM wr_evidence_chain WHERE chain_id = ? ORDER BY seq DESC LIMIT 1`, + ) + .get(args.chainId) as { seq: number; record_hash: string } | undefined + + let prevSeq: number + let prevHash: string + if (!tip) { + // Explicit genesis referencing the cutover timestamp. + const genesisPayload = canonicalJsonString({ + note: 'wr evidence chain genesis — no continuity is claimed for pre-cutover records', + cutover_at: now, + }) + const gHash = recordHash({ + chain_id: args.chainId, + seq: 0, + record_type: 'genesis', + payload_json: genesisPayload, + prev_hash: GENESIS_PREV_HASH, + created_at: now, + }) + db.prepare( + `INSERT INTO wr_evidence_chain + (chain_id, seq, record_type, payload_json, prev_hash, record_hash, created_at) + VALUES (?, 0, 'genesis', ?, ?, ?, ?)`, + ).run(args.chainId, genesisPayload, GENESIS_PREV_HASH, gHash, now) + prevSeq = 0 + prevHash = gHash + } else { + prevSeq = tip.seq + prevHash = tip.record_hash + } + + const seq = prevSeq + 1 + const payloadJson = canonicalJsonString(args.payload) + const hash = recordHash({ + chain_id: args.chainId, + seq, + record_type: args.recordType, + payload_json: payloadJson, + prev_hash: prevHash, + created_at: now, + }) + db.prepare( + `INSERT INTO wr_evidence_chain + (chain_id, seq, record_type, payload_json, prev_hash, record_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(args.chainId, seq, args.recordType, payloadJson, prevHash, hash, now) + result = { ok: true, seq, record_hash: hash } + }) + tx() + return result! +} + +/** + * Best-effort production append: resolves the ledger handle (or the injected + * provider). Evidence failure must never break the underlying operation — + * metadata-only warn, no payload in logs. + */ +export function appendEvidenceBestEffort(args: { + chainId: string + recordType: Exclude + payload: CanonicalJsonValue +}): AppendEvidenceResult | null { + try { + const db = getEvidenceDb() + if (!db) return null + return appendEvidenceRecord(db, args) + } catch (e) { + console.warn( + `[EVIDENCE] append failed chain=${args.chainId} type=${args.recordType}: ${(e as Error)?.message}`, + ) + return null + } +} + +// ── Read / verify ───────────────────────────────────────────────────────────── + +export function listEvidenceRecords(db: any, chainId: string): EvidenceRecordRow[] { + ensureEvidenceSchema(db) + return db + .prepare(`SELECT * FROM wr_evidence_chain WHERE chain_id = ? ORDER BY seq ASC`) + .all(chainId) as EvidenceRecordRow[] +} + +export type ChainVerdict = + | { valid: true; length: number } + | { + valid: false + reason: + | 'missing_genesis' + | 'sequence_gap' + | 'sequence_duplicate' + | 'prev_hash_mismatch' + | 'record_hash_mismatch' + at_seq: number + } + +/** + * Verify a chain end-to-end: genesis at seq 0, strictly contiguous monotonic + * sequence, every prev-hash link intact, every record hash recomputable. + * Removal, reorder, and insertion of post-genesis records are all detected. + */ +export function verifyEvidenceChain(db: any, chainId: string): ChainVerdict { + const rows = listEvidenceRecords(db, chainId) + if (rows.length === 0 || rows[0].seq !== 0 || rows[0].record_type !== 'genesis') { + return { valid: false, reason: 'missing_genesis', at_seq: rows.length > 0 ? rows[0].seq : 0 } + } + let prevHash = GENESIS_PREV_HASH + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + if (row.seq !== i) { + return { valid: false, reason: row.seq < i ? 'sequence_duplicate' : 'sequence_gap', at_seq: row.seq } + } + if (row.prev_hash !== prevHash) { + return { valid: false, reason: 'prev_hash_mismatch', at_seq: row.seq } + } + const expected = recordHash({ + chain_id: row.chain_id, + seq: row.seq, + record_type: row.record_type, + payload_json: row.payload_json, + prev_hash: row.prev_hash, + created_at: row.created_at, + }) + if (expected !== row.record_hash) { + return { valid: false, reason: 'record_hash_mismatch', at_seq: row.seq } + } + prevHash = row.record_hash + } + return { valid: true, length: rows.length } +} + +// ── Payload builders (typed record classes) ────────────────────────────────── + +/** PoAC — formation of a relationship (written by the one pipeline). */ +export function poacFormationPayload(args: { + handshake_id: string + profile_id: string + consent_id: string + capture_method: string + ingress_path: string +}): CanonicalJsonValue { + return { kind: 'formation', ...args } +} + +/** PoAC — grant lifecycle (creation / modification / revocation). */ +export function poacGrantPayload(args: { + event: 'grant_created' | 'grant_modified' | 'grant_revoked' + grant_id: string + handshake_id: string + grant_type: string + scopes: string[] + consent_id?: string | null + actor_wrdesk_user_id?: string | null +}): CanonicalJsonValue { + return { + kind: args.event, + grant_id: args.grant_id, + handshake_id: args.handshake_id, + grant_type: args.grant_type, + scopes: args.scopes, + consent_id: args.consent_id ?? null, + actor_wrdesk_user_id: args.actor_wrdesk_user_id ?? null, + } +} + +/** PoAC — blocked/admitted ingress decisions worth evidencing. */ +export function poacAdmissionPayload(args: { + handshake_id: string + decision: 'blocked' + reason: string + kind: string + source: string +}): CanonicalJsonValue { + return { + kind: 'admission', + handshake_id: args.handshake_id, + decision: args.decision, + reason: args.reason, + delivery_kind: args.kind, + source: args.source, + } +} + +/** PoAC — explicit operator content deletion of a revoked relationship (Q8). */ +export function poacContentDeletionPayload(args: { + handshake_id: string + blocks_deleted: number + embeddings_deleted: number + actor_wrdesk_user_id?: string | null +}): CanonicalJsonValue { + return { + kind: 'revoked_content_deleted', + handshake_id: args.handshake_id, + blocks_deleted: args.blocks_deleted, + embeddings_deleted: args.embeddings_deleted, + actor_wrdesk_user_id: args.actor_wrdesk_user_id ?? null, + } +} + +/** + * PoAE — execution record with Intent Hash + consent reference [IX.19.2]. + * Never contains prompt/parameter content — digests only. + */ +export function poaeExecutionPayload(args: { + handshake_id: string + request_id: string + tool_name: string + intent_hash: string + consent_id: string + outcome: 'success' | 'failure' | 'refused_deviation' + params_digest: string +}): CanonicalJsonValue { + return { kind: 'execution', ...args } +} + +/** + * BER — Boundary Event Record SCHEMA (writers arrive in Phase 6) [X.10.2]. + * Where the crossing is a consequential effect, BER and execution receipt are + * ONE record, not two. + */ +export function berCrossingPayload(args: { + governing_ref: string + governing_version: number + direction: 'ingress' | 'egress' + capability: string + data_class_digests: string[] + counterparty: string + channel: string + decision_ref: string +}): CanonicalJsonValue { + return { kind: 'boundary_crossing', ...args } +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/formationPipeline.ts b/code/apps/electron-vite-project/electron/main/handshake/formationPipeline.ts new file mode 100644 index 000000000..3d9a6286d --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/formationPipeline.ts @@ -0,0 +1,672 @@ +/** + * ONE formation pipeline (Phase 4 — V1, V2) [VII.4.6, IX.3.1] + * + * Every relationship formation goes through this module, dispatching on + * Phase-3 profile-registry records. The former dialects are gone: + * + * - initiator direct persist (deleted) → formInitiatorRelationship + * - .beap file-import persist (deleted) → staging + consent gate + * - inbound pipeline auto-insert → staging + consent gate + * - edge-agent pairing → retired for new pairings + * + * Inbound invitations NEVER create relationship rows. They pass the full + * verification chain, land in the Connect-offer staging store, and only a + * consent event lets the pipeline create the record: + * + * verification chain → client-generated Connect offer → consent → record + * + * Failed verification suppresses the offer entirely — no "connect anyway" + * [IX.3.1 rules 1–4]. Consent records are Hash-Pinned [IX.3.4]. + * + * `ingress_path` is written by this pipeline per the Q4 mapping and remains + * LOG-ONLY; capture-method values are log/render-only beyond the fail-closed + * shippable gate. Formation via different paths yields semantically + * identical relationships (same profile → same rights). + */ + +import * as path from 'node:path' +import * as fs from 'node:fs' +import { createHash, randomUUID } from 'node:crypto' +import { + resolveCaptureMethodForFormation, + resolveInvitationClassForFormation, + resolveProfile, + isRecordableIngressPath, + canonicalJsonString, + domainTag, + type CanonicalJsonValue, + type SourceType, +} from '@repo/ingestion-core' +import type { FormationMeta } from './coreStore' +import type { HandshakeCapsuleWire } from './capsuleBuilder' +import type { SigningKeypair } from './signatureKeys' +import type { SSOSession, HandshakeRecord, BeapKeyAgreementMaterial } from './types' +import type { ContextBlockForCommitment } from './contextCommitment' +import { HandshakeState as HS, INPUT_LIMITS, buildDefaultReceiverPolicy } from './types' +import { classifyHandshakeTier } from './tierClassification' +import { resolveEffectivePolicyFn } from './steps/policyResolution' +import { + insertHandshakeRecord, + insertSeenCapsuleHash, + insertContextStoreEntry, + updateHandshakePolicySelections, +} from './db' +import { validateInternalInitiateCapsuleWire } from './internalPersistence' +import { wireDeclaresSamePrincipal } from './samePrincipalWire' +import type { AiProcessingMode } from '../../../../../packages/shared/src/handshake/policyUtils' +import { + createDefaultGovernance, + createMessageGovernance, + baselineFromHandshake, + baselineFromPolicySelections, + type ContextItemGovernance, +} from './contextGovernance' +import { + stageConnectOffer, + getConsentableOffer, + buildConnectOfferPreview, + insertConsentRecord, + markOfferConsumed, + expireStaleOffers, + listPendingConnectOffers, + findPendingOfferByHandshakeId, + type ConnectOfferRow, + type ConsentRecordRow, +} from './connectOfferStaging' + +// ── Staging DB provider ─────────────────────────────────────────────────────── +// The staging store lives in its OWN SQLite file, outside both relationship +// DB handles (vault DB and frozen ledger). main.ts may override the provider; +// the default opens /.opengiraffe/connect-offers.db lazily. + +let _stagingDbProvider: (() => any) | null = null +let _defaultStagingDb: any = null + +export function setConnectOfferDbProvider(provider: (() => any) | null): void { + _stagingDbProvider = provider +} + +export function getConnectOfferDb(): any { + if (_stagingDbProvider) return _stagingDbProvider() + if (!_defaultStagingDb) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Database = require('better-sqlite3') + if (process.env.VITEST) { + // Test runs must never stage offers into the developer's real profile + // DB (they would leak into the app's pending list and across runs). + _defaultStagingDb = new Database(':memory:') + } else { + const dir = path.join(process.env.USERPROFILE || process.env.HOME || '.', '.opengiraffe') + fs.mkdirSync(dir, { recursive: true }) + _defaultStagingDb = new Database(path.join(dir, 'connect-offers.db')) + _defaultStagingDb.pragma('journal_mode = WAL') + } + } + return _defaultStagingDb +} + +/** @internal test seam */ +export function _resetConnectOfferDbForTests(): void { + _stagingDbProvider = null + try { _defaultStagingDb?.close() } catch { /* noop */ } + _defaultStagingDb = null +} + +// ── Q4 mapping: transport source → ingress path + capture method ───────────── +// Data-driven mapping (recording only): the recorded value is LOG-ONLY +// downstream [VII.4.6]. No code may branch on the recorded value. + +const SOURCE_INGRESS_MAP: Readonly> = Object.freeze({ + email: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + file_upload: { ingress_path: 'optirando.ingress.file_import', capture_method: 'manual_entry' }, + internal: { ingress_path: 'optirando_code_entry', capture_method: 'manual_entry' }, + p2p: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + p2p_relay: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + relay_pull: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + coordination_service: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + coordination_ws: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + api: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + extension: { ingress_path: 'beap_invitation', capture_method: 'assisted_email' }, + // Phase 5 (5A): WR-code captures are real producers now, so they get their + // own registered ingress paths instead of borrowing the invitation path. + wr_code_email: { ingress_path: 'wr_code_public', capture_method: 'assisted_email' }, + wr_code_manual: { ingress_path: 'wr_code_public', capture_method: 'manual_entry' }, + wr_code_scan: { ingress_path: 'wr_code_public', capture_method: 'scan' }, + wr_code_red: { ingress_path: 'wr_code_red', capture_method: 'scan' }, +}) + +/** + * Sentinel for an unmapped transport source. + * + * Phase-1 carry-over, ruled at Phase-5 start. The old default returned + * `beap_invitation` / `assisted_email` for ANY unknown source type. That was + * tolerable only while `assisted_email` had no live producer; 5A makes it a + * truthful capture method, so a default that silently attests "the user + * received this by assisted email" is a fabrication about how consent was + * obtained. + * + * Totality is preserved — the acceptance test asserting every transport source + * resolves to a recordable pair still holds — but the pair is now honest. It + * deliberately matches no entry in {@link SOURCE_INGRESS_MAP}, so + * `ingressCaptureMethodForOffer` returns null for it and consent FAILS rather + * than recording a capture nobody performed. + */ +export const UNMAPPED_SOURCE_INGRESS = Object.freeze({ + ingress_path: 'unmapped_transport_source', + capture_method: 'unmapped_transport_source', +}) + +export function ingressMappingForSource(sourceType: SourceType | string): { ingress_path: string; capture_method: string } { + return SOURCE_INGRESS_MAP[sourceType] ?? UNMAPPED_SOURCE_INGRESS +} + +// ── Wire → profile mapping (compat boundary) ───────────────────────────────── +// Legacy v2 capsules carry no profile. The signed canonical v3 core carries +// one. This is the SINGLE place a legacy wire value maps to a profile id. + +export function profileIdForWireCapsule(capsule: Record): { id: string; version: number } { + const envelope = capsule?.wr_canonical_v3 as { core?: { profile?: { id?: string; version?: number } } } | undefined + const p = envelope?.core?.profile + if (p && typeof p.id === 'string' && typeof p.version === 'number') { + return { id: p.id, version: p.version } + } + // Legacy wire compat mapping (same-principal pairing → internal_device, Q9). + if (wireDeclaresSamePrincipal(capsule as any)) return { id: 'internal_device', version: 1 } + return { id: 'legacy_v0', version: 1 } +} + +// ── Inbound staging (called from enforcement after the verification chain) ─── + +export interface StageInboundInitiateArgs { + handshake_id: string + capsule: Record + capsule_hash: string + sender_email?: string | null + sender_iss?: string | null + sender_sub?: string | null + sender_wrdesk_user_id?: string | null + receiver_email?: string | null + /** Ingestion provenance source (Q4 mapping input). */ + source_type: string + /** + * Compat boundary override: same-account .beap imports form under the + * `internal_device` profile even when the legacy wire lacks the marker + * (replaces the deleted ipc.ts force-internal UPDATE). + */ + profile_id_override?: string +} + +export type StageInboundResult = { staged: true; offerId: string } | { staged: false; reason: string; offerId?: string } + +/** + * Stage a fully verified inbound initiate capsule as a Connect offer. The + * verification chain has already run (enforcement pipeline); this NEVER + * touches the relationship store. + */ +export function stageInboundInitiate(args: StageInboundInitiateArgs): StageInboundResult { + const stagingDb = getConnectOfferDb() + const mapping = ingressMappingForSource(args.source_type) + const profile = args.profile_id_override + ? { id: args.profile_id_override, version: 1 } + : profileIdForWireCapsule(args.capsule) + const result = stageConnectOffer(stagingDb, { + handshake_id: args.handshake_id, + capsule: args.capsule, + capsule_hash: args.capsule_hash, + sender_email: args.sender_email, + sender_iss: args.sender_iss, + sender_sub: args.sender_sub, + sender_wrdesk_user_id: args.sender_wrdesk_user_id, + receiver_email: args.receiver_email, + profile_id: profile.id, + ingress_path: mapping.ingress_path, + invitation_class: 'public_bearer', + verification: { ok: true }, + }) + if (!result.staged) return { staged: false, reason: result.reason, offerId: result.offerId } + return { staged: true, offerId: result.offerId } +} + +// ── Offer listing / preview (renderer surface) ──────────────────────────────── + +export function listConnectOffers(): Array }> { + const stagingDb = getConnectOfferDb() + expireStaleOffers(stagingDb) + return listPendingConnectOffers(stagingDb).map((offer) => ({ + ...offer, + preview: buildConnectOfferPreview(offer), + })) +} + +export function pendingOfferForHandshake(handshakeId: string): ConnectOfferRow | null { + return findPendingOfferByHandshakeId(getConnectOfferDb(), handshakeId) +} + +export function declineConnectOffer(offerId: string): { ok: boolean } { + const stagingDb = getConnectOfferDb() + const offer = getConsentableOffer(stagingDb, offerId) + if (!offer) return { ok: false } + markOfferConsumed(stagingDb, offerId, 'declined') + return { ok: true } +} + +// ── Consent gate → record creation ──────────────────────────────────────────── + +export interface FormationConsentRef { + consent_id: string + offer_id: string + formation: FormationMeta +} + +export type ConsentPreparation = + | { ok: true; offer: ConnectOfferRow; consentRef: FormationConsentRef; consent: ConsentRecordRow } + | { ok: false; reason: string } + +/** + * The consent event [IX.3.1 rules 3–4]: validates that the offer is still + * consentable (verified, unsuppressed, unconsumed, unexpired — suppressed + * offers are structurally unreachable), fail-closes on capture method / + * invitation class / profile, writes the Hash-Pinned consent record, and + * hands back the FormationMeta the pipeline needs to create the record. + * + * `expectedPreviewHash` binds the consent to the preview the user actually + * saw: if the staged material changed since presentation, consent fails. + */ +export function prepareFormationConsent(args: { + offerId: string + actorWrdeskUserId: string + expectedPreviewHash?: string + sourceReference?: string | null +}): ConsentPreparation { + const stagingDb = getConnectOfferDb() + expireStaleOffers(stagingDb) + const offer = getConsentableOffer(stagingDb, args.offerId) + if (!offer) return { ok: false, reason: 'OFFER_NOT_CONSENTABLE' } + + // Fail-closed registry gates [IX.3.2, VII.4.2]. + const invClass = resolveInvitationClassForFormation(offer.invitation_class) + if (!invClass.ok) return { ok: false, reason: invClass.reason.toUpperCase() } + const captureMethodId = ingressCaptureMethodForOffer(offer) + if (captureMethodId === null) { + return { ok: false, reason: `INGRESS_PATH_HAS_NO_CAPTURE_METHOD:${offer.ingress_path}` } + } + const capture = resolveCaptureMethodForFormation(captureMethodId) + if (!capture.ok) return { ok: false, reason: capture.reason.toUpperCase() } + const profileRes = resolveProfile(offer.profile_id, 1) + if (!profileRes.ok) return { ok: false, reason: `${profileRes.reason.toUpperCase()}:${offer.profile_id}` } + if (!isRecordableIngressPath(offer.ingress_path)) { + return { ok: false, reason: 'INGRESS_PATH_NOT_RECORDABLE' } + } + + const preview = buildConnectOfferPreview(offer) + if (args.expectedPreviewHash && args.expectedPreviewHash !== preview.preview_hash) { + return { ok: false, reason: 'PREVIEW_HASH_MISMATCH' } + } + + const consent = insertConsentRecord(stagingDb, { + offer_id: offer.offer_id, + handshake_id: offer.handshake_id, + role: 'acceptor', + preview_hash: preview.preview_hash, + bound_definition_hash: preview.bound_definition_hash, + contract_state_hash: preview.contract_state_hash, + capture_method: captureMethodId, + ingress_path: offer.ingress_path, + source_reference: args.sourceReference ?? null, + actor_wrdesk_user_id: args.actorWrdeskUserId, + }) + + return { + ok: true, + offer, + consent, + consentRef: { + consent_id: consent.consent_id, + offer_id: offer.offer_id, + formation: { + profile_id: profileRes.record.id, + profile_version: profileRes.record.version, + ingress_path: offer.ingress_path, + capture_method: captureMethodId, + source_reference: args.sourceReference ?? null, + consent_id: consent.consent_id, + nonce: randomUUID(), + }, + }, + } +} + +/** Mark the offer consumed after the pipeline created the record. */ +export function completeFormationConsent(consentRef: FormationConsentRef): void { + markOfferConsumed(getConnectOfferDb(), consentRef.offer_id, 'consented', consentRef.consent_id) +} + +/** + * The capture method recorded in the consent evidence, derived from the offer's + * ingress path. Returns null when no mapping matches. + * + * There is deliberately no fallback. The consent record is evidence of how the + * user actually received the invitation, so an unmapped ingress path must fail + * the consent rather than record a capture method nobody performed — a default + * of `assisted_email` would attest to an email capture for offers that never + * touched mail. + */ +function ingressCaptureMethodForOffer(offer: ConnectOfferRow): string | null { + for (const mapping of Object.values(SOURCE_INGRESS_MAP)) { + if (mapping.ingress_path === offer.ingress_path) return mapping.capture_method + } + return null +} + +// ── Initiator-side formation (explicit user creation = consent event) ───────── + +export interface InitiatorConsentArgs { + handshake_id: string + /** Contract state at consent time — the outgoing initiate capsule hash. */ + contract_state_hash: string + /** Preview hash of the client-rendered creation summary. */ + preview_hash: string + bound_definition_hash: string + capture_method: string + ingress_path: string + source_reference?: string | null + actor_wrdesk_user_id: string +} + +export type InitiatorConsentResult = + | { ok: true; formation: FormationMeta; consent: ConsentRecordRow } + | { ok: false; reason: string } + +/** + * Wire → profile for NEW outbound formations (compat boundary, single site): + * same-principal device pairing forms under `internal_device` (Q9); + * everything else forms under `private_personal`. + */ +export function profileForNewFormation(capsule: Record): string { + return wireDeclaresSamePrincipal(capsule as any) ? 'internal_device' : 'private_personal' +} + +const INITIATOR_PREVIEW_DOMAIN = 'wr.initiator_formation.preview' + +function initiatorHashes(capsule: HandshakeCapsuleWire): { + preview_hash: string + bound_definition_hash: string +} { + const boundDefinition: Record = { + sender_email: capsule.senderIdentity?.email ?? '', + sender_iss: capsule.senderIdentity?.iss ?? '', + sender_sub: capsule.senderIdentity?.sub ?? '', + sender_wrdesk_user_id: capsule.sender_wrdesk_user_id ?? '', + receiver_email: capsule.receiver_email ?? '', + profile_id: profileForNewFormation(capsule as unknown as Record), + } + const preview: Record = { + handshake_id: capsule.handshake_id, + bound_definition: boundDefinition, + scopes: Array.isArray((capsule as any).context_scopes) + ? [...(capsule as any).context_scopes].filter((s: unknown) => typeof s === 'string').sort() + : [], + external_processing: capsule.external_processing ?? 'none', + reciprocal_allowed: capsule.reciprocal_allowed === true, + } + const sha = (domain: string, value: CanonicalJsonValue): string => + createHash('sha256').update(domainTag(domain, 1)).update(canonicalJsonString(value), 'utf8').digest('hex') + return { + preview_hash: sha(INITIATOR_PREVIEW_DOMAIN, preview), + bound_definition_hash: sha('wr.handshake.bound_definition', boundDefinition), + } +} + +export interface FormInitiatorResult { + success: boolean + error?: string +} + +/** + * Initiator-side formation through the ONE pipeline (replaces the deleted + * initiatorPersist dialect). The user's explicit creation act is the consent + * event; the record carries FormationMeta (profile, ingress_path, capture + * provenance) into the core store [IX.3.1 rule 5]. + */ +export function formInitiatorRelationship( + db: any, + capsule: HandshakeCapsuleWire, + session: SSOSession, + localBlocks: ContextBlockForCommitment[], + keypair: SigningKeypair, + formationArgs: { + capture_method: string + ingress_path: string + source_reference?: string | null + }, + policySelections?: { ai_processing_mode?: AiProcessingMode } | { cloud_ai?: boolean; internal_ai?: boolean }, + blockPolicyMap?: Map, + beapKeys?: BeapKeyAgreementMaterial | null, +): FormInitiatorResult { + try { + if (wireDeclaresSamePrincipal(capsule)) { + const w = validateInternalInitiateCapsuleWire(capsule as unknown as Record) + if (!w.ok) { + return { success: false, error: w.error ?? 'Internal initiate capsule invalid' } + } + } + + // Consent event + FormationMeta (fail-closed on capture method / ingress + // path / profile) BEFORE anything touches the relationship store. + const hashes = initiatorHashes(capsule) + const prep = prepareInitiatorFormation( + { + handshake_id: capsule.handshake_id, + contract_state_hash: capsule.capsule_hash, + preview_hash: hashes.preview_hash, + bound_definition_hash: hashes.bound_definition_hash, + capture_method: formationArgs.capture_method, + ingress_path: formationArgs.ingress_path, + source_reference: formationArgs.source_reference ?? null, + actor_wrdesk_user_id: session.wrdesk_user_id, + }, + profileForNewFormation(capsule as unknown as Record), + ) + if (!prep.ok) { + return { success: false, error: `Formation refused: ${prep.reason}` } + } + + const tierDecision = classifyHandshakeTier({ + plan: session.plan, + hardwareAttestation: session.currentHardwareAttestation, + dnsVerification: session.currentDnsVerification, + wrStampStatus: session.currentWrStampStatus, + }) + + const receiverPolicy = buildDefaultReceiverPolicy() + const effectivePolicyResult = resolveEffectivePolicyFn(null, receiverPolicy) + if ('unsatisfiable' in effectivePolicyResult) { + return { success: false, error: 'Policy resolution failed' } + } + const effectivePolicy = effectivePolicyResult + + const senderP2PEndpoint: string | null = + typeof capsule.p2p_endpoint === 'string' && capsule.p2p_endpoint.trim().length > 0 + ? capsule.p2p_endpoint.trim() + : null + const localP2pAuthToken: string = + typeof capsule.p2p_auth_token === 'string' && capsule.p2p_auth_token.trim().length > 0 + ? capsule.p2p_auth_token.trim() + : randomUUID() + + const record: HandshakeRecord = { + handshake_id: capsule.handshake_id, + relationship_id: capsule.relationship_id, + state: HS.PENDING_ACCEPT, + initiator: { + email: capsule.senderIdentity.email, + wrdesk_user_id: capsule.sender_wrdesk_user_id, + iss: capsule.senderIdentity.iss, + sub: capsule.senderIdentity.sub, + }, + acceptor: null, + local_role: 'initiator', + sharing_mode: null, + reciprocal_allowed: capsule.reciprocal_allowed, + tier_snapshot: tierDecision, + current_tier_signals: capsule.tierSignals, + last_seq_sent: 0, + last_seq_received: 0, + last_capsule_hash_sent: '', + last_capsule_hash_received: capsule.capsule_hash, + effective_policy: effectivePolicy, + external_processing: capsule.external_processing, + created_at: new Date().toISOString(), + activated_at: null, + expires_at: new Date(Date.now() + INPUT_LIMITS.PENDING_TIMEOUT_MS).toISOString(), + revoked_at: null, + revocation_source: null, + initiator_wrdesk_policy_hash: capsule.wrdesk_policy_hash, + initiator_wrdesk_policy_version: capsule.wrdesk_policy_version, + acceptor_wrdesk_policy_hash: null, + acceptor_wrdesk_policy_version: null, + initiator_context_commitment: capsule.context_commitment ?? null, + acceptor_context_commitment: null, + p2p_endpoint: senderP2PEndpoint, + local_p2p_auth_token: localP2pAuthToken, + counterparty_p2p_token: null, + local_public_key: keypair.publicKey, + local_private_key: keypair.privateKey, + receiver_email: capsule.receiver_email ?? null, + ...(beapKeys + ? { + local_x25519_private_key_b64: beapKeys.sender_x25519_private_key_b64, + local_x25519_public_key_b64: beapKeys.sender_x25519_public_key_b64, + local_mlkem768_secret_key_b64: beapKeys.sender_mlkem768_secret_key_b64, + local_mlkem768_public_key_b64: beapKeys.sender_mlkem768_public_key_b64, + } + : {}), + ...(capsule.sender_device_id?.trim() + ? { initiator_coordination_device_id: capsule.sender_device_id.trim() } + : {}), + ...(wireDeclaresSamePrincipal(capsule) + ? { + same_principal: true, + initiator_device_name: capsule.sender_computer_name?.trim() || null, + initiator_device_role: capsule.sender_device_role ?? null, + // Pairing-code-routed initiate capsules carry no receiver device + // metadata on the wire — the pairing code is the sole peer + // identifier, verified at acceptance time. + acceptor_coordination_device_id: capsule.receiver_device_id?.trim() || null, + acceptor_device_name: capsule.receiver_computer_name?.trim() || null, + acceptor_device_role: capsule.receiver_device_role ?? null, + internal_peer_device_id: capsule.receiver_device_id?.trim() || null, + internal_peer_device_role: capsule.receiver_device_role ?? null, + internal_peer_computer_name: capsule.receiver_computer_name?.trim() || null, + internal_peer_pairing_code: capsule.receiver_pairing_code?.trim() || null, + } + : {}), + } + + insertHandshakeRecord(db, record, prep.formation) + insertSeenCapsuleHash(db, capsule.handshake_id, capsule.capsule_hash) + if (policySelections && ((policySelections as { ai_processing_mode?: AiProcessingMode }).ai_processing_mode !== undefined + || (policySelections as { cloud_ai?: boolean }).cloud_ai !== undefined + || (policySelections as { internal_ai?: boolean }).internal_ai !== undefined)) { + updateHandshakePolicySelections(db, capsule.handshake_id, policySelections) + } + console.log('[HANDSHAKE] Initiator formation OK:', capsule.handshake_id, 'state=PENDING_ACCEPT consent=', prep.consent.consent_id) + + const relationshipId = capsule.relationship_id + const hasPolicy = policySelections && ((policySelections as { ai_processing_mode?: AiProcessingMode }).ai_processing_mode !== undefined + || (policySelections as { cloud_ai?: boolean }).cloud_ai !== undefined + || (policySelections as { internal_ai?: boolean }).internal_ai !== undefined) + const globalBaseline = hasPolicy + ? baselineFromPolicySelections(policySelections, record.effective_policy) + : baselineFromHandshake(record) + const buildGov = (b: { block_id: string; type: string }): ContextItemGovernance => { + const isMsg = b.type === 'message' || b.block_id?.startsWith('ctx-msg') + if (isMsg) { + return createMessageGovernance({ + publisher_id: session.wrdesk_user_id, + sender_wrdesk_user_id: session.wrdesk_user_id, + }) + } + const itemPolicy = blockPolicyMap?.get(b.block_id) + const baseline = itemPolicy + ? baselineFromPolicySelections(itemPolicy as Parameters[0], record.effective_policy) + : globalBaseline + return createDefaultGovernance({ + origin: 'local', + usage_policy: { ...baseline }, + provenance: { publisher_id: session.wrdesk_user_id, sender_wrdesk_user_id: session.wrdesk_user_id }, + }) + } + + for (const block of localBlocks) { + try { + insertContextStoreEntry(db, { + block_id: block.block_id, + block_hash: block.block_hash, + handshake_id: capsule.handshake_id, + relationship_id: relationshipId, + scope_id: block.scope_id ?? null, + publisher_id: session.wrdesk_user_id, + type: block.type, + content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? {}), + status: 'pending_delivery', + valid_until: null, + ingested_at: null, + superseded: 0, + governance_json: JSON.stringify(buildGov(block)), + }) + } catch { + /* non-fatal — context delivery can be retried */ + } + } + + return { success: true } + } catch (err: any) { + return { + success: false, + error: err?.message ?? 'Initiator formation failed', + } + } +} + +/** + * The initiator's explicit creation act is the consent event on that side. + * Fail-closed on capture method and ingress path; writes the Hash-Pinned + * consent record and returns the FormationMeta for the insert. + */ +export function prepareInitiatorFormation(args: InitiatorConsentArgs, profileId: string): InitiatorConsentResult { + const capture = resolveCaptureMethodForFormation(args.capture_method) + if (!capture.ok) return { ok: false, reason: capture.reason.toUpperCase() } + if (!isRecordableIngressPath(args.ingress_path)) { + return { ok: false, reason: 'INGRESS_PATH_NOT_RECORDABLE' } + } + const profileRes = resolveProfile(profileId, 1) + if (!profileRes.ok) return { ok: false, reason: `${profileRes.reason.toUpperCase()}:${profileId}` } + + const consent = insertConsentRecord(getConnectOfferDb(), { + offer_id: null, + handshake_id: args.handshake_id, + role: 'initiator', + preview_hash: args.preview_hash, + bound_definition_hash: args.bound_definition_hash, + contract_state_hash: args.contract_state_hash, + capture_method: args.capture_method, + ingress_path: args.ingress_path, + source_reference: args.source_reference ?? null, + actor_wrdesk_user_id: args.actor_wrdesk_user_id, + }) + + return { + ok: true, + consent, + formation: { + profile_id: profileRes.record.id, + profile_version: profileRes.record.version, + ingress_path: args.ingress_path, + capture_method: args.capture_method, + source_reference: args.source_reference ?? null, + consent_id: consent.consent_id, + nonce: randomUUID(), + }, + } +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/grants.ts b/code/apps/electron-vite-project/electron/main/handshake/grants.ts new file mode 100644 index 000000000..f36000ba9 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/grants.ts @@ -0,0 +1,341 @@ +/** + * Grant objects (Phase 5 — E2–E4, E9) [VII.10.x, VII.2.7] + * + * Distinct, receiver-enforced right objects replacing the flattened + * `effective_policy` + one-bit `sharing_mode` as the enforcement authority: + * + * - DELIVERY rights: scope-bound; enforced by the Phase-1 receiver-side + * ingress filter, which consumes grant scopes — off-scope transmissions + * are blocked pre-visibility and logged; repeated off-scope delivery + * surfaces a one-tap revoke offer [VII.10.2]. Every delivered item + * carries a reference to the grant it was delivered under [VII.10.3]. + * - PREPARATION rights: representable as a type; standing action scopes + * (pinned template hashes, effect vocabulary) are NOT built here — the + * scope slot stays open [VII.10.1, VII.10.5]. + * + * There is deliberately NO `execute` grant type. Execution is never a + * standing right — every execution is a distinct human consent tap (V4, see + * `execution/`). + * + * Lifecycle: + * - Created only behind an explicit consent screen (`consent_id` → + * Hash-Pinned consent record from the Phase-4 staging store). + * - Unlimited-until-revoke ground state (no implicit expiry). + * - Limit extensions (`single_use`, `ttl`) are parse-level CRITICAL: + * present-but-not-understood → grant refused, never accepted as + * unlimited [VII.10.8.3] (see ingestion-core `capabilityToken.ts`). + * - Revocation kills all rights of the counterparty via the receiver + * filter, silently. + * + * Legacy backfill (mirrors `legacy_v0` core discipline): pre-Phase-5 + * relationships get one synthetic delivery grant derived from their + * flattened `sharing_mode` / `effective_policy.allowedScopes`, marked + * `backfilled = 1` with no consent_id — never a fabricated consent record. + * + * Store: `wr_grants` on the relationship-DB handle (migration v76 on the + * vault chain; `ensureGrantSchema` covers the frozen ledger handle, which + * transitionally still runs the pipeline). + */ + +import { randomUUID } from 'node:crypto' +import { UNDERSTOOD_LIMIT_EXTENSIONS } from '@repo/ingestion-core' +import { HandshakeState, type HandshakeRecord } from './types' +import { appendEvidenceBestEffort, poacGrantPayload } from './evidenceChain' + +// ── Model ───────────────────────────────────────────────────────────────────── + +/** No `execute` variant exists [VII.10.1]. */ +export type GrantType = 'delivery' | 'preparation' + +export interface GrantRow { + grant_id: string + handshake_id: string + grant_type: GrantType + direction: 'inbound' | 'outbound' + scopes_json: string + limit_extensions_json: string | null + consent_id: string | null + backfilled: number + created_at: string + revoked_at: string | null + revoke_reason: string | null +} + +/** Wildcard scope: the grant covers every scope (ground state for legacy). */ +export const GRANT_SCOPE_ALL = '*' + +// ── Schema (frozen-handle fallback) ────────────────────────────────────────── + +export function ensureGrantSchema(db: any): void { + db.exec(` + CREATE TABLE IF NOT EXISTS wr_grants ( + grant_id TEXT PRIMARY KEY, + handshake_id TEXT NOT NULL, + grant_type TEXT NOT NULL CHECK (grant_type IN ('delivery', 'preparation')), + direction TEXT NOT NULL DEFAULT 'inbound' CHECK (direction IN ('inbound', 'outbound')), + scopes_json TEXT NOT NULL, + limit_extensions_json TEXT, + consent_id TEXT, + backfilled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + revoked_at TEXT, + revoke_reason TEXT + ); + CREATE INDEX IF NOT EXISTS idx_wr_grants_handshake ON wr_grants (handshake_id, grant_type, revoked_at); + CREATE TABLE IF NOT EXISTS wr_grant_offscope_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + handshake_id TEXT NOT NULL, + grant_id TEXT, + scope TEXT, + kind TEXT NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_wr_grant_offscope_handshake ON wr_grant_offscope_events (handshake_id); + `) +} + +// ── Create / list / revoke ──────────────────────────────────────────────────── + +export type CreateGrantResult = + | { ok: true; grant: GrantRow } + | { ok: false; reason: 'ununderstood_limit_extension' | 'invalid_grant_type'; detail?: string } + +/** + * Create a grant behind an explicit consent event. Limit extensions are + * parse-level critical: an ununderstood extension refuses the grant — it is + * never accepted as unlimited [VII.10.8.3]. + */ +export function createGrant( + db: any, + args: { + handshakeId: string + grantType: GrantType + direction?: 'inbound' | 'outbound' + scopes: readonly string[] + limitExtensions?: ReadonlyArray<{ ns: string; payload?: unknown }> + consentId: string | null + actorWrdeskUserId?: string | null + backfilled?: boolean + now?: Date + }, +): CreateGrantResult { + ensureGrantSchema(db) + if (args.grantType !== 'delivery' && args.grantType !== 'preparation') { + return { ok: false, reason: 'invalid_grant_type', detail: String(args.grantType) } + } + for (const ext of args.limitExtensions ?? []) { + if (!UNDERSTOOD_LIMIT_EXTENSIONS.has(ext.ns)) { + return { ok: false, reason: 'ununderstood_limit_extension', detail: ext.ns } + } + } + + const grant: GrantRow = { + grant_id: randomUUID(), + handshake_id: args.handshakeId, + grant_type: args.grantType, + direction: args.direction ?? 'inbound', + scopes_json: JSON.stringify(args.scopes.length > 0 ? args.scopes : [GRANT_SCOPE_ALL]), + limit_extensions_json: + args.limitExtensions && args.limitExtensions.length > 0 + ? JSON.stringify(args.limitExtensions) + : null, + consent_id: args.consentId, + backfilled: args.backfilled ? 1 : 0, + created_at: (args.now ?? new Date()).toISOString(), + revoked_at: null, + revoke_reason: null, + } + db.prepare( + `INSERT INTO wr_grants + (grant_id, handshake_id, grant_type, direction, scopes_json, limit_extensions_json, + consent_id, backfilled, created_at, revoked_at, revoke_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)`, + ).run( + grant.grant_id, + grant.handshake_id, + grant.grant_type, + grant.direction, + grant.scopes_json, + grant.limit_extensions_json, + grant.consent_id, + grant.backfilled, + grant.created_at, + ) + + // PoAC — grant creation is an authorized change [IX.19.1]. Backfilled + // legacy grants are evidence too (their payload says so via consent_id null). + appendEvidenceBestEffort({ + chainId: args.handshakeId, + recordType: 'poac', + payload: poacGrantPayload({ + event: 'grant_created', + grant_id: grant.grant_id, + handshake_id: grant.handshake_id, + grant_type: grant.grant_type, + scopes: JSON.parse(grant.scopes_json), + consent_id: grant.consent_id, + actor_wrdesk_user_id: args.actorWrdeskUserId ?? null, + }), + }) + + return { ok: true, grant } +} + +export function listGrants(db: any, handshakeId: string): GrantRow[] { + ensureGrantSchema(db) + return db + .prepare(`SELECT * FROM wr_grants WHERE handshake_id = ? ORDER BY created_at ASC`) + .all(handshakeId) as GrantRow[] +} + +/** + * Revocation kills ALL rights of the counterparty [VII.10.8]. Called from + * `revokeHandshake`; silent (receiver-filter enforcement, no capsule). + */ +export function revokeGrantsForHandshake( + db: any, + handshakeId: string, + reason: string, + actorWrdeskUserId?: string | null, + now: Date = new Date(), +): number { + ensureGrantSchema(db) + const active = db + .prepare(`SELECT * FROM wr_grants WHERE handshake_id = ? AND revoked_at IS NULL`) + .all(handshakeId) as GrantRow[] + if (active.length === 0) return 0 + const ts = now.toISOString() + const stmt = db.prepare(`UPDATE wr_grants SET revoked_at = ?, revoke_reason = ? WHERE grant_id = ?`) + for (const g of active) { + stmt.run(ts, reason, g.grant_id) + appendEvidenceBestEffort({ + chainId: handshakeId, + recordType: 'poac', + payload: poacGrantPayload({ + event: 'grant_revoked', + grant_id: g.grant_id, + handshake_id: handshakeId, + grant_type: g.grant_type, + scopes: JSON.parse(g.scopes_json), + consent_id: g.consent_id, + actor_wrdesk_user_id: actorWrdeskUserId ?? null, + }), + }) + } + return active.length +} + +// ── Resolution (receiver-filter consumption) ───────────────────────────────── + +/** + * Legacy backfill: a pre-Phase-5 relationship without any grant row gets one + * synthetic inbound delivery grant derived from its flattened policy — + * scopes from `effective_policy.allowedScopes` (or the wildcard ground + * state), `backfilled = 1`, no consent_id (never fabricated). + */ +export function ensureLegacyDeliveryGrant(db: any, record: HandshakeRecord): GrantRow | null { + ensureGrantSchema(db) + const existing = db + .prepare( + `SELECT * FROM wr_grants WHERE handshake_id = ? AND grant_type = 'delivery' AND direction = 'inbound' + ORDER BY created_at ASC LIMIT 1`, + ) + .get(record.handshake_id) as GrantRow | undefined + if (existing) return existing + // Only live relationships are backfilled — a revoked one gets no rights. + if (record.state === HandshakeState.REVOKED || record.state === HandshakeState.EXPIRED) { + return null + } + const scopes = + Array.isArray(record.effective_policy?.allowedScopes) && + record.effective_policy.allowedScopes.length > 0 + ? record.effective_policy.allowedScopes + : [GRANT_SCOPE_ALL] + const r = createGrant(db, { + handshakeId: record.handshake_id, + grantType: 'delivery', + direction: 'inbound', + scopes, + consentId: null, + backfilled: true, + }) + return r.ok ? r.grant : null +} + +/** Active (non-revoked) inbound delivery grant for a relationship, if any. */ +export function resolveActiveDeliveryGrant(db: any, handshakeId: string): GrantRow | null { + ensureGrantSchema(db) + const row = db + .prepare( + `SELECT * FROM wr_grants + WHERE handshake_id = ? AND grant_type = 'delivery' AND direction = 'inbound' AND revoked_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + ) + .get(handshakeId) as GrantRow | undefined + return row ?? null +} + +/** + * Resolve the grant a delivered item was admitted under [VII.10.3]: the + * inbound delivery grant active at the item's ingestion time. Deterministic + * for admitted items (grants are unlimited-until-revoke, per relationship). + * Used to render provenance for rows that predate the stored `grant_ref`. + */ +export function resolveDeliveryGrantAt(db: any, handshakeId: string, atIso: string): GrantRow | null { + ensureGrantSchema(db) + const row = db + .prepare( + `SELECT * FROM wr_grants + WHERE handshake_id = ? AND grant_type = 'delivery' AND direction = 'inbound' + AND created_at <= ? + AND (revoked_at IS NULL OR revoked_at > ?) + ORDER BY created_at DESC LIMIT 1`, + ) + .get(handshakeId, atIso, atIso) as GrantRow | undefined + return row ?? null +} + +export function grantScopeAllows(grant: GrantRow, scope: string): boolean { + let scopes: string[] + try { + scopes = JSON.parse(grant.scopes_json) + } catch { + return false + } + return scopes.includes(GRANT_SCOPE_ALL) || scopes.includes(scope) +} + +// ── Off-scope tracking → one-tap revoke offer [VII.10.2] ───────────────────── + +/** Repetition threshold after which the one-tap revoke offer is surfaced. */ +export const OFFSCOPE_REVOKE_OFFER_THRESHOLD = 3 + +export function recordOffScopeEvent( + db: any, + args: { handshakeId: string; grantId: string | null; scope: string | null; kind: string; source: string }, + now: Date = new Date(), +): void { + ensureGrantSchema(db) + db.prepare( + `INSERT INTO wr_grant_offscope_events (handshake_id, grant_id, scope, kind, source, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(args.handshakeId, args.grantId, args.scope, args.kind, args.source, now.toISOString()) +} + +export function countOffScopeEvents(db: any, handshakeId: string): number { + ensureGrantSchema(db) + const row = db + .prepare(`SELECT COUNT(*) AS n FROM wr_grant_offscope_events WHERE handshake_id = ?`) + .get(handshakeId) as { n: number } + return row.n +} + +/** + * True once repeated off-scope delivery should surface the one-tap revoke + * offer [VII.10.2]. Read by the UI/IPC layer; the offer itself is a render + * concern — no auto-revoke happens here. + */ +export function offScopeRevokeOfferDue(db: any, handshakeId: string): boolean { + return countOffScopeEvents(db, handshakeId) >= OFFSCOPE_REVOKE_OFFER_THRESHOLD +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/handshakeAccountIsolation.ts b/code/apps/electron-vite-project/electron/main/handshake/handshakeAccountIsolation.ts index d256092bd..300b7bbd7 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/handshakeAccountIsolation.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/handshakeAccountIsolation.ts @@ -1,58 +1,80 @@ /** * Account isolation for handshake list / recipient pickers: hide rows that do not * belong to the current SSO session, without mutating the DB. + * + * Identity comparison is the shared full-claim guard [VII.3.8–3.10] + * (`@repo/ingestion-core` — issuer + subject + full bound claim set, exact + * match, no OR-logic). Per adopted decision Q12, existing rows that only + * matched under the old OR-logic (mixed-realm rows: e.g. same email under a + * different issuer) stay visible but are flagged `repair_needed` — enforcement + * paths (ingest/ack/return, ingress filter, service RPC) reject them strictly; + * this module only governs list visibility. */ +import { + fullClaimIdentityMatch, + samePrincipalFullClaim, + type IdentityClaimSet, +} from '@repo/ingestion-core' import { isSameAccountHandshakeEmails, validateReceiverEmail } from '../../../../../packages/shared/src/handshake/receiverEmailValidation' import type { HandshakeRecord, PartyIdentity, SSOSession } from './types' import { HandshakeState } from './types' -export type HandshakeRowVisibility = { ok: true } | { ok: false; reason: string } +export type HandshakeRowVisibility = + | { ok: true; repair_needed?: boolean; repair_reason?: string } + | { ok: false; reason: string } + +function sessionClaims(session: SSOSession): IdentityClaimSet { + return { + iss: session.iss, + sub: session.sub, + email: session.email, + wrdesk_user_id: session.wrdesk_user_id, + } +} /** - * True when the authenticated session is the same human/device account as the party - * (email, wrdesk id, or iss+sub). + * Session-vs-party visibility classification. + * + * - `match`: full-claim guard passed (all bound claims match exactly). + * - `mixed_realm_repair`: guard failed, but the row still resolves to this + * session under the retired OR-logic (wrdesk id, iss+sub pair, or email). + * Q12: keep visible, flag for repair UX — never treat as an enforcement match. + * - `foreign`: no basis to show this row to the session. */ -export function sessionMatchesParty(session: SSOSession, party: PartyIdentity | null | undefined): boolean { - if (!party) return false +export function classifyPartyForSessionVisibility( + session: SSOSession, + party: PartyIdentity | null | undefined, +): 'match' | 'mixed_realm_repair' | 'foreign' { + if (!party) return 'foreign' + const guard = fullClaimIdentityMatch(sessionClaims(session), party) + if (guard.ok) return 'match' + + // Q12 legacy-admit predicate — visibility only, mirrors the retired OR-logic. const sw = (session.wrdesk_user_id || '').trim() const pw = (party.wrdesk_user_id || '').trim() - if (sw.length > 0 && pw.length > 0 && sw === pw) return true + const wrdeskMatched = sw.length > 0 && pw.length > 0 && sw === pw const iss = (session.iss || '').trim() const piss = (party.iss || '').trim() const sub = (session.sub || '').trim() const psub = (party.sub || '').trim() - if (iss.length > 0 && piss.length > 0 && sub.length > 0 && psub.length > 0 && iss === piss && sub === psub) { - return true - } - return isSameAccountHandshakeEmails(session.email, party.email) -} - -/** For internal: initiator and acceptor must be the same principal. */ -export function samePrincipalForInternal( - initiator: PartyIdentity, - acceptor: PartyIdentity, -): boolean { - if (!isSameAccountHandshakeEmails(initiator.email, acceptor.email)) return false - const iw = (initiator.wrdesk_user_id || '').trim() - const aw = (acceptor.wrdesk_user_id || '').trim() - if (iw.length > 0 && aw.length > 0 && iw !== aw) return false - const iiss = (initiator.iss || '').trim() - const aiss = (acceptor.iss || '').trim() - const isub = (initiator.sub || '').trim() - const asub = (acceptor.sub || '').trim() - if (iiss && aiss && isub && asub) { - if (iiss !== aiss || isub !== asub) return false + const issSubMatched = + iss.length > 0 && piss.length > 0 && sub.length > 0 && psub.length > 0 && iss === piss && sub === psub + const emailMatched = isSameAccountHandshakeEmails(session.email, party.email) + if (wrdeskMatched || issSubMatched || emailMatched) { + return 'mixed_realm_repair' } - return true + return 'foreign' } -/** Acceptor-side file import: party identity is not bound until accept (`recipientPersist.ts`). */ +/** Acceptor-side file import: party identity is not bound until accept (Connect-offer consent gate). */ function isPendingAcceptorPartyForSession(r: HandshakeRecord, session: SSOSession): boolean { if (r.local_role !== 'acceptor') return false if (r.state !== HandshakeState.PENDING_REVIEW) return false return validateReceiverEmail(r.receiver_email, session.email).valid } +const REPAIR_LOG = '[IDENTITY_GUARD] mixed_realm_row_repair_needed' + /** * Returns whether a persisted handshake row may be returned to the current session * (list / BEAP recipient picker). Does not read the DB; hide-only semantics. @@ -61,9 +83,9 @@ export function handshakeRowVisibilityForSession( r: HandshakeRecord, session: SSOSession, ): HandshakeRowVisibility { - if (r.handshake_type === 'internal') { + if (r.same_principal === true) { if (r.acceptor) { - if (!samePrincipalForInternal(r.initiator, r.acceptor)) { + if (!samePrincipalFullClaim(r.initiator, r.acceptor).ok) { return { ok: false, reason: 'internal_mismatched_principals' } } } else { @@ -71,16 +93,29 @@ export function handshakeRowVisibilityForSession( return { ok: false, reason: 'internal_pending_receiver_mismatch' } } } - if (sessionMatchesParty(session, r.initiator)) return { ok: true } - if (r.acceptor && sessionMatchesParty(session, r.acceptor)) return { ok: true } - if (isPendingAcceptorPartyForSession(r, session)) return { ok: true } - return { ok: false, reason: 'internal_session_not_party' } } - if (sessionMatchesParty(session, r.initiator)) return { ok: true } - if (r.acceptor && sessionMatchesParty(session, r.acceptor)) return { ok: true } - if (isPendingAcceptorPartyForSession(r, session)) return { ok: true } - return { ok: false, reason: 'standard_session_not_party' } + const vsInitiator = classifyPartyForSessionVisibility(session, r.initiator) + const vsAcceptor = r.acceptor ? classifyPartyForSessionVisibility(session, r.acceptor) : 'foreign' + + if (vsInitiator === 'match' || vsAcceptor === 'match') { + return { ok: true } + } + if (vsInitiator === 'mixed_realm_repair' || vsAcceptor === 'mixed_realm_repair') { + console.warn(REPAIR_LOG, { + handshake_id: r.handshake_id, + same_principal: r.same_principal === true, + reason: 'full_claim_guard_failed_legacy_or_logic_matched', + }) + return { ok: true, repair_needed: true, repair_reason: 'mixed_realm_claims' } + } + if (isPendingAcceptorPartyForSession(r, session)) { + return { ok: true } + } + return { + ok: false, + reason: r.same_principal === true ? 'internal_session_not_party' : 'standard_session_not_party', + } } const HIDDEN = '[HANDSHAKE_ACCOUNT_ISOLATION] hidden_row' @@ -105,7 +140,7 @@ export function filterHandshakeRecordsForCurrentSession( if (v.ok) { out.push(r) } else { - console.warn(HIDDEN, { handshake_id: r.handshake_id, reason: v.reason, handshake_type: r.handshake_type }) + console.warn(HIDDEN, { handshake_id: r.handshake_id, reason: v.reason, same_principal: r.same_principal === true }) } } return out diff --git a/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthRemoteCheck.ts b/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthRemoteCheck.ts index 43defbb41..8ea5fcef3 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthRemoteCheck.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthRemoteCheck.ts @@ -71,7 +71,7 @@ export async function runHandshakeHealthRemoteCheckAfterRelayConnect( } for (const r of rows) { - if (r.handshake_type !== 'internal') continue + if (r.same_principal !== true) continue const uidI = r.initiator?.wrdesk_user_id const uidA = r.acceptor?.wrdesk_user_id if (typeof uidI !== 'string' || typeof uidA !== 'string' || uidI !== uidA) { diff --git a/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthStartupLog.ts b/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthStartupLog.ts index 845df7a01..2fe9f5604 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthStartupLog.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/handshakeHealthStartupLog.ts @@ -55,7 +55,7 @@ export function logHandshakeHealthStartupLines(db: unknown): void { let localRole: string let peerRole: string let peerDev: string - if (r.handshake_type === 'internal') { + if (r.same_principal === true) { const dr = deriveInternalHostAiPeerRoles(r, localId) if (dr.ok) { localRole = dr.localRole diff --git a/code/apps/electron-vite-project/electron/main/handshake/handshakeVerification.ts b/code/apps/electron-vite-project/electron/main/handshake/handshakeVerification.ts deleted file mode 100644 index ab70321ee..000000000 --- a/code/apps/electron-vite-project/electron/main/handshake/handshakeVerification.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Handshake Verification — Full Cryptographic Verification Pipeline - * - * When a capsule arrives at the receiving orchestrator, this module - * performs the complete verification sequence: - * - * 1. Required field presence check - * 2. Nonce format validation - * 3. Timestamp freshness validation (clock skew window) - * 4. Nonce replay check (against seen-nonce store) - * 5. Receiver email binding verification - * 6. Canonical payload reconstruction - * 7. SHA-256 context_hash recalculation and comparison - * 8. capsule_hash verification (existing chain integrity hash) - * - * This module is called AFTER Gate 2 (canonicalRebuild) has validated - * field formats and BEFORE the handshake pipeline processes the capsule. - * - * Failure at any step produces a typed reason code for audit logging. - */ - -import { - type ContextHashInput, - verifyContextHash, - validateTimestamp, - validateNonce, -} from './contextHash' -import { computeCapsuleHash, type CapsuleHashInput } from './capsuleHash' -import { verifyContextCommitment, type ContextBlockForCommitment } from './contextCommitment' -import { INPUT_LIMITS } from './types' - -// ── Result types ── - -export type HandshakeVerifyResult = - | { verified: true } - | { verified: false; step: string; reason: string } - -export interface HandshakeCapsuleFields { - schema_version: number - capsule_type: 'initiate' | 'accept' | 'refresh' | 'revoke' - handshake_id: string - relationship_id: string - sender_id: string - sender_wrdesk_user_id: string - sender_email: string - receiver_id: string - receiver_email: string - timestamp: string - nonce: string - seq: number - capsule_hash: string - context_hash: string - context_commitment?: string | null - wrdesk_policy_hash: string - wrdesk_policy_version: string - sharing_mode?: string - prev_hash?: string - senderIdentity?: { sub: string } - receiverIdentity?: { sub: string } | null - context_blocks?: ReadonlyArray -} - -// ── Main verification function ── - -/** - * Full cryptographic verification of a received handshake capsule. - * - * @param capsule Capsule fields (post-canonical-rebuild) - * @param expectedReceiverEmail The local orchestrator's email - * @param seenNonces Set of previously seen nonces for this handshake - * @param now Current time for timestamp validation - * @param clockSkewToleranceMs Acceptable clock drift (default: 5 minutes) - */ -export function verifyHandshakeCapsule( - capsule: HandshakeCapsuleFields, - expectedReceiverEmail: string, - seenNonces: ReadonlySet, - now: Date = new Date(), - clockSkewToleranceMs: number = INPUT_LIMITS.CLOCK_SKEW_TOLERANCE_MS, -): HandshakeVerifyResult { - - // Step 1: Required field presence - const requiredFields: Array = [ - 'schema_version', 'capsule_type', 'handshake_id', 'relationship_id', - 'sender_id', 'sender_wrdesk_user_id', 'sender_email', 'receiver_id', - 'receiver_email', 'timestamp', 'nonce', 'seq', 'capsule_hash', - 'context_hash', 'wrdesk_policy_hash', 'wrdesk_policy_version', - ] - - for (const field of requiredFields) { - if (capsule[field] === undefined || capsule[field] === null || capsule[field] === '') { - return { verified: false, step: 'required_fields', reason: `Missing required field: ${field}` } - } - } - - // Step 2: Nonce format validation - const nonceCheck = validateNonce(capsule.nonce) - if (!nonceCheck.valid) { - return { verified: false, step: 'nonce_format', reason: nonceCheck.reason } - } - - // Step 3: Timestamp freshness - const tsCheck = validateTimestamp(capsule.timestamp, now, clockSkewToleranceMs) - if (!tsCheck.valid) { - return { verified: false, step: 'timestamp_freshness', reason: tsCheck.reason } - } - - // Step 4: Nonce replay check - if (seenNonces.has(capsule.nonce)) { - return { verified: false, step: 'nonce_replay', reason: 'Nonce has been seen before — possible replay attack' } - } - - // Step 5: Receiver email binding - if (capsule.receiver_email !== expectedReceiverEmail) { - return { - verified: false, - step: 'receiver_binding', - reason: `receiver_email "${capsule.receiver_email}" does not match expected "${expectedReceiverEmail}"`, - } - } - - // Step 6–7: Context hash verification (reconstructs canonical payload internally) - const contextHashInput: ContextHashInput = { - schema_version: capsule.schema_version, - capsule_type: capsule.capsule_type, - handshake_id: capsule.handshake_id, - relationship_id: capsule.relationship_id, - sender_id: capsule.sender_id, - sender_wrdesk_user_id: capsule.sender_wrdesk_user_id, - sender_email: capsule.sender_email, - receiver_id: capsule.receiver_id, - receiver_email: capsule.receiver_email, - timestamp: capsule.timestamp, - nonce: capsule.nonce, - seq: capsule.seq, - wrdesk_policy_hash: capsule.wrdesk_policy_hash, - wrdesk_policy_version: capsule.wrdesk_policy_version, - sharing_mode: capsule.sharing_mode, - prev_hash: capsule.prev_hash, - } - - const contextCheck = verifyContextHash(contextHashInput, capsule.context_hash) - if (!contextCheck.valid) { - return { verified: false, step: 'context_hash', reason: contextCheck.reason } - } - - // Step 8: Context commitment verification (schema v2+) - if (capsule.schema_version >= 2 && capsule.context_commitment != null) { - const commitCheck = verifyContextCommitment(capsule.context_commitment, capsule.context_blocks) - if (!commitCheck.valid) { - return { verified: false, step: 'context_commitment', reason: commitCheck.reason } - } - } - - // Step 9: capsule_hash verification (chain integrity hash) - const capsuleHashInput: CapsuleHashInput = { - capsule_type: capsule.capsule_type, - handshake_id: capsule.handshake_id, - relationship_id: capsule.relationship_id, - schema_version: capsule.schema_version, - sender_wrdesk_user_id: capsule.sender_wrdesk_user_id, - receiver_email: capsule.schema_version >= 2 ? capsule.receiver_email : undefined, - seq: capsule.seq, - timestamp: capsule.timestamp, - sharing_mode: capsule.sharing_mode, - prev_hash: capsule.prev_hash, - wrdesk_policy_hash: capsule.wrdesk_policy_hash, - wrdesk_policy_version: capsule.wrdesk_policy_version, - context_commitment: capsule.schema_version >= 2 ? capsule.context_commitment : undefined, - senderIdentity_sub: capsule.capsule_type === 'accept' && capsule.schema_version >= 2 ? capsule.senderIdentity?.sub : undefined, - receiverIdentity_sub: capsule.capsule_type === 'accept' && capsule.schema_version >= 2 ? capsule.receiverIdentity?.sub ?? undefined : undefined, - } - - const expectedCapsuleHash = computeCapsuleHash(capsuleHashInput) - if (capsule.capsule_hash !== expectedCapsuleHash) { - return { verified: false, step: 'capsule_hash', reason: 'capsule_hash does not match recomputed value' } - } - - return { verified: true } -} diff --git a/code/apps/electron-vite-project/electron/main/handshake/ingressAdmission.ts b/code/apps/electron-vite-project/electron/main/handshake/ingressAdmission.ts new file mode 100644 index 000000000..5a70c04b8 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/ingressAdmission.ts @@ -0,0 +1,258 @@ +/** + * Receiver-side ingress admission filter — [VII.2.7] (Phase 1, E2 groundwork). + * + * The FIRST ingress stage for inbound deliveries from a remote peer, run as + * soon as the target handshake_id is known and BEFORE anything is persisted + * or surfaced. It enforces what is representable today: + * + * 1. the relationship exists (except formation capsules, which create it), + * 2. it is live — not REVOKED / EXPIRED, and for message-class deliveries + * in its operational window (ACCEPTED | ACTIVE; ACCEPTED is the + * post-accept, pre-context-roundtrip window treated as active + * throughout the codebase), + * 3. the presented sender identity passes the shared full-claim guard + * [VII.3.8–3.10] when the transport authenticated one, + * 4. the delivery is within the flattened sharing_mode scope when the + * caller declares a context-bearing delivery. + * + * Blocked transmissions die pre-visibility: no inbox row, no placeholder, + * no notification — only an audit_log record (existing table, no schema + * change), a Tier-L admission evidence record, and a metadata-only console + * line. + * + * Phase 5 (E2–E4): the filter CONSUMES grant objects [VII.10.2]. Message- + * class deliveries resolve the relationship's inbound DELIVERY grant + * (legacy rows are lazily backfilled from the flattened policy); a delivery + * declaring a scope outside the grant is blocked pre-visibility, logged as + * an off-scope event, and — after repetition — surfaces the one-tap revoke + * offer. Admitted deliveries carry the grant reference (`grantRef`) so every + * delivered item resolves the grant it was delivered under [VII.10.3]. + * + * NOTE on service-RPC / DataChannel / p2p_signal ingress: those paths are + * admitted by `assertRecordForServiceRpc` (internal + ACTIVE + same-principal + * + identity-complete), which is a strict superset of this filter's checks. + * See internal-inference invariants; do not weaken that gate. + */ + +import { fullClaimIdentityMatch, type IdentityClaimSet } from '@repo/ingestion-core' +import { HandshakeState, type HandshakeRecord } from './types' +import { getHandshakeRecord, insertAuditLogEntry } from './db' +import { + ensureLegacyDeliveryGrant, + resolveActiveDeliveryGrant, + grantScopeAllows, + recordOffScopeEvent, + offScopeRevokeOfferDue, + type GrantRow, +} from './grants' +import { appendEvidenceBestEffort, poacAdmissionPayload } from './evidenceChain' + +/** Delivery class, decides which state window is admissible. */ +export type IngressDeliveryKind = + /** BEAP message/package for the inbox (direct_beap, email_beap, qBEAP). */ + | 'beap_message' + /** Handshake control-plane capsule (initiate/accept/refresh/revoke/context-sync). */ + | 'handshake_capsule' + +export type IngressBlockReason = + | 'unknown_relationship' + | 'relationship_revoked' + | 'relationship_expired' + | 'relationship_not_operational' + | 'sender_identity_mismatch' + | 'sharing_mode_scope_violation' + | 'delivery_rights_revoked' + | 'grant_scope_violation' + +export interface IngressAdmissionInput { + handshakeId: string + kind: IngressDeliveryKind + /** Transport tag for the log record ('coordination_ws' | 'relay_pull' | 'email' | 'file' | …). */ + source: string + /** + * Sender identity claims when (and only when) the transport authenticated + * them. Unauthenticated transport strings (e.g. an email From header) must + * NOT be passed here; identity is then enforced downstream where claims + * exist (ownership pipeline step). + */ + senderClaims?: IdentityClaimSet | null + /** Set when the delivery is known to carry shared-context payload. */ + carriesContext?: boolean + /** + * Declared delivery scope, when the transport/package declares one. An + * undeclared scope is admitted under the grant's ground state; a declared + * scope outside the grant is blocked pre-visibility [VII.10.2]. + */ + scope?: string | null +} + +export type IngressAdmissionResult = + | { + admitted: true + record: HandshakeRecord | null + /** + * Grant the delivery was admitted under [VII.10.3] — null only for + * control-plane capsules (formation precedes any grant). + */ + grantRef: string | null + } + | { admitted: false; reason: IngressBlockReason } + +/** Operational window for message-class deliveries. */ +const OPERATIONAL_STATES: ReadonlySet = new Set([ + HandshakeState.ACCEPTED, + HandshakeState.ACTIVE, +]) + +function isExpired(record: HandshakeRecord, now: Date): boolean { + if (record.expires_at == null) return false + const t = Date.parse(record.expires_at) + return !isNaN(t) && t < now.getTime() +} + +/** Remote party of the record as seen from the local role. */ +function counterpartyOf(record: HandshakeRecord): IdentityClaimSet | null { + const party = record.local_role === 'initiator' ? record.acceptor : record.initiator + if (!party) return null + return { + iss: party.iss ?? null, + sub: party.sub ?? null, + email: party.email ?? null, + wrdesk_user_id: party.wrdesk_user_id ?? null, + } +} + +function block( + db: any, + input: IngressAdmissionInput, + reason: IngressBlockReason, +): IngressAdmissionResult { + // Pre-visibility death still leaves a record: audit_log + metadata-only log line. + try { + insertAuditLogEntry(db, { + timestamp: new Date().toISOString(), + action: 'INGRESS_ADMISSION_BLOCKED', + handshake_id: input.handshakeId, + reason_code: reason, + failed_step: 'ingress_admission', + metadata: { kind: input.kind, source: input.source }, + }) + } catch { + /* audit failure must not mask the block */ + } + // Blocked admissions are PoAC-class evidence [IX.19.1] — Tier-L chain. + appendEvidenceBestEffort({ + chainId: input.handshakeId, + recordType: 'poac', + payload: poacAdmissionPayload({ + handshake_id: input.handshakeId, + decision: 'blocked', + reason, + kind: input.kind, + source: input.source, + }), + }) + console.log( + `[INGRESS_ADMISSION] blocked handshake=${input.handshakeId} kind=${input.kind} source=${input.source} reason=${reason}`, + ) + return { admitted: false, reason } +} + +/** + * Run the admission filter. Callers MUST invoke this before persisting or + * surfacing anything for an inbound delivery, and on `admitted: false` must + * drop the delivery without any user-visible artifact. + */ +export function admitInboundDelivery( + db: any, + input: IngressAdmissionInput, + now: Date = new Date(), +): IngressAdmissionResult { + let record: HandshakeRecord | null = null + try { + record = getHandshakeRecord(db, input.handshakeId) ?? null + } catch { + record = null + } + + if (!record) { + // Formation capsules legitimately arrive before a record exists; the + // handshake pipeline (receiver-email check, state machine) owns them. + if (input.kind === 'handshake_capsule') { + return { admitted: true, record: null, grantRef: null } + } + return block(db, input, 'unknown_relationship') + } + + if (record.state === HandshakeState.REVOKED) { + return block(db, input, 'relationship_revoked') + } + if (record.state === HandshakeState.EXPIRED || isExpired(record, now)) { + return block(db, input, 'relationship_expired') + } + + if (input.kind === 'beap_message' && !OPERATIONAL_STATES.has(record.state)) { + return block(db, input, 'relationship_not_operational') + } + + // Full-claim identity guard [VII.3.8–3.10] — only when the transport + // authenticated sender claims. Exact-match against the bound counterparty; + // no OR-logic, no sub-only shortcut (shared guard semantics). + if (input.senderClaims) { + const bound = counterpartyOf(record) + if (bound && !fullClaimIdentityMatch(input.senderClaims, bound).ok) { + return block(db, input, 'sender_identity_mismatch') + } + } + + // Flattened sharing_mode scope: a receive-only relationship must not accept + // context-bearing deliveries originating from the acceptor side. (Kept as + // defense-in-depth under the grant model — the legacy backfill derives its + // grant from the same flattened policy.) + if ( + input.carriesContext === true && + record.sharing_mode === 'receive-only' && + record.local_role === 'initiator' + ) { + return block(db, input, 'sharing_mode_scope_violation') + } + + // Grant-object consumption (Phase 5, E2) [VII.10.2–10.3]: message-class + // deliveries are admitted under the relationship's inbound delivery grant. + // Control-plane capsules are pre-grant (formation precedes any grant). + if (input.kind === 'beap_message') { + let grant: GrantRow | null = null + try { + grant = resolveActiveDeliveryGrant(db, input.handshakeId) ?? ensureLegacyDeliveryGrant(db, record) + } catch { + grant = null + } + if (!grant) { + return block(db, input, 'delivery_rights_revoked') + } + if (typeof input.scope === 'string' && input.scope.length > 0 && !grantScopeAllows(grant, input.scope)) { + try { + recordOffScopeEvent(db, { + handshakeId: input.handshakeId, + grantId: grant.grant_id, + scope: input.scope, + kind: input.kind, + source: input.source, + }) + if (offScopeRevokeOfferDue(db, input.handshakeId)) { + // Surfaced to the UI as a one-tap revoke offer [VII.10.2]; the + // offer is read via offScopeRevokeOfferDue — no auto-revoke here. + console.log( + `[INGRESS_ADMISSION] off-scope repetition threshold reached handshake=${input.handshakeId} — revoke offer due`, + ) + } + } catch { + /* off-scope bookkeeping failure must not mask the block */ + } + return block(db, input, 'grant_scope_violation') + } + return { admitted: true, record, grantRef: grant.grant_id } + } + + return { admitted: true, record, grantRef: null } +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/initiatorPersist.ts b/code/apps/electron-vite-project/electron/main/handshake/initiatorPersist.ts deleted file mode 100644 index 9a93b0062..000000000 --- a/code/apps/electron-vite-project/electron/main/handshake/initiatorPersist.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Initiator Persist — Direct DB Insert for Own Handshake Record - * - * When the initiator creates a handshake, they need a local record. This is NOT - * an incoming capsule from a counterparty — it's the initiator persisting their - * own outgoing handshake. It must NOT go through the receive/ingestion pipeline, - * which rejects when senderId === localUserId (ownership check). - * - * This module provides direct DB insert that creates the same record shape the - * pipeline would produce for the receiver, but with local_role: 'initiator'. - */ - -import type { HandshakeCapsuleWire } from './capsuleBuilder' -import type { SigningKeypair } from './signatureKeys' -import type { SSOSession, HandshakeRecord, BeapKeyAgreementMaterial } from './types' -import type { ContextBlockForCommitment } from './contextCommitment' -import { HandshakeState as HS, INPUT_LIMITS } from './types' -import { buildDefaultReceiverPolicy } from './types' -import { classifyHandshakeTier } from './tierClassification' -import { resolveEffectivePolicyFn } from './steps/policyResolution' -import { insertHandshakeRecord, insertSeenCapsuleHash, insertContextStoreEntry, updateHandshakePolicySelections } from './db' -import { validateInternalInitiateCapsuleWire } from './internalPersistence' -import type { AiProcessingMode } from '../../../../../packages/shared/src/handshake/policyUtils' -import { - createDefaultGovernance, - createMessageGovernance, - baselineFromHandshake, - baselineFromPolicySelections, - type ContextItemGovernance, -} from './contextGovernance' -import { randomUUID } from 'crypto' - -export interface PersistInitiatorResult { - success: boolean - error?: string -} - -/** - * Persist the initiator's handshake record directly, bypassing the receive pipeline. - * Creates the same record the receiver would get, but with local_role: 'initiator'. - */ -export function persistInitiatorHandshakeRecord( - db: any, - capsule: HandshakeCapsuleWire, - session: SSOSession, - localBlocks: ContextBlockForCommitment[], - keypair: SigningKeypair, - policySelections?: { ai_processing_mode?: AiProcessingMode } | { cloud_ai?: boolean; internal_ai?: boolean }, - blockPolicyMap?: Map, - beapKeys?: BeapKeyAgreementMaterial | null, -): PersistInitiatorResult { - try { - if (capsule.handshake_type === 'internal') { - const w = validateInternalInitiateCapsuleWire(capsule as unknown as Record) - if (!w.ok) { - return { success: false, error: w.error ?? 'Internal initiate capsule invalid' } - } - } - - const tierDecision = classifyHandshakeTier({ - plan: session.plan, - hardwareAttestation: session.currentHardwareAttestation, - dnsVerification: session.currentDnsVerification, - wrStampStatus: session.currentWrStampStatus, - }) - - const receiverPolicy = buildDefaultReceiverPolicy() - const effectivePolicyResult = resolveEffectivePolicyFn(null, receiverPolicy) - if ('unsatisfiable' in effectivePolicyResult) { - return { success: false, error: 'Policy resolution failed' } - } - const effectivePolicy = effectivePolicyResult - - const senderP2PEndpoint: string | null = - typeof capsule.p2p_endpoint === 'string' && capsule.p2p_endpoint.trim().length > 0 - ? capsule.p2p_endpoint.trim() - : null - const localP2pAuthToken: string = - typeof capsule.p2p_auth_token === 'string' && capsule.p2p_auth_token.trim().length > 0 - ? capsule.p2p_auth_token.trim() - : randomUUID() - - const record: HandshakeRecord = { - handshake_id: capsule.handshake_id, - relationship_id: capsule.relationship_id, - state: HS.PENDING_ACCEPT, - initiator: { - email: capsule.senderIdentity.email, - wrdesk_user_id: capsule.sender_wrdesk_user_id, - iss: capsule.senderIdentity.iss, - sub: capsule.senderIdentity.sub, - }, - acceptor: null, - local_role: 'initiator', - sharing_mode: null, - reciprocal_allowed: capsule.reciprocal_allowed, - tier_snapshot: tierDecision, - current_tier_signals: capsule.tierSignals, - last_seq_sent: 0, - last_seq_received: 0, - last_capsule_hash_sent: '', - last_capsule_hash_received: capsule.capsule_hash, - effective_policy: effectivePolicy, - external_processing: capsule.external_processing, - created_at: new Date().toISOString(), - activated_at: null, - expires_at: new Date(Date.now() + INPUT_LIMITS.PENDING_TIMEOUT_MS).toISOString(), - revoked_at: null, - revocation_source: null, - initiator_wrdesk_policy_hash: capsule.wrdesk_policy_hash, - initiator_wrdesk_policy_version: capsule.wrdesk_policy_version, - acceptor_wrdesk_policy_hash: null, - acceptor_wrdesk_policy_version: null, - initiator_context_commitment: capsule.context_commitment ?? null, - acceptor_context_commitment: null, - p2p_endpoint: senderP2PEndpoint, - local_p2p_auth_token: localP2pAuthToken, - counterparty_p2p_token: null, - local_public_key: keypair.publicKey, - local_private_key: keypair.privateKey, - receiver_email: capsule.receiver_email ?? null, - ...(beapKeys - ? { - local_x25519_private_key_b64: beapKeys.sender_x25519_private_key_b64, - local_x25519_public_key_b64: beapKeys.sender_x25519_public_key_b64, - local_mlkem768_secret_key_b64: beapKeys.sender_mlkem768_secret_key_b64, - local_mlkem768_public_key_b64: beapKeys.sender_mlkem768_public_key_b64, - } - : {}), - ...(capsule.sender_device_id?.trim() - ? { initiator_coordination_device_id: capsule.sender_device_id.trim() } - : {}), - ...(capsule.handshake_type === 'internal' - ? { - handshake_type: 'internal', - initiator_device_name: capsule.sender_computer_name?.trim() || null, - initiator_device_role: capsule.sender_device_role ?? null, - // For new pairing-code-routed initiate capsules, receiver_device_id / - // receiver_device_role / receiver_computer_name are not present on the - // wire — `internal_peer_pairing_code` is the sole peer identifier and is - // verified at acceptance time. We persist nulls for the legacy peer - // metadata so the row is intentionally pairing-code-routed. - acceptor_coordination_device_id: capsule.receiver_device_id?.trim() || null, - acceptor_device_name: capsule.receiver_computer_name?.trim() || null, - acceptor_device_role: capsule.receiver_device_role ?? null, - internal_peer_device_id: capsule.receiver_device_id?.trim() || null, - internal_peer_device_role: capsule.receiver_device_role ?? null, - internal_peer_computer_name: capsule.receiver_computer_name?.trim() || null, - internal_peer_pairing_code: capsule.receiver_pairing_code?.trim() || null, - } - : {}), - } - - insertHandshakeRecord(db, record) - insertSeenCapsuleHash(db, capsule.handshake_id, capsule.capsule_hash) - if (policySelections && ((policySelections as { ai_processing_mode?: AiProcessingMode }).ai_processing_mode !== undefined - || (policySelections as { cloud_ai?: boolean }).cloud_ai !== undefined - || (policySelections as { internal_ai?: boolean }).internal_ai !== undefined)) { - updateHandshakePolicySelections(db, capsule.handshake_id, policySelections) - } - console.log('[HANDSHAKE] Initiator persist OK:', capsule.handshake_id, 'state=PENDING_ACCEPT') - - const relationshipId = capsule.relationship_id - const hasPolicy = policySelections && ((policySelections as { ai_processing_mode?: AiProcessingMode }).ai_processing_mode !== undefined - || (policySelections as { cloud_ai?: boolean }).cloud_ai !== undefined - || (policySelections as { internal_ai?: boolean }).internal_ai !== undefined) - const globalBaseline = hasPolicy - ? baselineFromPolicySelections(policySelections, record.effective_policy) - : baselineFromHandshake(record) - const buildGov = (b: { block_id: string; type: string }): ContextItemGovernance => { - const isMsg = b.type === 'message' || b.block_id?.startsWith('ctx-msg') - if (isMsg) { - return createMessageGovernance({ - publisher_id: session.wrdesk_user_id, - sender_wrdesk_user_id: session.wrdesk_user_id, - }) - } - // Per-item policy: override wins over global (Phase 2). itemPolicy uses ai_processing_mode or legacy. - const itemPolicy = blockPolicyMap?.get(b.block_id) - const baseline = itemPolicy - ? baselineFromPolicySelections(itemPolicy as Parameters[0], record.effective_policy) - : globalBaseline - return createDefaultGovernance({ - origin: 'local', - usage_policy: { ...baseline }, - provenance: { publisher_id: session.wrdesk_user_id, sender_wrdesk_user_id: session.wrdesk_user_id }, - }) - } - - for (const block of localBlocks) { - try { - insertContextStoreEntry(db, { - block_id: block.block_id, - block_hash: block.block_hash, - handshake_id: capsule.handshake_id, - relationship_id: relationshipId, - scope_id: block.scope_id ?? null, - publisher_id: session.wrdesk_user_id, - type: block.type, - content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? {}), - status: 'pending_delivery', - valid_until: null, - ingested_at: null, - superseded: 0, - governance_json: JSON.stringify(buildGov(block)), - }) - } catch { - /* non-fatal — context delivery can be retried */ - } - } - - return { success: true } - } catch (err: any) { - return { - success: false, - error: err?.message ?? 'Initiator persist failed', - } - } -} diff --git a/code/apps/electron-vite-project/electron/main/handshake/internalCoordinationWire.ts b/code/apps/electron-vite-project/electron/main/handshake/internalCoordinationWire.ts index d1516f845..dc79ab48f 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/internalCoordinationWire.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/internalCoordinationWire.ts @@ -17,7 +17,7 @@ export type InternalRelayCapsuleWireOpts = { type InternalRelayRecordSlice = Pick< HandshakeRecord, - | 'handshake_type' + | 'same_principal' | 'local_role' | 'initiator_coordination_device_id' | 'acceptor_coordination_device_id' @@ -35,7 +35,7 @@ export function internalRelayCapsuleWireOptsFromRecord( record: InternalRelayRecordSlice, localDeviceId: string | undefined, ): InternalRelayCapsuleWireOpts | null { - if (record.handshake_type !== 'internal') return null + if (record.same_principal !== true) return null if (record.internal_coordination_identity_complete !== true) return null const loc = localDeviceId?.trim() if (!loc) return null diff --git a/code/apps/electron-vite-project/electron/main/handshake/internalPersistence.ts b/code/apps/electron-vite-project/electron/main/handshake/internalPersistence.ts index 41e2bd525..19abd45bd 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/internalPersistence.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/internalPersistence.ts @@ -4,6 +4,7 @@ import type { HandshakeRecord } from './types' import { HandshakeState } from './types' +import { wireDeclaresSamePrincipal } from './samePrincipalWire' import { validateInternalEndpointFields, validateInternalEndpointPairDistinct, @@ -30,7 +31,7 @@ export function computeInternalRoutingKey( * both roles, both computer names (initiator_* + acceptor_* columns). */ export function isInternalCoordinationIdentityComplete(record: HandshakeRecord): boolean { - if (record.handshake_type !== 'internal') return false + if (record.same_principal !== true) return false const iid = record.initiator_coordination_device_id?.trim() ?? '' const aid = record.acceptor_coordination_device_id?.trim() ?? '' if (!iid || !aid) return false @@ -45,7 +46,7 @@ export function isInternalCoordinationIdentityComplete(record: HandshakeRecord): * - internal_coordination_identity_complete: strict symmetry (ids + roles + names). */ export function finalizeInternalHandshakePersistence(record: HandshakeRecord): HandshakeRecord { - if (record.handshake_type !== 'internal') { + if (record.same_principal !== true) { return { ...record, internal_routing_key: null, @@ -95,7 +96,7 @@ export function validateInternalInitiateCapsuleWire(c: Record): error?: string code?: string } { - if (c?.handshake_type !== 'internal') return { ok: true } + if (!wireDeclaresSamePrincipal(c)) return { ok: true } const initiatorId = typeof c.sender_device_id === 'string' && c.sender_device_id.trim().length > 0 ? c.sender_device_id.trim() diff --git a/code/apps/electron-vite-project/electron/main/handshake/internalRelayOutboundGuards.ts b/code/apps/electron-vite-project/electron/main/handshake/internalRelayOutboundGuards.ts index 9d3bd0ccb..52e93ced2 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/internalRelayOutboundGuards.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/internalRelayOutboundGuards.ts @@ -9,6 +9,7 @@ import { } from '../../../../../packages/shared/src/handshake/internalEndpointValidation' import { getHandshakeRecord } from './db' import { internalRelayCapsuleWireOptsFromRecord } from './internalCoordinationWire' +import { wireDeclaresSamePrincipal } from './samePrincipalWire' import { getInstanceId } from '../orchestrator/orchestratorModeStore' export const LOCAL_INTERNAL_RELAY_VALIDATION_FAILED = 'LOCAL_INTERNAL_RELAY_VALIDATION_FAILED' @@ -48,7 +49,7 @@ export function isInternalRelayCapsuleEnvelope(o: Record): bool */ export function collectInternalRelayWireGaps(o: Record): string[] { const missing: string[] = [] - if (o.handshake_type !== 'internal') missing.push('handshake_type') + if (!wireDeclaresSamePrincipal(o)) missing.push('handshake_type') if (!nz(o.sender_device_id)) missing.push('sender_device_id') const sr = o.sender_device_role if (sr !== 'host' && sr !== 'sandbox') missing.push('sender_device_role') @@ -69,10 +70,10 @@ export function collectInternalRelayWireGaps(o: Record): string } export function shouldValidateInternalRelayWire( - record: { handshake_type?: string | null } | null | undefined, + record: { same_principal?: boolean | null } | null | undefined, o: Record, ): boolean { - if (!record || record.handshake_type !== 'internal') return false + if (!record || record.same_principal !== true) return false if (isCoordinationRelayNativeBeap(o)) return false return isInternalRelayCapsuleEnvelope(o) } @@ -189,7 +190,7 @@ export function applyContextSyncInternalRoutingFromRecord( const ct = typeof payload.capsule_type === 'string' ? payload.capsule_type.trim() : '' if (ct !== 'context_sync') return const record = getHandshakeRecord(db, handshakeId.trim()) - if (!record || record.handshake_type !== 'internal') return + if (!record || record.same_principal !== true) return let localId = '' try { diff --git a/code/apps/electron-vite-project/electron/main/handshake/internalSandboxesApi.ts b/code/apps/electron-vite-project/electron/main/handshake/internalSandboxesApi.ts index f5716084c..ca2c07f82 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/internalSandboxesApi.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/internalSandboxesApi.ts @@ -7,6 +7,11 @@ import { listHandshakeRecords } from './db' import { getQueueStatus } from './outboundQueue' import { getP2PHealth } from '../p2p/p2pHealth' import { handshakeRowVisibilityForSession } from './handshakeAccountIsolation' +import { getInstanceId } from '../orchestrator/orchestratorModeStore' +import { + assertRecordForServiceRpc, + deriveInternalHostAiPeerRoles, +} from '../internalInference/policy' import { HandshakeState, type HandshakeRecord, type SSOSession } from './types' /** P2P-ingested inbox rows (no IMAP `account_id`); see `beapEmailIngestion`. */ @@ -79,7 +84,7 @@ export function computeAuthoritativeDeviceInternalRole( if (!db || !session) return 'none' const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) let host = false let sand = false @@ -121,11 +126,18 @@ function sessionIsPartyOnVisibleHandshakeRow(record: HandshakeRecord, session: S return handshakeRowVisibilityForSession(record, session).ok } +/** + * Canonical Host-AI role mapping (coordination device ids), not `local_role`. + * `local_role` is a per-device view that can disagree with the ledger. + */ +function derivedHostAiRoles(record: HandshakeRecord) { + return deriveInternalHostAiPeerRoles(record, getInstanceId().trim()) +} + +/** This device is Host and peer is Sandbox per {@link deriveInternalHostAiPeerRoles}. */ function isLocalHostPeerSandbox(record: HandshakeRecord): boolean { - if (record.local_role === 'initiator') { - return record.initiator_device_role === 'host' && record.acceptor_device_role === 'sandbox' - } - return record.acceptor_device_role === 'host' && record.initiator_device_role === 'sandbox' + const dr = derivedHostAiRoles(record) + return dr.ok && dr.localRole === 'host' && dr.peerRole === 'sandbox' } /** @@ -133,23 +145,27 @@ function isLocalHostPeerSandbox(record: HandshakeRecord): boolean { * Used to suppress Host-only Sandbox UI even when `orchestratorMode` is mis-set to "host". */ function isLocalSandboxPeerHost(record: HandshakeRecord): boolean { - if (record.local_role === 'initiator') { - return record.initiator_device_role === 'sandbox' && record.acceptor_device_role === 'host' - } - return record.acceptor_device_role === 'sandbox' && record.initiator_device_role === 'host' + const dr = derivedHostAiRoles(record) + return dr.ok && dr.localRole === 'sandbox' && dr.peerRole === 'host' } function peerCoordinationOrLegacyId(record: HandshakeRecord): string { - if (record.local_role === 'initiator') { - return normId(record.acceptor_coordination_device_id) || record.internal_peer_device_id?.trim() || 'unknown / pending repair' + const dr = derivedHostAiRoles(record) + if (dr.ok && dr.peerCoordinationDeviceId) { + return dr.peerCoordinationDeviceId } - return normId(record.initiator_coordination_device_id) || record.internal_peer_device_id?.trim() || 'unknown / pending repair' + return record.internal_peer_device_id?.trim() || 'unknown / pending repair' } function peerDeviceName(record: HandshakeRecord): string | null { - const n = - record.local_role === 'initiator' ? record.acceptor_device_name : record.initiator_device_name - return n?.trim() || null + const dr = derivedHostAiRoles(record) + if (dr.ok) { + const peerIsAcceptor = + normId(record.acceptor_coordination_device_id) === dr.peerCoordinationDeviceId + const n = peerIsAcceptor ? record.acceptor_device_name : record.initiator_device_name + return n?.trim() || null + } + return null } function deriveDeliveryStatus(db: any, handshakeId: string): InternalSandboxListEntry['last_known_delivery_status'] { @@ -199,17 +215,20 @@ export function isBeapCloneEligibleForRecord( /** * Exported for `beapInbox` sandbox clone: ACTIVE internal host↔sandbox, same account, identity complete. + * Uses {@link assertRecordForServiceRpc} + canonical role derive (not `local_role`). */ export function isEligibleActiveInternalHostSandboxRecord( record: HandshakeRecord, session: SSOSession, ): boolean { + // Phase-2 ledger fields (ACTIVE + same_principal) plus #7 canonical service-RPC/role gates. if (record.state !== HandshakeState.ACTIVE) return false - if (record.handshake_type !== 'internal') return false + if (record.same_principal !== true) return false if (!sessionIsPartyOnVisibleHandshakeRow(record, session)) return false - if (!isLocalHostPeerSandbox(record)) return false - if (!record.internal_coordination_identity_complete) return false - return true + const ar = assertRecordForServiceRpc(record) + if (!ar.ok) return false + const dr = deriveInternalHostAiPeerRoles(ar.record, getInstanceId().trim()) + return dr.ok && dr.localRole === 'host' && dr.peerRole === 'sandbox' } /** @@ -260,7 +279,7 @@ export function listAvailableInternalSandboxes( const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) const sandboxes: InternalSandboxListEntry[] = [] const incomplete: InternalSandboxListIncompleteEntry[] = [] diff --git a/code/apps/electron-vite-project/electron/main/handshake/ipc.ts b/code/apps/electron-vite-project/electron/main/handshake/ipc.ts index ca25f8fa8..a1d9849b9 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/ipc.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/ipc.ts @@ -8,7 +8,7 @@ import type { HandshakeState, SSOSession, HandshakeRecord, BeapKeyAgreementMaterial } from './types' import { x25519 } from '@noble/curves/ed25519' import type { ContextBlockProof } from './canonicalRebuild' -import { ReasonCode, HandshakeState as HS } from './types' +import { ReasonCode, HandshakeState as HS, buildDefaultReceiverPolicy } from './types' // Context resolution imports removed — content enters only via the BEAP-Capsule pipeline import { getHandshakeRecord, @@ -27,8 +27,8 @@ import { sealedQuery, prepareSealedOperationalUpdate } from '../sealed-storage' import { resealWithAiAnalysis } from '../email/sealedContentUpdate' import { isInternalCoordinationIdentityComplete } from './internalPersistence' import { queryContextBlocks, queryContextBlocksWithGovernance } from './contextBlocks' -import { authorizeAction, diagnoseHandshakeInactive, isHandshakeActive } from './enforcement' -import { revokeHandshake } from './revocation' +import { authorizeAction, diagnoseHandshakeInactive, isHandshakeActive, resolveEffectivePolicy } from './enforcement' +import { revokeHandshake, deleteRevokedRelationshipContent } from './revocation' import { buildInitiateCapsule, buildInitiateCapsuleWithContent, @@ -36,10 +36,47 @@ import { buildRefreshCapsule, buildContextSyncCapsuleWithContent, } from './capsuleBuilder' -import { submitCapsuleViaRpc } from './capsuleTransport' -import { persistInitiatorHandshakeRecord } from './initiatorPersist' +import { submitCapsuleViaRpc, deserializeCapsuleToRawInput } from './capsuleTransport' +import { + formInitiatorRelationship, + stageInboundInitiate, + listConnectOffers, + declineConnectOffer, + pendingOfferForHandshake, + prepareFormationConsent, + type FormationConsentRef, +} from './formationPipeline' +// The staged-offer table has exactly one owning module (pinned by the Phase-4 +// acceptance test: no second module may read or build an alternate listing), +// so the O6 status gate lives there and is imported here. +import { revalidateOfferStatusForConsent } from './connectOfferStaging' +// Seal-key-source policy: which providers a row MAY be verified with, derived +// from the row's content class. The row's own `seal_key_source` tag records +// only how it WAS written, and is stale on legacy rows. +import { verificationKeySourcesForInboxRow } from '../email/inboxRowSealPolicy' + +/** + * Per-row key-source policy for the extension's sealed inbox reads. + * + * Before this, these reads routed from `seal_key_source` alone, so a legacy + * inner-sealed NON-confidential row was filtered whenever the inner vault was + * locked — invisible in the extension while the Electron inbox showed it, and + * recorded as a tamper event even though nothing about the row was tampered. + * + * The policy is applied per row, not per query: a batch mixes confidential rows + * (inner only) with non-confidential ones (outer, then inner), and one union + * list for the whole batch would let a confidential row verify against the + * outer key. + */ +function inboxRowKeySources(row: { source_type?: unknown; handshake_id?: unknown }) { + return verificationKeySourcesForInboxRow({ + source_type: typeof row.source_type === 'string' ? row.source_type : null, + handshake_id: typeof row.handshake_id === 'string' ? row.handshake_id : null, + }) +} +import { handleIngestionRPC } from '../ingestion/ipc' +import { resolveProfile } from '@repo/ingestion-core' import { attachHandshakeProfilesAndSyncScope } from './handshakeConfidentiality' -import { persistRecipientHandshakeRecord } from './recipientPersist' import { sendCapsuleViaEmail } from './emailTransport' import { computeBlockHash, type ContextBlockForCommitment } from './contextCommitment' import { @@ -99,7 +136,10 @@ import { getPairingCode as getOrchestratorPairingCode, } from '../orchestrator/orchestratorModeStore' import { safeFingerprint } from '../security/cryptoFingerprint' -import { filterHandshakeRecordsForCurrentSession } from './handshakeAccountIsolation' +import { + filterHandshakeRecordsForCurrentSession, + handshakeRowVisibilityForSession, +} from './handshakeAccountIsolation' /** Set `WR_P2P_SEND_KEY_DIAG=1` to log legacy key-substring diagnostics (default: fingerprint-only on errors). */ const P2P_SEND_KEY_DIAG = process.env.WR_P2P_SEND_KEY_DIAG === '1' @@ -218,7 +258,7 @@ type EnsureKeyAgreementKeysOptions = { */ strictDeviceBoundX25519?: boolean /** - * Normal cross-principal `handshake.accept` only (`record.handshake_type !== 'internal'`). + * Normal cross-principal `handshake.accept` only (`record.same_principal !== true`). * When true, the ephemeral X25519 mint branch is unreachable: missing key throws * `ERR_HANDSHAKE_ACCEPT_X25519_GUARD` after a loud log (regression / preflight bypass). */ @@ -227,7 +267,7 @@ type EnsureKeyAgreementKeysOptions = { normalAcceptX25519BindingDiag?: { handshake_id: string local_role: string | null | undefined - handshake_type: string | null | undefined + same_principal: boolean rawParams: unknown ingress: string } @@ -257,7 +297,7 @@ function acceptorX25519FromHandshakeAcceptParams(params: unknown): string { export function logNormalAcceptX25519BindingFailure(diag: { handshake_id: string local_role?: string | null - handshake_type?: string | null + same_principal?: boolean params: unknown ingress: string }): void { @@ -274,7 +314,7 @@ export function logNormalAcceptX25519BindingFailure(diag: { JSON.stringify({ handshake_id: diag.handshake_id, local_role: diag.local_role ?? null, - handshake_type: diag.handshake_type ?? null, + same_principal: diag.same_principal === true, has_senderX25519PublicKeyB64, has_nested_key_agreement_x25519, ingress: diag.ingress, @@ -348,7 +388,7 @@ async function ensureKeyAgreementKeys( logNormalAcceptX25519BindingFailure({ handshake_id: d?.handshake_id ?? '(unknown)', local_role: d?.local_role, - handshake_type: d?.handshake_type, + same_principal: d?.same_principal, params: d?.rawParams ?? {}, ingress: d?.ingress ?? 'ensureKeyAgreementKeys.forbid_ephemeral_x25519', }) @@ -697,6 +737,153 @@ function getCounterpartyEmail(record: HandshakeRecord, session: SSOSession): str return record.initiator.email } +/** + * Display-only projection of a staged Connect offer for the pending list. + * NOT a relationship row — nothing is persisted; `connect_offer_id` marks it + * so accept/decline route through the consent gate. + */ +function connectOfferToDisplayRecord(offer: { + offer_id: string + handshake_id: string + capsule_json: string + sender_email: string | null + sender_iss: string | null + sender_sub: string | null + sender_wrdesk_user_id: string | null + receiver_email: string | null + profile_id: string + staged_at: string + expires_at: string +}): HandshakeRecord { + let capsule: Record = {} + try { capsule = JSON.parse(offer.capsule_json) } catch { /* display only */ } + const receiverPolicy = buildDefaultReceiverPolicy() + const effectivePolicyResult = resolveEffectivePolicy(null, receiverPolicy) + return { + handshake_id: offer.handshake_id, + relationship_id: (capsule.relationship_id as string) ?? '', + state: HS.PENDING_REVIEW, + initiator: { + email: offer.sender_email ?? '', + wrdesk_user_id: offer.sender_wrdesk_user_id ?? '', + iss: offer.sender_iss ?? '', + sub: offer.sender_sub ?? '', + }, + acceptor: null, + local_role: 'acceptor', + sharing_mode: null, + reciprocal_allowed: capsule.reciprocal_allowed === true, + tier_snapshot: null as any, + current_tier_signals: (capsule.tierSignals as any) ?? { plan: 'free', hardwareAttestation: null, dnsVerification: null, wrStampStatus: null }, + last_seq_sent: 0, + last_seq_received: 0, + last_capsule_hash_sent: '', + last_capsule_hash_received: (capsule.capsule_hash as string) ?? '', + effective_policy: 'unsatisfiable' in effectivePolicyResult ? (null as any) : effectivePolicyResult, + external_processing: (capsule.external_processing as any) ?? 'none', + created_at: offer.staged_at, + activated_at: null, + expires_at: offer.expires_at, + revoked_at: null, + revocation_source: null, + initiator_wrdesk_policy_hash: (capsule.wrdesk_policy_hash as string) ?? '', + initiator_wrdesk_policy_version: (capsule.wrdesk_policy_version as string) ?? '', + acceptor_wrdesk_policy_hash: null, + acceptor_wrdesk_policy_version: null, + initiator_context_commitment: (capsule.context_commitment as string) ?? null, + acceptor_context_commitment: null, + p2p_endpoint: null, + local_p2p_auth_token: '', + counterparty_p2p_token: null, + receiver_email: offer.receiver_email ?? null, + ...(offer.profile_id === 'internal_device' + ? { + same_principal: true, + initiator_device_name: (capsule.sender_computer_name as string) ?? null, + initiator_device_role: (capsule.sender_device_role as any) ?? null, + internal_peer_pairing_code: + typeof capsule.receiver_pairing_code === 'string' && /^\d{6}$/.test(capsule.receiver_pairing_code.trim()) + ? capsule.receiver_pairing_code.trim() + : null, + } + : {}), + ...( { connect_offer_id: offer.offer_id } as unknown as Partial ), + } as HandshakeRecord +} + +/** + * Phase 4 (Q1) [IX.3.1 rules 3–4]: consent to a staged Connect offer. Writes + * the Hash-Pinned consent record, then re-runs the staged capsule through the + * ONE pipeline behind the consent gate — the only way a relationship row is + * created from an inbound invitation. + */ +async function consentToStagedOffer( + db: any, + offer: { offer_id: string; handshake_id: string; capsule_json: string }, + session: SSOSession, + expectedPreviewHash?: string, +): Promise<{ ok: true; record: HandshakeRecord } | { ok: false; reason: string; error?: string }> { + // O6 — consent-time re-validation (Phase 4 / 4B). The offer's status was + // checked when it was staged, but consent happens later and a publisher can + // withdraw, be revoked, or be suspended inside that window. Re-check the + // three A6 layers against what is on the row NOW; a mid-window transition + // fails consent rather than binding the operator to a promise that has since + // been retracted. The 7-day offer timeout is UI staleness only and is not + // this gate. + const statusGate = revalidateOfferStatusForConsent(db, offer.offer_id) + if (!statusGate.ok) { + console.warn('[CONNECT_OFFER] consent refused by status re-validation:', { + offer_id: offer.offer_id, + reason: statusGate.reason, + }) + return { ok: false, reason: statusGate.reason, error: statusGate.error } + } + + const prep = prepareFormationConsent({ + offerId: offer.offer_id, + actorWrdeskUserId: session.wrdesk_user_id, + expectedPreviewHash, + }) + if (!prep.ok) return { ok: false, reason: prep.reason } + + const result = await handleIngestionRPC( + 'ingestion.ingest', + { + rawInput: deserializeCapsuleToRawInput(offer.capsule_json), + sourceType: 'internal', + transportMeta: { channel_id: 'connect-offer-consent', mime_type: 'application/vnd.beap+json' }, + formationConsent: prep.consentRef satisfies FormationConsentRef, + }, + db, + session, + ) + if (!result?.success) { + return { + ok: false, + reason: result?.handshake_result?.reason ?? result?.reason ?? 'INGEST_FAILED', + error: result?.error, + } + } + const record = getHandshakeRecord(db, offer.handshake_id) + if (!record) return { ok: false, reason: 'RECORD_NOT_CREATED' } + return { ok: true, record } +} + +/** + * Row-level session authorization for single-handshake IPC reads/deletes. + * Fail closed (treat as not found) when there is no SSO session or the session + * is not a visible party on the row — same rules as handshake.list filtering. + */ +function assertRecordVisibleToCurrentSession( + record: HandshakeRecord | null | undefined, +): { ok: true; record: HandshakeRecord } | { ok: false } { + if (!record) return { ok: false } + const session = getCurrentSession() + if (!session) return { ok: false } + if (!handshakeRowVisibilityForSession(record, session).ok) return { ok: false } + return { ok: true, record } +} + export async function handleHandshakeRPC( method: string, params: any, @@ -704,20 +891,22 @@ export async function handleHandshakeRPC( ): Promise { switch (method) { case 'handshake.queryStatus': { - const record = getHandshakeRecord(db, params.handshakeId) + const raw = getHandshakeRecord(db, params.handshakeId) + const gated = assertRecordVisibleToCurrentSession(raw) return { type: 'handshake-status', - record: record ?? null, - reason: record ? ReasonCode.OK : ReasonCode.HANDSHAKE_NOT_FOUND, + record: gated.ok ? gated.record : null, + reason: gated.ok ? ReasonCode.OK : ReasonCode.HANDSHAKE_NOT_FOUND, } } case 'handshake.get': { const { handshake_id } = params as { handshake_id: string } if (!handshake_id) return { error: 'handshake_id is required' } - const record = getHandshakeRecord(db, handshake_id) - if (!record) return { error: 'Handshake not found', reason: ReasonCode.HANDSHAKE_NOT_FOUND } - return { record } + const raw = getHandshakeRecord(db, handshake_id) + const gated = assertRecordVisibleToCurrentSession(raw) + if (!gated.ok) return { error: 'Handshake not found', reason: ReasonCode.HANDSHAKE_NOT_FOUND } + return { record: gated.record } } case 'handshake.getPendingP2PBeapMessages': { @@ -782,13 +971,50 @@ export async function handleHandshakeRPC( try { session = requireSession() } catch (err: any) { return { type: 'revocation-result', success: false, reason: ReasonCode.UNAUTHENTICATED } } - await revokeHandshake(db, handshakeId, 'local-user', session.wrdesk_user_id, session, _getOidcToken) + // Phase 4 (Q1): declining a staged Connect offer (no record yet) + // consumes the offer — nothing to revoke, nothing was formed. + if (!getHandshakeRecord(db, handshakeId)) { + const offer = pendingOfferForHandshake(handshakeId) + if (offer) { + declineConnectOffer(offer.offer_id) + return { type: 'revocation-result', success: true, reason: ReasonCode.OK } + } + } + // Phase 4 (V5): silent revocation — no peer-notify capsule; the + // receiver-side ingress filter is the sole enforcement [VII.10.7.2]. + await revokeHandshake(db, handshakeId, 'local-user', session.wrdesk_user_id) return { type: 'revocation-result', success: true, reason: ReasonCode.OK } } catch { return { type: 'revocation-result', success: false, reason: ReasonCode.INTERNAL_ERROR } } } + case 'handshake.deleteRevokedContent': { + // Phase 4 (Q8): content deletion is a SEPARATE explicit operator action — + // never part of revocation itself. Only valid on an already-REVOKED + // relationship; audit rows are never deleted (evidence persists). + const { handshakeId } = params + let session: SSOSession + try { session = requireSession() } catch { + return { type: 'revoked-content-delete-result', success: false, reason: 'UNAUTHENTICATED' } + } + try { + const r = deleteRevokedRelationshipContent(db, handshakeId, session.wrdesk_user_id) + if (!r.ok) { + return { type: 'revoked-content-delete-result', success: false, reason: r.reason } + } + return { + type: 'revoked-content-delete-result', + success: true, + reason: ReasonCode.OK, + blocks_deleted: r.blocks_deleted, + embeddings_deleted: r.embeddings_deleted, + } + } catch { + return { type: 'revoked-content-delete-result', success: false, reason: ReasonCode.INTERNAL_ERROR } + } + } + case 'handshake.importCapsule': { const { capsuleJson } = params as { capsuleJson: string } if (!capsuleJson || typeof capsuleJson !== 'string') { @@ -842,37 +1068,82 @@ export async function handleHandshakeRPC( if (!rebuildResult.ok) { return { success: false, error: rebuildResult.reason ?? 'Canonical rebuild failed', reason: 'CANONICAL_REBUILD_FAILED' } } - const canonicalValidated = { ...distribution.validated_capsule, capsule: rebuildResult.capsule } - const persistResult = persistRecipientHandshakeRecord(db, canonicalValidated, session) - if (!persistResult.success) { - return { success: false, error: persistResult.error, reason: persistResult.reason ?? 'PERSIST_FAILED' } - } - const senderIdentity = cap?.senderIdentity as { email?: string } | undefined - const capsuleSenderEmail = (senderIdentity?.email ?? cap?.sender_email) as string | undefined - if ( - persistResult.handshake_id && - capsuleSenderEmail && - isSameAccountHandshakeEmails(capsuleSenderEmail, capsuleReceiverEmail) - ) { - try { - db.prepare(`UPDATE handshakes SET handshake_type = ? WHERE handshake_id = ?`).run('internal', persistResult.handshake_id) - refreshInternalHandshakePersistenceFlags(db, persistResult.handshake_id) - } catch (e) { - console.warn('[IMPORT] Could not mark handshake as internal:', e) - } + // Phase 4 (Q1) [IX.3.1]: a .beap import is an inbound INVITATION. It + // lands in the Connect-offer staging store — never the relationship + // store. The record is created only when the user consents (accept). + const rebuilt = rebuildResult.capsule as Record + const senderIdentity = (rebuilt?.senderIdentity ?? cap?.senderIdentity) as + | { email?: string; iss?: string; sub?: string; wrdesk_user_id?: string } + | undefined + const capsuleSenderEmail = (senderIdentity?.email ?? rebuilt?.sender_email ?? cap?.sender_email) as string | undefined + const sameAccount = + !!capsuleSenderEmail && isSameAccountHandshakeEmails(capsuleSenderEmail, capsuleReceiverEmail) + const staging = stageInboundInitiate({ + handshake_id: handshakeId, + capsule: rebuilt, + capsule_hash: (rebuilt?.capsule_hash as string) ?? '', + sender_email: capsuleSenderEmail ?? null, + sender_iss: senderIdentity?.iss ?? null, + sender_sub: senderIdentity?.sub ?? null, + sender_wrdesk_user_id: (senderIdentity?.wrdesk_user_id ?? rebuilt?.sender_wrdesk_user_id ?? null) as string | null, + receiver_email: capsuleReceiverEmail ?? null, + source_type: 'file_upload', + ...(sameAccount ? { profile_id_override: 'internal_device' } : {}), + }) + if (!staging.staged && staging.reason !== 'duplicate') { + return { success: false, error: `Connect offer staging failed: ${staging.reason}`, reason: 'STAGING_FAILED' } } return { success: true, - handshake_id: persistResult.handshake_id, + staged: true, + offer_id: staging.offerId, + handshake_id: handshakeId, + // Display-compat: staged offers surface in the pending list; consent + // (accept) creates the actual record. state: HS.PENDING_REVIEW, - sender: (cap?.senderIdentity ?? cap?.sender_email) as { email?: string } | string, + sender: (senderIdentity ?? capsuleSenderEmail) as { email?: string } | string, } } + case 'handshake.listConnectOffers': { + // Suppressed offers are structurally unreachable from this surface + // [IX.3.1 rule 2] — listConnectOffers only reads unsuppressed verified rows. + try { + return { type: 'connect-offer-list', offers: listConnectOffers() } + } catch (e: any) { + return { type: 'connect-offer-list', offers: [], error: e?.message } + } + } + + case 'handshake.consentToOffer': { + const { offer_id, expected_preview_hash } = params as { offer_id: string; expected_preview_hash?: string } + if (!offer_id) return { success: false, error: 'offer_id is required' } + let session: SSOSession + try { session = requireSession() } catch (err: any) { + return { success: false, error: err.message, reason: 'NO_SESSION' } + } + if (!db) return { success: false, error: 'Database unavailable', reason: 'DB_UNAVAILABLE' } + const offers = listConnectOffers() + const offer = offers.find((o) => o.offer_id === offer_id) + if (!offer) return { success: false, error: 'Offer not consentable', reason: 'OFFER_NOT_CONSENTABLE' } + const consent = await consentToStagedOffer(db, offer, session, expected_preview_hash) + if (!consent.ok) { + return { success: false, error: consent.error ?? consent.reason, reason: consent.reason } + } + return { success: true, handshake_id: consent.record.handshake_id, state: consent.record.state } + } + + case 'handshake.declineOffer': { + const { offer_id } = params as { offer_id: string } + if (!offer_id) return { success: false, error: 'offer_id is required' } + const declined = declineConnectOffer(offer_id) + return { success: declined.ok } + } + case 'handshake.list': { - const filter = params?.filter as { state?: HandshakeState; relationship_id?: string; handshake_type?: string } | undefined + const filter = params?.filter as { state?: HandshakeState; relationship_id?: string; same_principal?: boolean } | undefined let records = listHandshakeRecords(db, filter) // LAYER 2 — Pending acceptor: receiver must match current email (in addition to account isolation) @@ -895,12 +1166,38 @@ export async function handleHandshakeRPC( // LAYER 3 — Account isolation: only initiator/acceptor for this SSO session; internal same-principal records = filterHandshakeRecordsForCurrentSession(records, session) - return { type: 'handshake-list', records } + // Phase 4 (Q1): staged Connect offers surface in the pending list as + // display-only entries (state PENDING_REVIEW, marked connect_offer_id). + // Consent (accept) creates the actual record; suppressed offers are + // structurally absent from listConnectOffers. + let offerRecords: HandshakeRecord[] = [] + if (!params?.filter?.state || params.filter.state === HS.PENDING_REVIEW) { + try { + const knownIds = new Set(records.map((r) => r.handshake_id)) + offerRecords = listConnectOffers() + .filter((o) => !knownIds.has(o.handshake_id)) + .filter((o) => { + if (!session?.email) return true + const check = validateReceiverEmail(o.receiver_email, session.email) + return check.valid + }) + .map((o) => connectOfferToDisplayRecord(o)) + } catch (e: any) { + console.warn('[HANDSHAKE] Connect offer list merge failed:', e?.message) + } + } + + return { type: 'handshake-list', records: [...records, ...offerRecords] } } case 'handshake.delete': { const { handshakeId } = params as { handshakeId: string } if (!handshakeId) return { success: false, error: 'handshakeId is required' } + const raw = getHandshakeRecord(db, handshakeId) + const gated = assertRecordVisibleToCurrentSession(raw) + if (!gated.ok) { + return { success: false, error: 'Handshake not found', reason: ReasonCode.HANDSHAKE_NOT_FOUND } + } const result = deleteHandshakeRecord(db, handshakeId) return result.success ? { success: true } : { success: false, error: result.error } } @@ -1285,7 +1582,7 @@ export async function handleHandshakeRPC( profile_items: initProfileItems, p2p_endpoint: p2pEndpointParam, policy_selections: initPolicySelections, - handshake_type: initHandshakeType, + profile_id: initProfileIdParam, device_name: initDeviceName, device_role: initDeviceRole, counterparty_device_id: initCounterpartyDeviceIdRaw, @@ -1303,7 +1600,12 @@ export async function handleHandshakeRPC( profile_items?: Array<{ profile_id: string; policy_mode?: 'inherit' | 'override'; policy?: { ai_processing_mode?: 'none' | 'local_only' | 'internal_and_cloud' } | { cloud_ai?: boolean; internal_ai?: boolean } }> p2p_endpoint?: string | null policy_selections?: { ai_processing_mode?: 'none' | 'local_only' | 'internal_and_cloud' } | { cloud_ai?: boolean; internal_ai?: boolean } - handshake_type?: 'internal' | 'standard' + /** + * Phase 4 (Q9): formation profile — 'internal_device' for same-principal + * Cross-Device pairing, omitted/'private_personal' otherwise. Replaces the + * eliminated `handshake_type` request discriminator. + */ + profile_id?: string device_name?: string device_role?: 'host' | 'sandbox' counterparty_device_id?: string @@ -1311,7 +1613,7 @@ export async function handleHandshakeRPC( counterparty_computer_name?: string /** * 6-digit internal pairing code for the target device. When provided (and - * `handshake_type === 'internal'`), the IPC handler resolves it to + * the profile is same-principal), the IPC handler resolves it to * `counterparty_device_id` + `counterparty_computer_name` via the coordination * service so the renderer never needs to know the peer's full instance_id. * Ignored when `counterparty_device_id` is already provided (legacy callers). @@ -1319,6 +1621,13 @@ export async function handleHandshakeRPC( counterparty_pairing_code?: string } + // Q9: the admission situation is a profile-registry parameter. + const initProfileRes = resolveProfile(initProfileIdParam ?? 'private_personal', 1) + if (!initProfileRes.ok) { + return { success: false, error: `Unknown formation profile: ${initProfileIdParam}` } + } + const initSamePrincipal = initProfileRes.record.same_principal + // Pairing-code routing: receiver_pairing_code is the sole peer identifier for new // internal initiate capsules. counterparty_device_id / counterparty_computer_name are // accepted for backwards compatibility but no longer required and not used to route @@ -1331,7 +1640,7 @@ export async function handleHandshakeRPC( return { success: false, error: 'receiverUserId and receiverEmail are required' } } - if (initHandshakeType === 'internal') { + if (initSamePrincipal) { const localRelayId = getLocalDeviceIdForRelay() const localPairingCode = getLocalPairingCode() const vContract = validateInternalInitiateContract({ @@ -1352,7 +1661,7 @@ export async function handleHandshakeRPC( } const handshakeId = `hs-${randomUUID()}` - const strictInternalX25519 = initHandshakeType === 'internal' + const strictInternalX25519 = initSamePrincipal const { blocks: contextBlocks, blockPolicyMap: initBlockPolicyMap } = buildContextBlocksFromParamsWithPolicy(rawBlocks, rawMessage) const profileIds = initProfileIds ?? (initProfileItems?.map((i) => i.profile_id) ?? []) const profileBlocks = profileIds.length > 0 @@ -1379,7 +1688,7 @@ export async function handleHandshakeRPC( // here. Legacy callers that still pass counterparty_device_id keep that value. // Fail-open: null → out-of-band (email/file) delivery exactly as before. let resolvedReceiverDeviceId: string | undefined - if (initHandshakeType === 'internal' && initReceiverPairingCode && p2pConfig.use_coordination) { + if (initSamePrincipal && initReceiverPairingCode && p2pConfig.use_coordination) { if (initCounterpartyDeviceId?.trim()) { resolvedReceiverDeviceId = initCounterpartyDeviceId.trim() } else { @@ -1430,7 +1739,7 @@ export async function handleHandshakeRPC( ...(p2pAuthToken ? { p2p_auth_token: p2pAuthToken } : {}), sender_x25519_public_key_b64: keyAgreement.sender_x25519_public_key_b64, sender_mlkem768_public_key_b64: keyAgreement.sender_mlkem768_public_key_b64, - ...(initHandshakeType === 'internal' && + ...(initSamePrincipal && initDeviceRole && initDeviceName?.trim() && initReceiverPairingCode @@ -1472,14 +1781,18 @@ export async function handleHandshakeRPC( | null = null let relayError: string | null = null if (db) { - // Initiator persists own record via direct insert — NOT the receive pipeline. - // The pipeline rejects when senderId === localUserId (ownership check). - localResult = persistInitiatorHandshakeRecord( + // Phase 4 (V1): initiator-side formation through the ONE pipeline. + // The explicit creation act is the consent event; the record carries + // FormationMeta (profile / ingress_path / capture provenance). + localResult = formInitiatorRelationship( db, capsule, session, localBlocks, keypair, + initSamePrincipal + ? { capture_method: 'manual_entry', ingress_path: 'optirando_code_entry', source_reference: 'pairing_code' } + : { capture_method: 'assisted_email', ingress_path: 'beap_invitation', source_reference: receiverEmail ?? null }, initPolicySelections, canonicalBlockPolicyMap, keyAgreement, @@ -1501,13 +1814,13 @@ export async function handleHandshakeRPC( ? await registerHandshakeWithRelay(db, capsule.handshake_id, p2pAuthToken ?? '', receiverEmail, _getOidcToken, { initiator_user_id: session.sub, acceptor_user_id: coordinationAcceptorUserIdForRegistration(session, receiverEmail, receiverUserId, { - explicitInternal: initHandshakeType === 'internal', + explicitInternal: initSamePrincipal, }), initiator_email: session.email, acceptor_email: receiverEmail, - handshake_type: initHandshakeType, + same_principal: initSamePrincipal, ...(localRelayDeviceId ? { initiator_device_id: localRelayDeviceId } : {}), - ...(initHandshakeType === 'internal' && resolvedReceiverDeviceId + ...(initSamePrincipal && resolvedReceiverDeviceId ? { acceptor_device_id: resolvedReceiverDeviceId } : {}), }) @@ -1522,7 +1835,7 @@ export async function handleHandshakeRPC( // Internal initiates traverse the coordination relay with same-principal // routing; external initiates are delivered out-of-band via email/file/USB - // and never reach this branch (`initHandshakeType === 'internal'` gate + // and never reach this branch (`initSamePrincipal` gate // below). The relay's per-capsule_type whitelist // (packages/coordination-service/src/server.ts:RELAY_ALLOWED_TYPES) includes // `'initiate'` and an initiate-specific guard immediately after enforces @@ -1538,7 +1851,7 @@ export async function handleHandshakeRPC( // `resolvePairingCodeViaCoordination` so the server guards never fire on // a healthy client. const shouldRelayInitiate = - initHandshakeType === 'internal' && + initSamePrincipal && p2pConfig.use_coordination === true && !!p2pConfig.coordination_url?.trim() && regResult.success @@ -1577,7 +1890,7 @@ export async function handleHandshakeRPC( console.warn('[HANDSHAKE] Internal initiate relay push threw:', relayError) } } - } else if (initHandshakeType === 'internal') { + } else if (initSamePrincipal) { // Internal handshake but coordination isn't configured — skip relay push silently. relayDelivery = 'skipped' } @@ -1614,7 +1927,7 @@ export async function handleHandshakeRPC( profile_items: dlProfileItems, p2p_endpoint: dlP2PEndpointParam, policy_selections: dlPolicySelections, - handshake_type: dlHandshakeType, + profile_id: dlProfileIdParam, device_name: dlDeviceName, device_role: dlDeviceRole, counterparty_device_id: dlCounterpartyDeviceIdRaw, @@ -1631,7 +1944,8 @@ export async function handleHandshakeRPC( profile_items?: Array<{ profile_id: string; policy_mode?: 'inherit' | 'override'; policy?: { ai_processing_mode?: 'none' | 'local_only' | 'internal_and_cloud' } | { cloud_ai?: boolean; internal_ai?: boolean } }> policy_selections?: { ai_processing_mode?: 'none' | 'local_only' | 'internal_and_cloud' } | { cloud_ai?: boolean; internal_ai?: boolean } p2p_endpoint?: string | null - handshake_type?: 'internal' | 'standard' + /** Phase 4 (Q9): formation profile — see `handshake.initiate`. */ + profile_id?: string device_name?: string device_role?: 'host' | 'sandbox' counterparty_device_id?: string @@ -1641,6 +1955,12 @@ export async function handleHandshakeRPC( counterparty_pairing_code?: string } + const dlProfileRes = resolveProfile(dlProfileIdParam ?? 'private_personal', 1) + if (!dlProfileRes.ok) { + return { success: false, error: `Unknown formation profile: ${dlProfileIdParam}` } + } + const dlSamePrincipal = dlProfileRes.record.same_principal + const dlCounterpartyDeviceId = dlCounterpartyDeviceIdRaw const dlCounterpartyComputerName = dlCounterpartyComputerNameRaw const dlReceiverPairingCode = normalizePairingCode(dlCounterpartyPairingCode ?? null) @@ -1649,7 +1969,7 @@ export async function handleHandshakeRPC( return { success: false, error: 'receiverUserId and receiverEmail are required' } } - if (dlHandshakeType === 'internal') { + if (dlSamePrincipal) { const localRelayIdDl = getLocalDeviceIdForRelay() const localPairingCodeDl = getLocalPairingCode() const vContractDl = validateInternalInitiateContract({ @@ -1670,7 +1990,7 @@ export async function handleHandshakeRPC( } const dlHandshakeId = `hs-${randomUUID()}` - const dlStrictInternalX25519 = dlHandshakeType === 'internal' + const dlStrictInternalX25519 = dlSamePrincipal const { blocks: dlContextBlocks, blockPolicyMap: dlBlockPolicyMap } = buildContextBlocksFromParamsWithPolicy(dlRawBlocks, dlRawMessage) const dlProfileIdsList = dlProfileIds ?? (dlProfileItems?.map((i) => i.profile_id) ?? []) const dlProfileBlocks = dlProfileIdsList.length > 0 @@ -1728,7 +2048,7 @@ export async function handleHandshakeRPC( ...(dlP2PAuthToken ? { p2p_auth_token: dlP2PAuthToken } : {}), sender_x25519_public_key_b64: dlKeyAgreement.sender_x25519_public_key_b64, sender_mlkem768_public_key_b64: dlKeyAgreement.sender_mlkem768_public_key_b64, - ...(dlHandshakeType === 'internal' && + ...(dlSamePrincipal && dlDeviceRole && dlDeviceName?.trim() && dlReceiverPairingCode @@ -1764,12 +2084,15 @@ export async function handleHandshakeRPC( } } - const buildLocalResult = persistInitiatorHandshakeRecord( + const buildLocalResult = formInitiatorRelationship( db, capsule, session, localBlocks, keypair, + dlSamePrincipal + ? { capture_method: 'manual_entry', ingress_path: 'optirando_code_entry', source_reference: 'pairing_code' } + : { capture_method: 'manual_entry', ingress_path: 'optirando.ingress.file_import', source_reference: 'beap_download' }, dlPolicySelections, dlCanonicalBlockPolicyMap, dlKeyAgreement, @@ -1798,13 +2121,13 @@ export async function handleHandshakeRPC( ? registerHandshakeWithRelay(db, capsule.handshake_id, dlP2PAuthToken ?? '', dlReceiverEmail, _getOidcToken, { initiator_user_id: session.sub, acceptor_user_id: coordinationAcceptorUserIdForRegistration(session, dlReceiverEmail, dlReceiverUserId, { - explicitInternal: dlHandshakeType === 'internal', + explicitInternal: dlSamePrincipal, }), initiator_email: session.email, acceptor_email: dlReceiverEmail, - handshake_type: dlHandshakeType, + same_principal: dlSamePrincipal, ...(localRelayDeviceId ? { initiator_device_id: localRelayDeviceId } : {}), - ...(dlHandshakeType === 'internal' && dlCounterpartyDeviceId?.trim() + ...(dlSamePrincipal && dlCounterpartyDeviceId?.trim() ? { acceptor_device_id: dlCounterpartyDeviceId.trim() } : {}), }) @@ -1871,6 +2194,22 @@ export async function handleHandshakeRPC( } let record = getHandshakeRecord(db, handshake_id) + if (!record) { + // Phase 4 (Q1): the id may refer to a staged Connect offer — the + // user's accept IS the consent event; only it creates the record. + const offer = pendingOfferForHandshake(handshake_id) + if (offer) { + const consent = await consentToStagedOffer(db, offer, session) + if (!consent.ok) { + return { + success: false, + error: consent.error ?? `Connect offer consent failed: ${consent.reason}`, + reason: consent.reason, + } + } + record = consent.record + } + } if (!record) { return { success: false, error: 'Handshake not found', reason: ReasonCode.HANDSHAKE_NOT_FOUND } } @@ -1894,14 +2233,14 @@ export async function handleHandshakeRPC( ? 'receive-only' : requested_sharing_mode - // X25519 preflight (normal only): authoritative internal classification is - // `record.handshake_type === 'internal'`. Internal accepts skip this wire check; + // X25519 preflight (normal only): authoritative same-principal + // classification is the profile-derived `record.same_principal` (Q9). + // Same-principal accepts skip this wire check; // `ensureKeyAgreementKeys(..., { strictDeviceBoundX25519: true })` may call // `getDeviceX25519PublicKey` when the acceptor did not pass a wire key. - const isInternalRecord = record.handshake_type === 'internal' + const isInternalRecord = record.same_principal === true const x25519WireFromAcceptor = acceptorX25519FromHandshakeAcceptParams(params) const has_sender_x25519 = x25519WireFromAcceptor.length > 0 - const record_handshake_type = record.handshake_type ?? null const decision = isInternalRecord ? 'internal_skip_x25519_preflight' : has_sender_x25519 @@ -1911,7 +2250,7 @@ export async function handleHandshakeRPC( '[HANDSHAKE][ACCEPT_MODE]', JSON.stringify({ handshake_id, - record_handshake_type, + record_same_principal: isInternalRecord, has_sender_x25519, decision, }), @@ -1922,7 +2261,7 @@ export async function handleHandshakeRPC( logNormalAcceptX25519BindingFailure({ handshake_id, local_role: record.local_role, - handshake_type: record.handshake_type ?? null, + same_principal: record.same_principal === true, params, ingress: 'handleHandshakeRPC.handshake.accept.preflight', }) @@ -1932,12 +2271,12 @@ export async function handleHandshakeRPC( const initiatorUserId = record.initiator.wrdesk_user_id let initiatorEmail = record.initiator.email - // For internal handshakes, initiator email is always the same as session email - if (!initiatorEmail && record.handshake_type === 'internal') { + // For same-principal handshakes, initiator email is always the same as session email + if (!initiatorEmail && record.same_principal === true) { initiatorEmail = session.email } - if (record.handshake_type === 'internal') { + if (record.same_principal === true) { const acceptLocalDev = getLocalDeviceIdForRelay() if (!acceptLocalDev?.trim()) { return { @@ -2210,14 +2549,14 @@ export async function handleHandshakeRPC( sender_mlkem768_public_key_b64: (params as any).senderMlkem768PublicKeyB64 ?? (params as any).key_agreement?.mlkem768_public_key_b64, }, { - strictDeviceBoundX25519: record.handshake_type === 'internal', - forbidEphemeralX25519ForNormalAccept: record.handshake_type !== 'internal', + strictDeviceBoundX25519: record.same_principal === true, + forbidEphemeralX25519ForNormalAccept: record.same_principal !== true, normalAcceptX25519BindingDiag: - record.handshake_type !== 'internal' + record.same_principal !== true ? { handshake_id, local_role: record.local_role, - handshake_type: record.handshake_type ?? null, + same_principal: record.same_principal === true, rawParams: params, ingress: 'handleHandshakeRPC.handshake.accept.ensureKeyAgreementKeys', } @@ -2275,9 +2614,11 @@ export async function handleHandshakeRPC( ...(p2pAuthToken ? { p2p_auth_token: p2pAuthToken } : {}), sender_x25519_public_key_b64: acceptKeyAgreement.sender_x25519_public_key_b64, sender_mlkem768_public_key_b64: acceptKeyAgreement.sender_mlkem768_public_key_b64, + // Phase 2: initiator full-claim identity for the signed core's initiator_id. + initiatorIdentity: record.initiator ?? null, initiatorCoordinationDeviceId: record.initiator_coordination_device_id?.trim() ?? undefined, - isInternalHandshake: record.handshake_type === 'internal', - ...(record.handshake_type === 'internal' + isInternalHandshake: record.same_principal === true, + ...(record.same_principal === true ? { senderDeviceRole: acceptDeviceRole, senderComputerName: acceptDeviceName, @@ -2426,7 +2767,7 @@ export async function handleHandshakeRPC( } try { const accCoordDev = getLocalDeviceIdForRelay() - if (record.handshake_type === 'internal' && accCoordDev?.trim()) { + if (record.same_principal === true && accCoordDev?.trim()) { db.prepare(` UPDATE handshakes SET acceptor_device_name = ?, @@ -2459,8 +2800,8 @@ export async function handleHandshakeRPC( } } - // For internal handshakes, initiator email is always the same as session email - if (!initiatorEmail && record.handshake_type === 'internal') { + // For same-principal handshakes, initiator email is always the same as session email + if (!initiatorEmail && record.same_principal === true) { initiatorEmail = session.email } @@ -2484,7 +2825,7 @@ export async function handleHandshakeRPC( use_coordination: use_coordination_flag, initiatorEmail: initiatorEmail || 'NULL', p2p_endpoint: record?.p2p_endpoint || 'NULL', - handshake_type: record?.handshake_type, + same_principal: record?.same_principal === true, handshake_id, }) @@ -2494,7 +2835,7 @@ export async function handleHandshakeRPC( console.log('[HANDSHAKE-DEBUG] setImmediate(post-accept relay) started for', handshake_id) const p2pConfig = getP2PConfig(db) const regUserIds = coordinationRegistryUserIdsForSession(session, { - handshake_type: record.handshake_type, + same_principal: record.same_principal === true, initiator: record.initiator, acceptor: record.acceptor, }) @@ -2502,7 +2843,7 @@ export async function handleHandshakeRPC( initiator_user_id: regUserIds.initiator_user_id, acceptor_user_id: regUserIds.acceptor_user_id, same_principal: regUserIds.initiator_user_id === regUserIds.acceptor_user_id, - internal_handshake: record.handshake_type === 'internal', + internal_handshake: record.same_principal === true, p2p_endpoint: record.p2p_endpoint, initiatorEmail: initiatorEmail, }) @@ -2514,7 +2855,7 @@ export async function handleHandshakeRPC( acceptor_user_id: regUserIds.acceptor_user_id, initiator_email: initiatorEmail, acceptor_email: session.email, - handshake_type: record.handshake_type ?? undefined, + same_principal: record.same_principal === true, ...(acceptLocalDeviceId ? { acceptor_device_id: acceptLocalDeviceId } : {}), ...(record.initiator_coordination_device_id?.trim() ? { initiator_device_id: record.initiator_coordination_device_id.trim() } @@ -2524,7 +2865,7 @@ export async function handleHandshakeRPC( console.log('[ACCEPT-6] Relay registration result:', JSON.stringify(regResult)) if (!regResult.success) { console.error('[HANDSHAKE] Relay registration failed on accept:', regResult.error, '— handshake_id:', handshake_id) - if (record.handshake_type === 'internal') { + if (record.same_principal === true) { const reason = regResult.error ?? 'RELAY_REGISTRATION_FAILED' try { updateHandshakeContextSyncPending(db, handshake_id, true) @@ -2771,7 +3112,7 @@ export async function handleHandshakeRPC( refreshLocalDev = undefined } const refreshInternalWire = internalRelayCapsuleWireOptsFromRecord(record, refreshLocalDev) - if (record.handshake_type === 'internal' && getP2PConfig(db).use_coordination && !refreshInternalWire) { + if (record.same_principal === true && getP2PConfig(db).use_coordination && !refreshInternalWire) { return { success: false, error: @@ -2788,6 +3129,8 @@ export async function handleHandshakeRPC( context_block_proofs: context_block_proofs ?? [], local_public_key: localPub, local_private_key: localPriv, + localHandshakeRole: record.local_role, + counterpartyIdentity: record.local_role === 'initiator' ? record.acceptor : record.initiator, ...(record.local_p2p_auth_token?.trim() ? { p2p_auth_token: record.local_p2p_auth_token.trim() } : {}), ...(refreshInternalWire ?? {}), }) @@ -3226,6 +3569,16 @@ export async function handleHandshakeRPC( } } + // ── Phase 3 (3B): WRC publisher resolution ────────────────────────────── + // Lives in main because MV3 has no DNS, and the dual-channel validation is + // not something a renderer may be trusted to have performed. The extension + // gets the client's typed result verbatim — including the distinct failure + // reason — so no caller has to re-derive why a code did not resolve. + case 'wrc.resolvePublisher': { + const { handleWrcResolvePublisher } = await import('../wrc/wrcRuntime') + return handleWrcResolvePublisher((params ?? {}) as Record) + } + // ── Phase B, PR B-8: Extension BEAP Inbox — sealed read + operational mutations ── // ── Phase B, PR B-8.1: cursor-based pagination helpers ── @@ -3256,7 +3609,7 @@ export async function handleHandshakeRPC( type InboxRow = { id: string; handshake_id: string | null; subject: string | null; body_text: string | null - depackaged_json: string | null; received_at: number; read_status: number; archived: number + depackaged_json: string | null; depackaged_metadata: string | null; received_at: number; read_status: number; archived: number has_attachments: number; attachment_count: number; ai_analysis_json: string | null urgency_score: number | null; from_address: string | null; from_name: string | null source_type: string | null; seal: string | null; seal_input_json: string | null @@ -3270,7 +3623,7 @@ export async function handleHandshakeRPC( const rows = pos ? sealedQuery( db, - `SELECT id, handshake_id, subject, body_text, depackaged_json, received_at, read_status, archived, + `SELECT id, handshake_id, subject, body_text, depackaged_json, depackaged_metadata, received_at, read_status, archived, has_attachments, attachment_count, ai_analysis_json, urgency_score, from_address, from_name, source_type, seal, seal_input_json, seal_key_source, validated_at, validation_reason @@ -3281,10 +3634,11 @@ export async function handleHandshakeRPC( LIMIT ?`, [pos.received_at, pos.received_at, pos.id, effectiveLimit], 'depackaged_json', + { keySources: inboxRowKeySources }, ) : sealedQuery( db, - `SELECT id, handshake_id, subject, body_text, depackaged_json, received_at, read_status, archived, + `SELECT id, handshake_id, subject, body_text, depackaged_json, depackaged_metadata, received_at, read_status, archived, has_attachments, attachment_count, ai_analysis_json, urgency_score, from_address, from_name, source_type, seal, seal_input_json, seal_key_source, validated_at, validation_reason @@ -3294,6 +3648,7 @@ export async function handleHandshakeRPC( LIMIT ?`, [effectiveLimit], 'depackaged_json', + { keySources: inboxRowKeySources }, ) let attStmt: { all: (id: string) => Array<{ attachment_id: string; filename: string | null; mime_type: string | null; size_bytes: number | null; content_sha256: string | null }> } | null = null @@ -3309,6 +3664,7 @@ export async function handleHandshakeRPC( subject: row.subject, body_text: row.body_text, depackaged_json: row.depackaged_json, + depackaged_metadata: row.depackaged_metadata, received_at: row.received_at, read_status: row.read_status, archived: row.archived, @@ -3348,7 +3704,7 @@ export async function handleHandshakeRPC( type InboxRow = { id: string; handshake_id: string | null; subject: string | null; body_text: string | null - depackaged_json: string | null; received_at: number; read_status: number; archived: number + depackaged_json: string | null; depackaged_metadata: string | null; received_at: number; read_status: number; archived: number has_attachments: number; attachment_count: number; ai_analysis_json: string | null urgency_score: number | null; from_address: string | null; from_name: string | null source_type: string | null; seal: string | null; seal_input_json: string | null @@ -3358,7 +3714,7 @@ export async function handleHandshakeRPC( const placeholders = ids.map(() => '?').join(', ') const rows = sealedQuery( db, - `SELECT id, handshake_id, subject, body_text, depackaged_json, received_at, read_status, archived, + `SELECT id, handshake_id, subject, body_text, depackaged_json, depackaged_metadata, received_at, read_status, archived, has_attachments, attachment_count, ai_analysis_json, urgency_score, from_address, from_name, source_type, seal, seal_input_json, seal_key_source, validated_at, validation_reason @@ -3366,6 +3722,7 @@ export async function handleHandshakeRPC( WHERE deleted = 0 AND id IN (${placeholders})`, ids, 'depackaged_json', + { keySources: inboxRowKeySources }, ) let attStmt: { all: (id: string) => Array<{ attachment_id: string; filename: string | null; mime_type: string | null; size_bytes: number | null; content_sha256: string | null }> } | null = null @@ -3381,6 +3738,7 @@ export async function handleHandshakeRPC( subject: row.subject, body_text: row.body_text, depackaged_json: row.depackaged_json, + depackaged_metadata: row.depackaged_metadata, received_at: row.received_at, read_status: row.read_status, archived: row.archived, @@ -3513,7 +3871,7 @@ export function registerHandshakeRoutes(app: any, getDb: () => any): void { const db = getDb() if (!db) return res.status(503).json({ error: 'vault_locked' }) const session = _getSession() - await revokeHandshake(db, req.params.id, 'local-user', session?.wrdesk_user_id, session ?? undefined, _getOidcToken) + await revokeHandshake(db, req.params.id, 'local-user', session?.wrdesk_user_id) res.json({ success: true }) } catch (err: any) { res.status(500).json({ error: err?.message }) diff --git a/code/apps/electron-vite-project/electron/main/handshake/ledger.ts b/code/apps/electron-vite-project/electron/main/handshake/ledger.ts index 1708d2543..0d6f9db2b 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/ledger.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/ledger.ts @@ -1,9 +1,25 @@ /** - * Handshake Ledger — Tier 1 storage for handshake metadata. + * Handshake Ledger — the append-only evidence / receipt store (Tier-L chain + * home, Phase 5 / Q10) plus, transitionally, the Tier-1 handshake pipeline DB. * - * A separate SQLite database that stores only hashes, identifiers, and - * cryptographic commitments from handshake capsules. It never stores - * plaintext context data, so it does NOT require the vault to be unlocked. + * ROLE (post Phase-5 repurposing): + * - PRIMARY: home of the hash-chained evidence store (`wr_evidence_chain`, + * see `evidenceChain.ts`) — PoAC / PoAE / BER records, per-contract chains + * with explicit genesis records [IX.19.1, X.10.1]. Evidence writers are + * the only writers permitted on NEW ledger tables; the schema is frozen at + * handshake-migration v74 (Phase 3) and swept of key material. + * - TRANSITIONAL: the ledger still carries the frozen ≤v74 handshake tables + * so the handshake pipeline can run while the vault is locked. Contract + + * runtime state (wr_handshake_core / wr_handshake_runtime, v75+) lives in + * the VAULT DB only — those tables are never applied here. Migrating the + * remaining pipeline usage off this handle is tracked follow-up work; no + * new non-evidence writers may be added. + * + * Historical note: the pre-Phase-3 header claimed this DB stored "only + * hashes, identifiers, and cryptographic commitments". That was not true — it + * received the full handshake migration chain, including private-key columns. + * The Phase-3 freeze + sweep (`ledgerHygiene.ts`) removed key material and + * undocumented tables; the claim above describes the actual current state. * * Lifecycle: * - Opens when the SSO session becomes available (user logs in) @@ -13,8 +29,6 @@ * Protection model: * - Encrypted at rest with a key derived from the SSO session token * - Key is held in memory only while the session is active - * - Even if the file is compromised, no plaintext context is exposed - * (only hashes, which are non-reversible) */ import { dirname, join } from 'path' @@ -23,7 +37,9 @@ import { existsSync, mkdirSync } from 'fs' import { createRequire } from 'module' import { homedir } from 'os' import { createHash, createHmac } from 'crypto' -import { migrateHandshakeTables } from './db' +import { migrateHandshakeTables, LEDGER_SCHEMA_FREEZE_VERSION } from './db' +import { sweepLedgerForFreeze, assertLedgerHygiene } from './ledgerHygiene' +import { ensureEvidenceSchema } from './evidenceChain' import { bindKeyProvider, unbindKeyProvider } from '../sealed-storage/index' import { deriveLedgerSealKey } from '../sealed-storage/ledgerSealKey' @@ -156,6 +172,13 @@ function applySchema(db: any): void { } } } + // Phase 5 (Q10): the Tier-L evidence chain is ledger-native schema — + // applied here, never through the (frozen) handshake migration chain. + try { + ensureEvidenceSchema(db) + } catch (err: any) { + console.warn('[LEDGER] Evidence schema warning:', err?.message) + } // Record schema version db.prepare( `INSERT OR IGNORE INTO ledger_meta (key, value) VALUES ('schema_version', '1')` @@ -220,14 +243,42 @@ export async function openLedger(sessionToken: string): Promise { applySchema(db) - // Apply the full vault-schema handshake tables so processHandshakeCapsule - // can run against the ledger DB without vault access. + // Apply the vault-schema handshake tables so processHandshakeCapsule can + // run against the ledger DB without vault access — FROZEN at v74 (Phase 3, + // G5): the core-store split (v75+) and everything after never lands on the + // ledger handle. Its repurposing as the Tier-L evidence home is Phase 5. + // The freeze is persisted as ledger_meta data so LAZY migration calls that + // receive this handle elsewhere (ingestion IPC) respect it too. try { - migrateHandshakeTables(db) + db.prepare(`INSERT OR REPLACE INTO ledger_meta (key, value) VALUES ('wr_schema_freeze', ?)`).run( + String(LEDGER_SCHEMA_FREEZE_VERSION), + ) + migrateHandshakeTables(db, { freezeAtVersion: LEDGER_SCHEMA_FREEZE_VERSION }) } catch (err: any) { console.warn('[LEDGER] Handshake schema migration warning:', err?.message) } + // One-time (idempotent) hygiene sweep under the freeze: key material off + // relationship rows, undocumented tables copied out to a sidecar and + // dropped, then assert documented-tables-only + integrity. + try { + const sweep = sweepLedgerForFreeze(db, { sidecarDir: dirname(dbPath) }) + if (sweep.keyRowsSwept > 0 || sweep.undocumentedTablesRemoved.length > 0 || sweep.errors.length > 0) { + console.log('[LEDGER] Freeze sweep:', { + key_rows_swept: sweep.keyRowsSwept, + undocumented_removed: sweep.undocumentedTablesRemoved, + sidecar: sweep.sidecarPath, + errors: sweep.errors, + }) + } + const hygiene = assertLedgerHygiene(db) + if (!hygiene.ok) { + console.warn('[LEDGER] Hygiene assertion failed:', hygiene) + } + } catch (err: any) { + console.warn('[LEDGER] Freeze sweep warning:', err?.message) + } + // Drain any WAL left by the previous session so reads stay fast. // PASSIVE: copies WAL frames that have no readers blocking them; safe to ignore errors. try { db.pragma('wal_checkpoint(PASSIVE)') } catch { /* ignore — non-critical */ } diff --git a/code/apps/electron-vite-project/electron/main/handshake/ledgerHygiene.ts b/code/apps/electron-vite-project/electron/main/handshake/ledgerHygiene.ts new file mode 100644 index 000000000..63bf685f3 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/ledgerHygiene.ts @@ -0,0 +1,201 @@ +/** + * Ledger freeze & sweep (Phase 3 — G5, prep for Q10). + * + * `handshake-ledger.db` historically received the FULL handshake migration + * chain, so vault-schema tables (and, pre-v73, private-key columns) bled + * into it. From Phase 3 on: + * + * - the ledger handle is FROZEN at LEDGER_SCHEMA_FREEZE_VERSION (v74) — + * `migrateHandshakeTables(db, { freezeAtVersion })` never applies the + * core-store split (v75+) or anything later to it; + * - a ONE-TIME SWEEP (idempotent, re-runnable) copies out and removes + * private-key material from relationship rows and any undocumented + * tables written through the ledger handle; + * - a hygiene assertion verifies the ledger contains only documented + * tables, no key-material columns hold values, and the file passes + * SQLite integrity. + * + * Key-material destination: the ledger's OWN `handshake_key_store` (a + * documented ≤v74 table). The ledger is the ACTIVE pipeline DB while the + * vault is locked, so relationships formed through it must keep signing — + * fully relocating keys off the ledger is coupled to its Phase-5 + * repurposing as the Tier-L evidence home (Q10). The sweep guarantees no + * key ever sits on a RELATIONSHIP ROW (row-level v73 semantics, re-asserted). + * + * Undocumented tables are copied verbatim into a JSON sidecar file next to + * the DB (copy-out) and then dropped. + */ + +import { join } from 'path' +import { writeFileSync } from 'fs' +import { LEDGER_SCHEMA_FREEZE_VERSION, documentedHandshakeTableNames } from './db' + +const LEDGER_NATIVE_TABLES: ReadonlySet = new Set([ + 'ledger_meta', + 'ledger_handshakes', + 'ledger_context_blocks', + 'ledger_schema_migrations', + // Phase 5 (Q10): Tier-L evidence chain home — the only NEW table class + // permitted on the ledger handle; written exclusively by evidenceChain.ts. + 'wr_evidence_chain', +]) + +const SWEEP_META_KEY = 'wr_ledger_sweep_v1' + +function listUserTables(db: any): string[] { + const rows = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .all() as Array<{ name: string }> + return rows.map((r) => r.name) +} + +export interface LedgerTableAudit { + documented: string[] + undocumented: string[] +} + +/** Classify every table on the handle against the documented manifest. */ +export function auditLedgerTables(db: any): LedgerTableAudit { + const manifest = documentedHandshakeTableNames(LEDGER_SCHEMA_FREEZE_VERSION) + const documented: string[] = [] + const undocumented: string[] = [] + for (const name of listUserTables(db)) { + if (manifest.has(name) || LEDGER_NATIVE_TABLES.has(name)) documented.push(name) + else undocumented.push(name) + } + return { documented, undocumented } +} + +export interface LedgerSweepSummary { + keyRowsSwept: number + undocumentedTablesRemoved: string[] + sidecarPath: string | null + errors: string[] +} + +/** + * One-time (idempotent) sweep before/under the freeze. Safe to call on every + * open — a clean ledger sweeps to a no-op. + */ +export function sweepLedgerForFreeze(db: any, opts?: { sidecarDir?: string }): LedgerSweepSummary { + const summary: LedgerSweepSummary = { + keyRowsSwept: 0, + undocumentedTablesRemoved: [], + sidecarPath: null, + errors: [], + } + + // 1 — key material off relationship rows (re-assert v73 copy-before-null; + // covers rows written by pre-v73 builds after the migration already ran). + try { + db.prepare( + `INSERT INTO handshake_key_store ( + handshake_id, local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64, + created_at, updated_at + ) + SELECT handshake_id, local_private_key, local_x25519_private_key_b64, local_mlkem768_secret_key_b64, + datetime('now'), datetime('now') + FROM handshakes + WHERE local_private_key IS NOT NULL + OR local_x25519_private_key_b64 IS NOT NULL + OR local_mlkem768_secret_key_b64 IS NOT NULL + ON CONFLICT(handshake_id) DO NOTHING`, + ).run() + const nulled = db.prepare( + `UPDATE handshakes + SET local_private_key = NULL, + local_x25519_private_key_b64 = NULL, + local_mlkem768_secret_key_b64 = NULL + WHERE local_private_key IS NOT NULL + OR local_x25519_private_key_b64 IS NOT NULL + OR local_mlkem768_secret_key_b64 IS NOT NULL`, + ).run() + summary.keyRowsSwept = nulled.changes ?? 0 + } catch (e: any) { + summary.errors.push(`key_sweep: ${e?.message}`) + } + + // 2 — undocumented tables: copy out to a sidecar file, then drop. + const audit = auditLedgerTables(db) + if (audit.undocumented.length > 0) { + const quarantine: Record = {} + for (const table of audit.undocumented) { + try { + // Table name comes from sqlite_master, not caller input; quote defensively. + quarantine[table] = db.prepare(`SELECT * FROM "${table.replace(/"/g, '""')}"`).all() + } catch (e: any) { + summary.errors.push(`copy_out:${table}: ${e?.message}`) + } + } + if (opts?.sidecarDir) { + try { + const path = join(opts.sidecarDir, `handshake-ledger-sweep-${Date.now()}.json`) + writeFileSync(path, JSON.stringify({ swept_at: new Date().toISOString(), tables: quarantine }, null, 2)) + summary.sidecarPath = path + } catch (e: any) { + summary.errors.push(`sidecar_write: ${e?.message}`) + } + } + // Drop only what was copied out without error (copy-before-remove). + for (const table of audit.undocumented) { + if (!(table in quarantine)) continue + if (opts?.sidecarDir && !summary.sidecarPath) continue + try { + db.prepare(`DROP TABLE IF EXISTS "${table.replace(/"/g, '""')}"`).run() + summary.undocumentedTablesRemoved.push(table) + } catch (e: any) { + summary.errors.push(`drop:${table}: ${e?.message}`) + } + } + } + + try { + db.prepare(`INSERT OR REPLACE INTO ledger_meta (key, value) VALUES (?, ?)`).run( + SWEEP_META_KEY, + new Date().toISOString(), + ) + } catch { /* meta marker is best-effort */ } + + return summary +} + +export interface LedgerHygieneReport { + ok: boolean + undocumented: string[] + keyColumnsClear: boolean + integrityOk: boolean +} + +/** Post-sweep assertion (acceptance test 6): documented tables only, no row-level keys, integrity ok. */ +export function assertLedgerHygiene(db: any): LedgerHygieneReport { + const audit = auditLedgerTables(db) + let keyColumnsClear = true + try { + const row = db + .prepare( + `SELECT COUNT(*) AS n FROM handshakes + WHERE local_private_key IS NOT NULL + OR local_x25519_private_key_b64 IS NOT NULL + OR local_mlkem768_secret_key_b64 IS NOT NULL`, + ) + .get() as { n: number } + keyColumnsClear = row.n === 0 + } catch { + // No handshakes table at all — trivially clear. + } + let integrityOk = false + try { + const result = db.pragma('integrity_check') + integrityOk = Array.isArray(result) + ? result.length === 1 && String(result[0]?.integrity_check).toLowerCase() === 'ok' + : String(result).toLowerCase() === 'ok' + } catch { + integrityOk = false + } + return { + ok: audit.undocumented.length === 0 && keyColumnsClear && integrityOk, + undocumented: audit.undocumented, + keyColumnsClear, + integrityOk, + } +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/llmStream.ts b/code/apps/electron-vite-project/electron/main/handshake/llmStream.ts index 40a616b04..6f344ea67 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/llmStream.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/llmStream.ts @@ -11,6 +11,9 @@ import { } from '../llm/localLlmRuntimeDiagnostics' import { assertGpuInferenceAvailableForChatBase } from '../inference/inferenceGate' import { parseOpenAiChatCompletionsSseLine } from '../llm/openAiSseChatStream' +import { attachAndLogProvenance } from '../aiProvenance/attachProvenance' +import { extractUpstreamMarking } from '../../../../../packages/shared/src/aiProvenance/generate' +import type { AiTextWithProvenance } from '../../../../../packages/shared/src/aiProvenance' export type StreamSender = (channel: string, payload: unknown) => void export type OnToken = (token: string) => void @@ -26,7 +29,7 @@ export async function streamLocalLlmChat( userPrompt: string, send: StreamSender, baseUrl: string = DEFAULT_LOCAL_LLM_STREAM_BASE_URL, -): Promise { +): Promise { const t0 = Date.now() const inflightStart = ollamaRuntimeInFlightDelta(1) if (DEBUG_OLLAMA_RUNTIME_TRACE) { @@ -55,6 +58,7 @@ export async function streamLocalLlmChat( if (!res.body) throw new Error('Local LLM response has no body') let full = '' + let lastParsedObjLocal: unknown = undefined const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -71,6 +75,7 @@ export async function streamLocalLlmChat( full += parsed.content send('handshake:chatStreamToken', { token: parsed.content }) } + if (parsed) lastParsedObjLocal = parsed } } if (DEBUG_OLLAMA_RUNTIME_TRACE) { @@ -81,7 +86,11 @@ export async function streamLocalLlmChat( promptCharsApprox: systemPrompt.length + userPrompt.length, }) } - return full || 'No response from model.' + return attachAndLogProvenance(full || 'No response from model.', { + model_id: model || 'llama3', + provider: 'local', + upstream_marking: extractUpstreamMarking(lastParsedObjLocal), + }) } catch (streamErr: any) { if (DEBUG_OLLAMA_RUNTIME_TRACE) { ollamaRuntimeLog('streamOllamaChat:error', { @@ -107,7 +116,7 @@ export async function streamOpenAIChat( userPrompt: string, apiKey: string, send: StreamSender, -): Promise { +): Promise { const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, @@ -124,6 +133,7 @@ export async function streamOpenAIChat( if (!res.body) throw new Error('OpenAI response has no body') let full = '' + let lastParsedObjOpenAI: unknown = undefined const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -140,6 +150,7 @@ export async function streamOpenAIChat( if (data === '[DONE]') continue try { const obj = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> } + lastParsedObjOpenAI = obj const delta = obj.choices?.[0]?.delta?.content ?? '' if (delta) { full += delta @@ -151,7 +162,11 @@ export async function streamOpenAIChat( } } } - return full || 'No response from model.' + return attachAndLogProvenance(full || 'No response from model.', { + model_id: model || 'gpt-4o', + provider: 'cloud:openai', + upstream_marking: extractUpstreamMarking(lastParsedObjOpenAI), + }) } /** Stream tokens from xAI chat completions (SSE, same format as OpenAI). */ @@ -161,7 +176,7 @@ export async function streamXaiChat( userPrompt: string, apiKey: string, send: StreamSender, -): Promise { +): Promise { const res = await fetch('https://api.x.ai/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, @@ -178,6 +193,7 @@ export async function streamXaiChat( if (!res.body) throw new Error('xAI response has no body') let full = '' + let lastParsedObjXai: unknown = undefined const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -194,6 +210,7 @@ export async function streamXaiChat( if (data === '[DONE]') continue try { const obj = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> } + lastParsedObjXai = obj const delta = obj.choices?.[0]?.delta?.content ?? '' if (delta) { full += delta @@ -205,7 +222,11 @@ export async function streamXaiChat( } } } - return full || 'No response from model.' + return attachAndLogProvenance(full || 'No response from model.', { + model_id: model || 'grok-2-1212', + provider: 'cloud:xai', + upstream_marking: extractUpstreamMarking(lastParsedObjXai), + }) } /** Stream tokens from Anthropic Messages API (SSE, content_block_delta with text_delta). */ @@ -215,7 +236,7 @@ export async function streamAnthropicChat( userPrompt: string, apiKey: string, send: StreamSender, -): Promise { +): Promise { const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { @@ -235,6 +256,7 @@ export async function streamAnthropicChat( if (!res.body) throw new Error('Anthropic response has no body') let full = '' + let lastParsedObjAnthropic: unknown = undefined const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -254,6 +276,7 @@ export async function streamAnthropicChat( type?: string delta?: { type?: string; text?: string } } + lastParsedObjAnthropic = obj if (obj.type === 'content_block_delta' && obj.delta?.type === 'text_delta' && obj.delta.text) { full += obj.delta.text send('handshake:chatStreamToken', { token: obj.delta.text }) @@ -269,6 +292,7 @@ export async function streamAnthropicChat( if (data && data !== '[DONE]') { try { const obj = JSON.parse(data) as { type?: string; delta?: { type?: string; text?: string } } + lastParsedObjAnthropic = obj if (obj.type === 'content_block_delta' && obj.delta?.type === 'text_delta' && obj.delta.text) { full += obj.delta.text send('handshake:chatStreamToken', { token: obj.delta.text }) @@ -278,7 +302,11 @@ export async function streamAnthropicChat( } } } - return full || 'No response from model.' + return attachAndLogProvenance(full || 'No response from model.', { + model_id: model || 'claude-sonnet-4-20250514', + provider: 'cloud:anthropic', + upstream_marking: extractUpstreamMarking(lastParsedObjAnthropic), + }) } /** Stream tokens from Google Gemini streamGenerateContent (SSE). */ @@ -288,7 +316,7 @@ export async function streamGoogleChat( userPrompt: string, apiKey: string, send: StreamSender, -): Promise { +): Promise { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model || 'gemini-pro'}:streamGenerateContent?alt=sse&key=${apiKey}` const res = await fetch(url, { method: 'POST', @@ -302,6 +330,7 @@ export async function streamGoogleChat( if (!res.body) throw new Error('Google response has no body') let full = '' + let lastParsedObjGoogle: unknown = undefined const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -320,6 +349,7 @@ export async function streamGoogleChat( const obj = JSON.parse(data) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> } + lastParsedObjGoogle = obj const text = obj.candidates?.[0]?.content?.parts?.[0]?.text ?? '' if (text) { full += text @@ -338,6 +368,7 @@ export async function streamGoogleChat( const obj = JSON.parse(data) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> } + lastParsedObjGoogle = obj const text = obj.candidates?.[0]?.content?.parts?.[0]?.text ?? '' if (text) { full += text @@ -348,7 +379,11 @@ export async function streamGoogleChat( } } } - return full || 'No response from model.' + return attachAndLogProvenance(full || 'No response from model.', { + model_id: model || 'gemini-pro', + provider: 'cloud:google', + upstream_marking: extractUpstreamMarking(lastParsedObjGoogle), + }) } // ── Unified streaming interface ───────────────────────────────────────────── @@ -366,13 +401,13 @@ export interface StreamLLMParams { /** * Unified streaming interface. Streams tokens to the UI via onToken. - * Returns the full accumulated response. + * Returns the full accumulated response with AiProvenance attached. */ export async function streamLLMResponse( provider: LLMProvider, params: StreamLLMParams, onToken: OnToken, -): Promise { +): Promise { const send: StreamSender = (ch, payload) => { if (ch === 'handshake:chatStreamToken' && payload && typeof payload === 'object' && 'token' in payload) { const t = (payload as { token: string }).token diff --git a/code/apps/electron-vite-project/electron/main/handshake/nonceStore.ts b/code/apps/electron-vite-project/electron/main/handshake/nonceStore.ts new file mode 100644 index 000000000..5c2d9406a --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/nonceStore.ts @@ -0,0 +1,41 @@ +/** + * Core nonce store — freshness/replay check for signed core records + * (Phase 2 — A1) [VII.3.1]. + * + * A nonce may be observed once per scope. Idempotent redelivery of the SAME + * object (same bound hash) is not a replay — transports retry; the + * duplicate-capsule dedup owns that path. A seen nonce arriving with a + * DIFFERENT bound hash is a replay: a fresh object reusing spent freshness. + */ + +export const WR_CORE_NONCE_SCOPE = 'wr.handshake.core' + +export type NonceCheckResult = + | { ok: true; firstSeen: boolean } + | { ok: false; reason: 'replay'; boundHash: string | null } + +/** + * Check-and-record in one transaction. `boundHash` binds the nonce to the + * object it arrived with (the capsule_hash for v3 capsules). + */ +export function checkAndRecordNonce( + db: any, + scope: string, + nonce: string, + boundHash: string, +): NonceCheckResult { + const tx = db.transaction((): NonceCheckResult => { + const row = db + .prepare('SELECT bound_hash FROM wr_core_nonces WHERE scope = ? AND nonce = ?') + .get(scope, nonce) as { bound_hash: string | null } | undefined + if (row) { + if (row.bound_hash === boundHash) return { ok: true, firstSeen: false } + return { ok: false, reason: 'replay', boundHash: row.bound_hash } + } + db.prepare( + 'INSERT INTO wr_core_nonces (scope, nonce, bound_hash, seen_at) VALUES (?, ?, ?, ?)', + ).run(scope, nonce, boundHash, new Date().toISOString()) + return { ok: true, firstSeen: true } + }) + return tx() +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/outboundQueue.ts b/code/apps/electron-vite-project/electron/main/handshake/outboundQueue.ts index 194fb86f3..d724d0b9a 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/outboundQueue.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/outboundQueue.ts @@ -58,7 +58,7 @@ function logInternalHsTraceOutbound( ): void { try { const rec = getHandshakeRecord(db, handshakeId) - if (rec?.handshake_type !== 'internal') return + if (rec?.same_principal !== true) return const cap = capsule as Record let localDev = '' try { @@ -76,7 +76,7 @@ function logInternalHsTraceOutbound( trace: 'outbound_coordination_send', ts: new Date().toISOString(), handshake_id: handshakeId, - handshake_type: rec.handshake_type, + same_principal: rec.same_principal === true, capsule_type: typeof cap.capsule_type === 'string' ? cap.capsule_type : null, sender_wrdesk_user_id: typeof cap.sender_wrdesk_user_id === 'string' ? cap.sender_wrdesk_user_id : null, @@ -571,11 +571,11 @@ async function handleCoordinationOutbound403( const { getCurrentSession } = await import('./ipc') const sess = getCurrentSession() const initiatorId = - record.handshake_type === 'internal' && sess?.sub?.trim() + record.same_principal === true && sess?.sub?.trim() ? sess.sub.trim() : (record.initiator?.sub ?? record.initiator?.wrdesk_user_id ?? '') const acceptorId = - record.handshake_type === 'internal' && sess?.sub?.trim() + record.same_principal === true && sess?.sub?.trim() ? sess.sub.trim() : (record.acceptor?.sub ?? record.acceptor?.wrdesk_user_id ?? '') const initiatorEmail = record.initiator?.email ?? '' @@ -586,7 +586,7 @@ async function handleCoordinationOutbound403( acceptor_user_id: acceptorId, initiator_email: initiatorEmail, acceptor_email: acceptorEmail, - handshake_type: record.handshake_type === 'internal' ? 'internal' : undefined, + same_principal: record.same_principal === true, ...(record.initiator_coordination_device_id?.trim() ? { initiator_device_id: record.initiator_coordination_device_id.trim() } : {}), diff --git a/code/apps/electron-vite-project/electron/main/handshake/p2pTokenBackfill.ts b/code/apps/electron-vite-project/electron/main/handshake/p2pTokenBackfill.ts index 6f1a96bab..3427a4de3 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/p2pTokenBackfill.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/p2pTokenBackfill.ts @@ -81,7 +81,7 @@ export function runActiveHandshakeLocalP2pTokenBackfill( } const internalWire = internalRelayCapsuleWireOptsFromRecord(rec, localDev) const p2pCfg = getP2PConfig(db) - if (rec.handshake_type === 'internal' && p2pCfg.use_coordination && !internalWire) { + if (rec.same_principal === true && p2pCfg.use_coordination && !internalWire) { continue } @@ -97,6 +97,8 @@ export function runActiveHandshakeLocalP2pTokenBackfill( local_public_key: localPub, local_private_key: localPriv, p2p_auth_token: token, + localHandshakeRole: rec.local_role, + counterpartyIdentity: rec.local_role === 'initiator' ? rec.acceptor : rec.initiator, ...(internalWire ?? {}), }) let target = rec.p2p_endpoint?.trim() || '' diff --git a/code/apps/electron-vite-project/electron/main/handshake/p2pTransport.ts b/code/apps/electron-vite-project/electron/main/handshake/p2pTransport.ts index 28c3b5bf7..9c19c2957 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/p2pTransport.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/p2pTransport.ts @@ -631,7 +631,7 @@ export async function sendCapsuleViaCoordination( if (db && queueHandshakeId?.trim()) { try { const rec = getHandshakeRecord(db, queueHandshakeId.trim()) - if (rec?.handshake_type === 'internal') { + if (rec?.same_principal === true) { console.log( '[RELAY_ROUTING_DEBUG] internal_handshake_device_context', JSON.stringify({ diff --git a/code/apps/electron-vite-project/electron/main/handshake/realmInventory.ts b/code/apps/electron-vite-project/electron/main/handshake/realmInventory.ts new file mode 100644 index 000000000..9f602ded9 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/realmInventory.ts @@ -0,0 +1,109 @@ +/** + * Realm-distribution inventory (Phase 1 risk register: "identity-guard + * tightening breaks same-user multi-realm setups"). + * + * Counts how existing handshake rows are distributed across identity realms + * (OIDC issuers) BEFORE full-claim enforcement decides anything, so the phase + * report can quantify how many rows would fall into the Q12 + * `mixed_realm_repair` class instead of matching cleanly. + * + * Metadata-only: the inventory carries counts and issuer host names, never + * emails, subjects, or tokens. + */ + +import { fullClaimIdentityMatch, samePrincipalFullClaim } from '@repo/ingestion-core' +import type { HandshakeRecord, PartyIdentity, SSOSession } from './types' +import { classifyPartyForSessionVisibility } from './handshakeAccountIsolation' + +export interface RealmDistributionInventory { + total_rows: number + by_state: Record + internal_rows: number + standard_rows: number + /** Distinct issuer hosts seen on any party (hostname only, metadata). */ + distinct_issuer_hosts: string[] + rows_initiator_iss_missing: number + rows_acceptor_iss_missing: number + /** Both parties carry an issuer and they differ (cross-realm relationship). */ + rows_cross_realm_pair: number + /** Internal rows whose parties fail the same-principal full-claim check. */ + internal_rows_principal_mismatch: number + /** Session-relative classification (only when a session is provided). */ + session_relative?: { + match: number + mixed_realm_repair: number + foreign: number + } +} + +function issuerHost(party: PartyIdentity | null | undefined): string | null { + const iss = (party?.iss ?? '').trim() + if (!iss) return null + try { + return new URL(iss).host || iss + } catch { + return iss + } +} + +export function inventoryRealmDistribution( + records: readonly HandshakeRecord[], + session?: SSOSession | null, +): RealmDistributionInventory { + const inv: RealmDistributionInventory = { + total_rows: records.length, + by_state: {}, + internal_rows: 0, + standard_rows: 0, + distinct_issuer_hosts: [], + rows_initiator_iss_missing: 0, + rows_acceptor_iss_missing: 0, + rows_cross_realm_pair: 0, + internal_rows_principal_mismatch: 0, + } + const hosts = new Set() + const sessionRelative = { match: 0, mixed_realm_repair: 0, foreign: 0 } + + for (const r of records) { + inv.by_state[r.state] = (inv.by_state[r.state] ?? 0) + 1 + if (r.same_principal === true) inv.internal_rows++ + else inv.standard_rows++ + + const hi = issuerHost(r.initiator) + const ha = issuerHost(r.acceptor) + if (hi) hosts.add(hi) + if (ha) hosts.add(ha) + if (!hi) inv.rows_initiator_iss_missing++ + if (r.acceptor && !ha) inv.rows_acceptor_iss_missing++ + if (hi && ha && hi !== ha) inv.rows_cross_realm_pair++ + + if (r.same_principal === true && r.acceptor) { + if (!samePrincipalFullClaim(r.initiator, r.acceptor).ok) { + inv.internal_rows_principal_mismatch++ + } + } + + if (session) { + const vsInitiator = classifyPartyForSessionVisibility(session, r.initiator) + const vsAcceptor = r.acceptor ? classifyPartyForSessionVisibility(session, r.acceptor) : 'foreign' + if (vsInitiator === 'match' || vsAcceptor === 'match') sessionRelative.match++ + else if (vsInitiator === 'mixed_realm_repair' || vsAcceptor === 'mixed_realm_repair') + sessionRelative.mixed_realm_repair++ + else sessionRelative.foreign++ + } + } + + inv.distinct_issuer_hosts = [...hosts].sort() + if (session) inv.session_relative = sessionRelative + return inv +} + +/** Convenience: read all rows from the ledger DB and log the inventory (metadata only). */ +export function logRealmDistributionInventory(db: any, session?: SSOSession | null): RealmDistributionInventory { + // Lazy import avoids a module cycle with db.ts consumers. + const { listHandshakeRecords } = require('./db') as typeof import('./db') + const records = listHandshakeRecords(db) + const inv = inventoryRealmDistribution(records, session) + console.log('[IDENTITY_GUARD] realm_distribution_inventory', JSON.stringify(inv)) + return inv +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/recipientPersist.ts b/code/apps/electron-vite-project/electron/main/handshake/recipientPersist.ts deleted file mode 100644 index 04ed84c94..000000000 --- a/code/apps/electron-vite-project/electron/main/handshake/recipientPersist.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Recipient Persist — Import .beap file as PENDING_REVIEW - * - * When the acceptor imports an initiate capsule from a file, we create a - * local record with state PENDING_REVIEW. The user needs to review and decide - * (accept or decline). PENDING_ACCEPT is the initiator's state (waiting for - * the other side); PENDING_REVIEW is the recipient's state (reviewing the request). - * - * Validation is done via processIncomingInput before this is called. - */ - -import type { HandshakeRecord } from './types' -import { HandshakeState as HS, INPUT_LIMITS } from './types' - -/** Use capsule expires_at only if it is in the future; otherwise default to PENDING_TIMEOUT. Prevents "expired" on import when capsule has stale expires_at. */ -function resolveExpiresAt(capsuleExpiresAt: string | undefined): string { - if (!capsuleExpiresAt) return new Date(Date.now() + INPUT_LIMITS.PENDING_TIMEOUT_MS).toISOString() - const parsed = Date.parse(capsuleExpiresAt) - if (isNaN(parsed) || parsed <= Date.now()) return new Date(Date.now() + INPUT_LIMITS.PENDING_TIMEOUT_MS).toISOString() - return capsuleExpiresAt -} -import { buildDefaultReceiverPolicy } from './types' -import { classifyHandshakeTier } from './tierClassification' -import { resolveEffectivePolicyFn } from './steps/policyResolution' -import { insertHandshakeRecord, insertSeenCapsuleHash } from './db' -import type { ValidatedCapsule } from '../ingestion/types' -import { validateInternalInitiateCapsuleWire } from './internalPersistence' -import { randomUUID } from 'crypto' - -export interface PersistRecipientResult { - success: boolean - error?: string - reason?: string - handshake_id?: string - handshakeRecord?: HandshakeRecord -} - -/** - * Persist the acceptor's handshake record from an imported initiate capsule. - * Creates record with state PENDING_REVIEW. - */ -export function persistRecipientHandshakeRecord( - db: any, - validated: ValidatedCapsule, - ssoSession: { plan?: string; currentHardwareAttestation?: any; currentDnsVerification?: any; wrStampStatus?: any }, -): PersistRecipientResult { - try { - const c = validated.capsule as Record - if ((c?.capsule_type ?? '') !== 'initiate') { - return { success: false, error: 'Only initiate capsules can be imported', reason: 'NOT_INITIATE_CAPSULE' } - } - - const tierDecision = classifyHandshakeTier({ - plan: ssoSession.plan ?? 'free', - hardwareAttestation: ssoSession.currentHardwareAttestation, - dnsVerification: ssoSession.currentDnsVerification, - wrStampStatus: (ssoSession as any).wrStampStatus ?? (ssoSession as any).currentWrStampStatus, - }) - - const receiverPolicy = buildDefaultReceiverPolicy() - const effectivePolicyResult = resolveEffectivePolicyFn(null, receiverPolicy) - if ('unsatisfiable' in effectivePolicyResult) { - return { success: false, error: 'Policy resolution failed' } - } - const effectivePolicy = effectivePolicyResult - - const senderP2PEndpoint: string | null = - typeof c?.p2p_endpoint === 'string' && c.p2p_endpoint.trim().length > 0 ? c.p2p_endpoint.trim() : null - const senderP2PAuthToken: string | null = - typeof c?.p2p_auth_token === 'string' && c.p2p_auth_token.trim().length > 0 ? c.p2p_auth_token.trim() : null - const senderPublicKey = typeof c?.sender_public_key === 'string' ? c.sender_public_key : '' - const senderX25519: string | null = - (typeof c?.sender_x25519_public_key_b64 === 'string' && c.sender_x25519_public_key_b64.trim().length > 0) - ? c.sender_x25519_public_key_b64.trim() - : null - const senderMlkem768: string | null = - (typeof c?.sender_mlkem768_public_key_b64 === 'string' && c.sender_mlkem768_public_key_b64.trim().length > 0) - ? c.sender_mlkem768_public_key_b64.trim() - : null - const initiatorCoordinationDeviceId: string | null = - typeof c?.sender_device_id === 'string' && c.sender_device_id.trim().length > 0 - ? c.sender_device_id.trim() - : null - - const wireInternal = c?.handshake_type === 'internal' - if (wireInternal) { - // Phase 4: the initiator now learns the peer's `device_name` from the - // pairing-code resolve RPC before building the capsule, so both endpoints - // always carry real, non-empty computer names on the wire. The earlier - // pairing-time `` sentinel path no longer exists. - const w = validateInternalInitiateCapsuleWire(c as Record) - if (!w.ok) { - return { success: false, error: w.error ?? 'Internal initiate capsule invalid', reason: w.code } - } - } - - const senderIdentity = c.senderIdentity ?? { - email: c.sender_email ?? '', - iss: c.iss ?? '', - sub: c.sub ?? c.sender_id ?? '', - wrdesk_user_id: c.sender_wrdesk_user_id ?? c.sender_id ?? '', - } - - const record: HandshakeRecord = { - handshake_id: c.handshake_id ?? '', - relationship_id: c.relationship_id ?? '', - state: HS.PENDING_REVIEW, - initiator: { - email: senderIdentity.email ?? '', - wrdesk_user_id: (senderIdentity.wrdesk_user_id ?? c.sender_wrdesk_user_id ?? c.sender_id ?? '') as string, - iss: senderIdentity.iss ?? '', - sub: senderIdentity.sub ?? '', - }, - acceptor: null, - local_role: 'acceptor', - sharing_mode: null, - reciprocal_allowed: c.reciprocal_allowed ?? false, - tier_snapshot: tierDecision, - current_tier_signals: c.tierSignals ?? { plan: 'free', hardwareAttestation: null, dnsVerification: null, wrStampStatus: null }, - last_seq_sent: 0, - last_seq_received: 0, - last_capsule_hash_sent: '', - last_capsule_hash_received: c.capsule_hash ?? '', - effective_policy: effectivePolicy, - external_processing: c.external_processing ?? 'none', - created_at: c.timestamp ?? new Date().toISOString(), - activated_at: null, - expires_at: resolveExpiresAt(c.expires_at), - revoked_at: null, - revocation_source: null, - initiator_wrdesk_policy_hash: c.wrdesk_policy_hash ?? '', - initiator_wrdesk_policy_version: c.wrdesk_policy_version ?? '', - acceptor_wrdesk_policy_hash: null, - acceptor_wrdesk_policy_version: null, - initiator_context_commitment: c.context_commitment ?? null, - acceptor_context_commitment: null, - p2p_endpoint: senderP2PEndpoint, - local_p2p_auth_token: randomUUID(), - counterparty_p2p_token: senderP2PAuthToken, - counterparty_public_key: senderPublicKey || null, - receiver_email: c.receiver_email ?? null, - peer_x25519_public_key_b64: senderX25519, - peer_mlkem768_public_key_b64: senderMlkem768, - initiator_coordination_device_id: initiatorCoordinationDeviceId, - ...(wireInternal - ? { - handshake_type: 'internal' as const, - initiator_device_name: - typeof c.sender_computer_name === 'string' ? c.sender_computer_name.trim() : null, - initiator_device_role: c.sender_device_role ?? null, - acceptor_device_name: - typeof c.receiver_computer_name === 'string' ? c.receiver_computer_name.trim() : null, - acceptor_device_role: c.receiver_device_role ?? null, - acceptor_coordination_device_id: null, - internal_peer_device_id: - typeof c.receiver_device_id === 'string' ? c.receiver_device_id.trim() : null, - internal_peer_device_role: c.receiver_device_role ?? null, - internal_peer_computer_name: - typeof c.receiver_computer_name === 'string' ? c.receiver_computer_name.trim() : null, - // Pairing-code routed initiate capsules carry receiver_pairing_code on the - // wire. Persist into internal_peer_pairing_code so the AcceptHandshakeModal - // / handshake.accept comparison can read it. Legacy capsules omit this and - // fall back to the UUID equality check via internal_peer_device_id. - internal_peer_pairing_code: - typeof c.receiver_pairing_code === 'string' && /^\d{6}$/.test(c.receiver_pairing_code.trim()) - ? c.receiver_pairing_code.trim() - : null, - } - : {}), - } - - insertHandshakeRecord(db, record) - insertSeenCapsuleHash(db, record.handshake_id, record.last_capsule_hash_received) - console.log('[HANDSHAKE] Recipient import OK:', record.handshake_id, 'state=PENDING_REVIEW') - - return { success: true, handshake_id: record.handshake_id, handshakeRecord: record } - } catch (err: any) { - const msg = err?.message ?? String(err) - const stack = err?.stack ?? '' - console.error('[HANDSHAKE] Recipient persist failed:', msg, stack) - try { - const fs = require('fs') - const path = require('path') - const logDir = path.join(process.env.USERPROFILE || process.env.HOME || '', '.opengiraffe') - const logFile = path.join(logDir, 'import-error.log') - fs.mkdirSync(logDir, { recursive: true }) - fs.appendFileSync(logFile, `[${new Date().toISOString()}] persistRecipientHandshakeRecord: ${msg}\n${stack}\n\n`) - } catch (_) { /* ignore */ } - return { - success: false, - error: msg, - reason: 'INTERNAL_ERROR', - } - } -} diff --git a/code/apps/electron-vite-project/electron/main/handshake/revocation.ts b/code/apps/electron-vite-project/electron/main/handshake/revocation.ts index db0196d09..2e1dbfdc1 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/revocation.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/revocation.ts @@ -1,11 +1,18 @@ /** - * Handshake revocation. + * Handshake revocation — SILENT [VII.10.7.2–7.4] (Phase 4, V5). * * 1. Mark REVOKED (historical records intact, tier_snapshot NOT modified). * 2. Future activation denied immediately. - * 3. Crypto-erase or delete per receiver policy. - * 4. Delete derived data (embeddings cascade via FK). - * 5. Best-effort peer notification if local-user initiated. + * 3. NO peer notification of any kind: no capsule, no bounce, no state change + * visible to the counterparty. Enforcement is exclusively the receiver-side + * ingress admission filter (ingressAdmission.ts): transmissions from a + * revoked counterparty die pre-visibility with a logged record. Old-build + * peers keep a zombie ACTIVE record and keep transmitting — acceptable + * BECAUSE the filter kills those transmissions pre-visibility. + * 4. Q8: revocation does NOT delete context blocks, embeddings, or audit + * rows — evidence and digests persist. Content deletion is the separate + * explicit operator action `deleteRevokedRelationshipContent`. + * 5. Re-handshake reanimates nothing. */ // ── UX-3 D1: revoke notification callback ──────────────────────────────────── @@ -20,7 +27,6 @@ export function setRevokeNotifyCallback(cb: RevokeNotifyCallback | null): void { } // ───────────────────────────────────────────────────────────────────────────── -import type { SSOSession } from './types' import { HandshakeState } from './types' import { getHandshakeRecord, @@ -30,11 +36,8 @@ import { insertAuditLogEntry, } from './db' import { buildRevocationAuditEntry } from './auditLog' -import { buildRevokeCapsule } from './capsuleBuilder' -import { enqueueOutboundCapsule, processOutboundQueue } from './outboundQueue' -import { getP2PConfig, getEffectiveRelayEndpoint } from '../p2p/p2pConfig' -import { getInstanceId } from '../orchestrator/orchestratorModeStore' -import { internalRelayCapsuleWireOptsFromRecord } from './internalCoordinationWire' +import { revokeGrantsForHandshake } from './grants' +import { appendEvidenceBestEffort, poacContentDeletionPayload } from './evidenceChain' import { P2pSessionLogReason, closeSession } from '../internalInference/p2pSession/p2pInferenceSessionManager' export async function revokeHandshake( @@ -42,8 +45,6 @@ export async function revokeHandshake( handshakeId: string, source: 'remote-capsule' | 'local-user', actorUserId?: string, - session?: SSOSession, - getOidcToken?: () => Promise, ): Promise { const record = getHandshakeRecord(db, handshakeId) if (!record) return @@ -51,22 +52,8 @@ export async function revokeHandshake( // Already revoked — idempotent if (record.state === HandshakeState.REVOKED) return - // Snapshot signing keys before the transaction deletes context blocks. - // We need them after the transaction to build the outbound revoke capsule. - const localPub = record.local_public_key ?? '' - const localPriv = record.local_private_key ?? '' - const lastSeqReceived = record.last_seq_received ?? 0 - const lastSeqSent = record.last_seq_sent ?? 0 - const lastCapsuleHash = record.last_capsule_hash_received ?? '' - const counterpartyUserId = record.local_role === 'initiator' - ? record.acceptor?.wrdesk_user_id ?? '' - : record.initiator?.wrdesk_user_id ?? '' - const counterpartyEmail = record.local_role === 'initiator' - ? record.acceptor?.email ?? '' - : record.initiator?.email ?? '' - const tx = db.transaction(() => { - // 1. Mark REVOKED + // 1. Mark REVOKED — content, evidence, and digests persist (Q8). const revoked = { ...record, state: HandshakeState.REVOKED, @@ -75,13 +62,12 @@ export async function revokeHandshake( } updateHandshakeRecord(db, revoked) - // 2. Delete embeddings first (FK cascade would handle it, but explicit is safer) - deleteEmbeddingsByHandshake(db, handshakeId) - - // 3. Delete context blocks (crypto-erase: deleting is sufficient since DB is encrypted) - deleteBlocksByHandshake(db, handshakeId) + // 2. Kill ALL grant objects of the counterparty [VII.10.8] (Phase 5, E4). + // Enforcement stays the receiver-side ingress filter; each revoked + // grant produces its own PoAC record. + revokeGrantsForHandshake(db, handshakeId, `handshake_revoked:${source}`, actorUserId) - // 4. Audit log + // 3. Audit log insertAuditLogEntry(db, buildRevocationAuditEntry(handshakeId, source, actorUserId)) }) @@ -104,69 +90,52 @@ export async function revokeHandshake( } catch (err: any) { console.warn('[TOPOLOGY_AUTO_WIRE] removeTopologyForHandshake on revoke failed:', err?.message) } +} - // 5. Best-effort peer notification: build and enqueue a signed revoke capsule. - // Only for local-user initiated revocations (remote-capsule means we already received theirs). - // Requires a session, signing keys, and a known counterparty. - if ( - source === 'local-user' && - session && - localPub && - localPriv && - counterpartyUserId && - counterpartyEmail - ) { - try { - const p2pConfig = getP2PConfig(db) - const targetEndpoint = record.p2p_endpoint?.trim() || getEffectiveRelayEndpoint(p2pConfig, null) - if (!targetEndpoint) { - console.warn('[Revoke] No target endpoint for peer notification, handshake:', handshakeId) - return - } - - let revokeLocalDev: string | undefined - try { - revokeLocalDev = getInstanceId()?.trim() || undefined - } catch { - revokeLocalDev = undefined - } - const revokeInternalWire = internalRelayCapsuleWireOptsFromRecord(record, revokeLocalDev) - if (p2pConfig.use_coordination && record.handshake_type === 'internal' && !revokeInternalWire) { - console.warn( - '[Revoke] Skipping peer notify — INTERNAL_RELAY_ENDPOINTS_INCOMPLETE, handshake:', - handshakeId, - ) - return - } +/** + * Q8: separate EXPLICIT operator action — delete the shared-content payload + * (context blocks + embeddings) of an already-revoked relationship. Never + * called from `revokeHandshake`; a UI/IPC surface must invoke it as its own + * deliberate step. Audit rows are never deleted here — evidence persists. + */ +export function deleteRevokedRelationshipContent( + db: any, + handshakeId: string, + actorUserId?: string, +): { ok: true; blocks_deleted: number; embeddings_deleted: number } | { ok: false; reason: 'not_found' | 'not_revoked' } { + const record = getHandshakeRecord(db, handshakeId) + if (!record) return { ok: false, reason: 'not_found' } + if (record.state !== HandshakeState.REVOKED) return { ok: false, reason: 'not_revoked' } - const revokeCapsule = buildRevokeCapsule(session, { - handshake_id: handshakeId, - counterpartyUserId, - counterpartyEmail, - last_seq_sent: lastSeqSent, - last_seq_received: lastSeqReceived, - last_capsule_hash_received: lastCapsuleHash, - local_public_key: localPub, - local_private_key: localPriv, - ...(record.local_p2p_auth_token?.trim() ? { p2p_auth_token: record.local_p2p_auth_token.trim() } : {}), - ...(revokeInternalWire ?? {}), - }) + let blocksDeleted = 0 + let embeddingsDeleted = 0 + const tx = db.transaction(() => { + // Embeddings first (FK cascade would handle it, but explicit is safer), + // then blocks (crypto-erase: deleting suffices — the DB is encrypted). + embeddingsDeleted = deleteEmbeddingsByHandshake(db, handshakeId) + blocksDeleted = deleteBlocksByHandshake(db, handshakeId) + insertAuditLogEntry(db, { + timestamp: new Date().toISOString(), + action: 'revoked_content_deleted', + handshake_id: handshakeId, + reason_code: 'OK', + actor_wrdesk_user_id: actorUserId, + metadata: { blocks_deleted: blocksDeleted, embeddings_deleted: embeddingsDeleted }, + }) + }) + tx() - const enqRv = enqueueOutboundCapsule(db, handshakeId, targetEndpoint, revokeCapsule) - if (!enqRv.enqueued) { - console.warn('[Revoke] Revoke capsule enqueue blocked:', enqRv.message) - return - } - console.log('[Revoke] Revoke capsule enqueued for peer delivery, handshake:', handshakeId) + // Content deletion is an authorized change — PoAC-recorded (Q8, Phase 5). + appendEvidenceBestEffort({ + chainId: handshakeId, + recordType: 'poac', + payload: poacContentDeletionPayload({ + handshake_id: handshakeId, + blocks_deleted: blocksDeleted, + embeddings_deleted: embeddingsDeleted, + actor_wrdesk_user_id: actorUserId ?? null, + }), + }) - if (getOidcToken) { - processOutboundQueue(db, getOidcToken).catch((err: any) => { - console.warn('[Revoke] processOutboundQueue error:', err?.message) - }) - } - } catch (err: any) { - // Best-effort: log but never block the local revoke - console.warn('[Revoke] Failed to enqueue revoke capsule for peer:', err?.message) - } - } + return { ok: true, blocks_deleted: blocksDeleted, embeddings_deleted: embeddingsDeleted } } diff --git a/code/apps/electron-vite-project/electron/main/handshake/samePrincipalWire.ts b/code/apps/electron-vite-project/electron/main/handshake/samePrincipalWire.ts new file mode 100644 index 000000000..8a5310524 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/handshake/samePrincipalWire.ts @@ -0,0 +1,33 @@ +/** + * Legacy wire → profile compat boundary (Phase 4, Q9) [VII.4.6] + * + * The `handshake_type: 'internal' | 'standard'` discriminator is ELIMINATED + * from records and from all semantic branching: the admission situation + * "both endpoints belong to the same principal" is the profile-registry + * parameter `same_principal` (profile `internal_device`). + * + * v2 wire capsules still carry `handshake_type` for old peers (dual-format + * emission, Phase 2). This module is the ONLY place inbound code may read + * that wire field — everything downstream consumes the mapped profile / + * boolean. Guarded by handshakeTypeElimination.guard.test.ts. + */ + +/** + * Single permitted read of the legacy wire discriminator: does this wire + * object (capsule, parsed input, IPC request) declare same-principal + * device pairing? + */ +export function wireDeclaresSamePrincipal( + x: { handshake_type?: unknown } | null | undefined, +): boolean { + return typeof x?.handshake_type === 'string' && x.handshake_type.trim() === 'internal' +} + +/** + * Legacy wire value for OUTBOUND emission to old-build peers (dual-format): + * same-principal relationships emit 'internal', everything else omits the + * field (callers spread conditionally). + */ +export function legacyWireHandshakeType(samePrincipal: boolean | undefined): 'internal' | undefined { + return samePrincipal === true ? 'internal' : undefined +} diff --git a/code/apps/electron-vite-project/electron/main/handshake/signatureKeys.ts b/code/apps/electron-vite-project/electron/main/handshake/signatureKeys.ts index 3c2d54961..b2e88c0ae 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/signatureKeys.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/signatureKeys.ts @@ -60,9 +60,15 @@ export function signCapsuleHash(capsuleHash: string, privateKeyHex: string): str const data = Buffer.from(capsuleHash, 'hex') let keyObj if (privateKeyHex.length === 64) { - // Raw seed: create keypair from seed for signing - const seed = Buffer.from(privateKeyHex, 'hex') - keyObj = generateKeyPairSync('ed25519', { seed }).privateKey + // Raw seed → wrap in PKCS#8 (RFC 8410). The previous + // generateKeyPairSync('ed25519', { seed }) form silently ignored the seed + // (no such option exists) and signed with a RANDOM key — any 64-char-hex + // key would have produced signatures that never verify. + const der = Buffer.concat([ + Buffer.from('302e020100300506032b657004220420', 'hex'), + Buffer.from(privateKeyHex, 'hex'), + ]) + keyObj = createPrivateKey({ key: der, format: 'der', type: 'pkcs8' }) } else { // Full PKCS#8 DER keyObj = createPrivateKey({ key: Buffer.from(privateKeyHex, 'hex'), format: 'der', type: 'pkcs8' }) diff --git a/code/apps/electron-vite-project/electron/main/handshake/steps/contextVersions.ts b/code/apps/electron-vite-project/electron/main/handshake/steps/contextVersions.ts deleted file mode 100644 index ca5ce4985..000000000 --- a/code/apps/electron-vite-project/electron/main/handshake/steps/contextVersions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { PipelineStep } from '../types' - -/** - * Context version verification. - * - * In the hardened model, handshake capsules carry only proof hashes — - * they have no version field. Version monotonicity checks are enforced - * when full content blocks arrive via the BEAP-Capsule pipeline. - * - * This step is a no-op for handshake capsules but remains in the - * pipeline as a structural placeholder for future BEAP-Capsule support. - */ -export const verifyContextVersions: PipelineStep = { - name: 'verify_context_versions', - execute(_ctx) { - return { passed: true } - }, -} diff --git a/code/apps/electron-vite-project/electron/main/handshake/steps/index.ts b/code/apps/electron-vite-project/electron/main/handshake/steps/index.ts index 1aa569b33..bc5b74357 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/steps/index.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/steps/index.ts @@ -19,7 +19,6 @@ import { verifyChainIntegrity } from './chainIntegrity' import { verifySharingMode } from './sharingMode' import { verifyExternalProcessing } from './externalProcessing' import { verifyContextBinding } from './contextBinding' -import { verifyContextVersions } from './contextVersions' import { resolveEffectivePolicy } from './policyResolution' import { verifyScopePurpose } from './scopePurpose' import { verifyTimestamp } from './timestamp' @@ -43,7 +42,6 @@ export const HANDSHAKE_PIPELINE: readonly PipelineStep[] = Object.freeze([ verifySharingMode, verifyExternalProcessing, verifyContextBinding, - verifyContextVersions, resolveEffectivePolicy, verifyScopePurpose, verifyTimestamp, @@ -70,7 +68,6 @@ export { verifySharingMode, verifyExternalProcessing, verifyContextBinding, - verifyContextVersions, resolveEffectivePolicy, verifyScopePurpose, verifyTimestamp, diff --git a/code/apps/electron-vite-project/electron/main/handshake/steps/internalRoutingCapsule.ts b/code/apps/electron-vite-project/electron/main/handshake/steps/internalRoutingCapsule.ts index d180f8386..23f00985c 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/steps/internalRoutingCapsule.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/steps/internalRoutingCapsule.ts @@ -1,25 +1,31 @@ /** - * Internal handshake capsules must carry coordination device routing on wire so ingest - * matches relay registration (no silent ACK with ambiguous same-principal routing). + * Same-principal handshake capsules must carry coordination device routing on wire so + * ingest matches relay registration (no silent ACK with ambiguous same-principal routing). + * + * Phase 4 (Q9): the gate is the profile parameter `same_principal` (persisted record) or + * the wire declaration read through `wireDeclaresSamePrincipal` — this is wire-shape + * validation for a routing requirement that is profile-record DATA (internal_device + * requires device-pair routing), not a `handshake_type` semantic branch. */ import type { PipelineStep } from '../types' import { ReasonCode } from '../types' import { validateInternalInitiateCapsuleWire } from '../internalPersistence' +import { wireDeclaresSamePrincipal, legacyWireHandshakeType } from '../samePrincipalWire' import { validateInternalCapsuleDeviceIds } from '../../../../../../packages/shared/src/handshake/internalEndpointValidation' export const verifyInternalCapsuleRouting: PipelineStep = { name: 'verify_internal_capsule_routing', execute(ctx) { const { input, handshakeRecord } = ctx - const isInternal = - input.handshake_type === 'internal' || handshakeRecord?.handshake_type === 'internal' - if (!isInternal) { + const isSamePrincipal = + wireDeclaresSamePrincipal(input) || handshakeRecord?.same_principal === true + if (!isSamePrincipal) { return { passed: true } } if (input.capsuleType === 'handshake-initiate') { - if (input.handshake_type !== 'internal') { + if (!wireDeclaresSamePrincipal(input)) { return { passed: true } } // Pass `receiver_pairing_code` through so pairing-code initiates (the new @@ -28,7 +34,7 @@ export const verifyInternalCapsuleRouting: PipelineStep = { // that arrives via the coordination relay (file-import path bypasses the // pipeline and was unaffected, masking the bug). const wire: Record = { - handshake_type: 'internal', + handshake_type: legacyWireHandshakeType(true), sender_device_id: input.sender_device_id, sender_device_role: input.sender_device_role, sender_computer_name: input.sender_computer_name, diff --git a/code/apps/electron-vite-project/electron/main/handshake/steps/ownership.ts b/code/apps/electron-vite-project/electron/main/handshake/steps/ownership.ts index 71e66552d..609b6ce05 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/steps/ownership.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/steps/ownership.ts @@ -1,25 +1,102 @@ -import type { PipelineStep } from '../types' +import type { PipelineStep, PartyIdentity, SSOSession, VerifiedCapsuleInput } from '../types' import { ReasonCode, HandshakeState } from '../types' +import { + fullClaimIdentityMatch, + isPartialIdentityCollision, +} from '@repo/ingestion-core' import { isSameAccountHandshakeEmails } from '../../../../../../packages/shared/src/handshake/receiverEmailValidation' import { computeInternalRoutingKey } from '../internalPersistence' +import { wireDeclaresSamePrincipal } from '../samePrincipalWire' + +function senderClaims(input: VerifiedCapsuleInput): { + iss: string + sub: string + email: string + wrdesk_user_id: string +} { + return { + iss: input.senderIdentity?.iss ?? '', + sub: input.senderIdentity?.sub ?? '', + email: input.senderIdentity?.email ?? input.sender_email ?? '', + wrdesk_user_id: input.senderIdentity?.wrdesk_user_id ?? input.sender_wrdesk_user_id ?? '', + } +} + +/** + * The routing fields (`sender_wrdesk_user_id`, `sender_email`) and the claim + * block (`senderIdentity`) both describe the sender. Production capsules set + * them from the same session; a disagreement means a crafted capsule asserting + * two different senders at once — always an ownership violation. + */ +function senderFieldsConsistent(input: VerifiedCapsuleInput): boolean { + const idWrdesk = (input.senderIdentity?.wrdesk_user_id ?? '').trim() + const routeWrdesk = (input.sender_wrdesk_user_id ?? '').trim() + if (idWrdesk && routeWrdesk && idWrdesk !== routeWrdesk) return false + const idEmail = (input.senderIdentity?.email ?? '').trim().toLowerCase() + const routeEmail = (input.sender_email ?? '').trim().toLowerCase() + if (idEmail && routeEmail && idEmail !== routeEmail) return false + return true +} + +function sessionClaims(session: SSOSession): { + iss: string + sub: string + email: string + wrdesk_user_id: string +} { + return { + iss: session.iss, + sub: session.sub, + email: session.email, + wrdesk_user_id: session.wrdesk_user_id, + } +} + +/** + * Full-claim sender-vs-party comparison [VII.3.8–3.10]. + * + * - `is_party`: every claim the party was bound with matches exactly. + * - `collision`: guard failed but at least one identity claim collided + * (same sub/wrdesk id under a different issuer/email) — spoof indicator, + * must be rejected, never treated as "different principal". + * - `distinct`: no identity overlap at all. + */ +function classifySenderAgainstParty( + input: VerifiedCapsuleInput, + party: PartyIdentity | null | undefined, +): 'is_party' | 'collision' | 'distinct' { + if (!party) return 'distinct' + const result = fullClaimIdentityMatch(senderClaims(input), party) + if (result.ok) return 'is_party' + return isPartialIdentityCollision(result) ? 'collision' : 'distinct' +} export const verifyHandshakeOwnership: PipelineStep = { name: 'verify_handshake_ownership', execute(ctx) { - const { input, handshakeRecord, localUserId, existingHandshakes } = ctx + const { input, handshakeRecord, ssoSession, existingHandshakes, localUserId } = ctx const senderId = input.sender_wrdesk_user_id + if (!senderFieldsConsistent(input)) { + return { passed: false, reason: ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION } + } + if (input.capsuleType === 'handshake-initiate') { - // Same wrdesk_user_id on two devices is only valid for internal (same-account) handshakes. - if (senderId === localUserId) { + // Sender claiming the local principal's identity is only valid for internal + // (same-account) handshakes on a second device. Full-claim comparison: a + // partial collision (e.g. same wrdesk id, different issuer) is a violation. + const selfMatch = fullClaimIdentityMatch(senderClaims(input), sessionClaims(ssoSession)) + if (selfMatch.ok) { if (!isSameAccountHandshakeEmails(input.sender_email, input.receiver_email)) { return { passed: false, reason: ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION } } + } else if (isPartialIdentityCollision(selfMatch)) { + return { passed: false, reason: ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION } } // Same-principal internal: duplicate is keyed by device pair + owner, not email pair // (relationship_id embeds handshake_id so two device-pair handshakes differ on rel id alone). - if (input.handshake_type === 'internal') { + if (wireDeclaresSamePrincipal(input)) { const routeKey = computeInternalRoutingKey( input.sender_wrdesk_user_id, input.sender_device_id ?? undefined, @@ -28,7 +105,7 @@ export const verifyHandshakeOwnership: PipelineStep = { if (routeKey) { const dupByRoute = existingHandshakes.find( h => - h.handshake_type === 'internal' && + h.same_principal === true && (h.state === HandshakeState.PENDING_ACCEPT || h.state === HandshakeState.ACCEPTED || h.state === HandshakeState.ACTIVE) && @@ -62,7 +139,11 @@ export const verifyHandshakeOwnership: PipelineStep = { // For accept: sender must NOT be the initiator unless internal same-account (second device). if (input.capsuleType === 'handshake-accept') { - if (senderId === handshakeRecord.initiator.wrdesk_user_id) { + const vsInitiator = classifySenderAgainstParty(input, handshakeRecord.initiator) + if (vsInitiator === 'collision') { + return { passed: false, reason: ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION } + } + if (vsInitiator === 'is_party') { if (!isSameAccountHandshakeEmails(handshakeRecord.initiator.email, handshakeRecord.receiver_email)) { return { passed: false, reason: ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION } } @@ -70,11 +151,11 @@ export const verifyHandshakeOwnership: PipelineStep = { return { passed: true } } - // For refresh/revoke: sender must be the OTHER party - const isInitiator = senderId === handshakeRecord.initiator.wrdesk_user_id - const isAcceptor = handshakeRecord.acceptor != null && - senderId === handshakeRecord.acceptor.wrdesk_user_id - if (!isInitiator && !isAcceptor) { + // For refresh/revoke/context-sync: sender must be one of the bound parties + // (party selection is legitimate; each candidate match is full-claim exact). + const vsInitiator = classifySenderAgainstParty(input, handshakeRecord.initiator) + const vsAcceptor = classifySenderAgainstParty(input, handshakeRecord.acceptor) + if (vsInitiator !== 'is_party' && vsAcceptor !== 'is_party') { return { passed: false, reason: ReasonCode.HANDSHAKE_OWNERSHIP_VIOLATION } } diff --git a/code/apps/electron-vite-project/electron/main/handshake/topologyAutoWire.ts b/code/apps/electron-vite-project/electron/main/handshake/topologyAutoWire.ts index cd270edc5..a960b90de 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/topologyAutoWire.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/topologyAutoWire.ts @@ -114,7 +114,7 @@ export function autoWireTopologyForHandshake( record: HandshakeRecord, opts?: { db?: unknown }, ): void { - if (record.handshake_type !== 'internal') return + if (record.same_principal !== true) return if (record.state !== HandshakeState.ACTIVE) return if (!record.internal_coordination_identity_complete) return @@ -188,7 +188,7 @@ export function syncTopologyFromActiveHandshakes(db: unknown): void { try { const rows: HandshakeRecord[] = listHandshakeRecords(db as any, { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) for (const record of rows) { if (!record.internal_coordination_identity_complete) continue diff --git a/code/apps/electron-vite-project/electron/main/handshake/types.ts b/code/apps/electron-vite-project/electron/main/handshake/types.ts index d5eef783e..760c33773 100644 --- a/code/apps/electron-vite-project/electron/main/handshake/types.ts +++ b/code/apps/electron-vite-project/electron/main/handshake/types.ts @@ -308,7 +308,13 @@ export interface VerifiedCapsuleInput { preview?: ExecutionCapsule; wrdesk_policy_hash: string; wrdesk_policy_version: string; - /** Internal initiate / accept capsule wire (optional). */ + /** + * LEGACY WIRE COMPAT ONLY (Phase 4, Q9): v2 capsules declare same-principal + * pairing via this wire field. The ONLY permitted read is + * `wireDeclaresSamePrincipal` (samePrincipalWire.ts) — no other code may + * branch on it; the admission situation is the profile-registry parameter + * `same_principal` (internal_device profile). + */ handshake_type?: 'internal' | 'standard' | null; sender_device_id?: string | null; receiver_device_id?: string | null; @@ -400,8 +406,14 @@ export interface HandshakeRecord { context_sync_pending?: boolean; /** AI policy: ai_processing_mode (new) or legacy cloud_ai/internal_ai. Parsed from JSON. */ policy_selections?: { ai_processing_mode?: string } | { cloud_ai?: boolean; internal_ai?: boolean }; - /** Ledger-only: internal (same-account orchestrator) vs standard cross-party handshake */ - handshake_type?: 'internal' | 'standard' | null; + /** + * Phase 4 (Q9): same-principal admission situation, derived at the SINGLE + * persistence boundary (db.ts row adapter) from the profile registry / + * legacy column. Replaces the eliminated `handshake_type` discriminator. + * "Cross-Device" is the UI label of the `internal_device` profile, not a + * separate mechanism. + */ + same_principal?: boolean; initiator_device_name?: string | null; acceptor_device_name?: string | null; initiator_device_role?: 'host' | 'sandbox' | null; @@ -529,6 +541,14 @@ export enum ReasonCode { INTERNAL_ERROR = 'INTERNAL_ERROR', SIGNATURE_INVALID = 'SIGNATURE_INVALID', COUNTERSIGNATURE_INVALID = 'COUNTERSIGNATURE_INVALID', + // Phase 2 — canonical core [VII.3.1, VII.3.5, VII.6.1.3] + CANONICAL_ENVELOPE_INVALID = 'CANONICAL_ENVELOPE_INVALID', + UNKNOWN_CRITICAL_EXTENSION = 'UNKNOWN_CRITICAL_EXTENSION', + NONCE_REPLAY = 'NONCE_REPLAY', + /** Phase 3 [VII.4.2]: unknown profile id / unsupported profile version — fail-closed, no fallback. */ + UNKNOWN_PROFILE = 'UNKNOWN_PROFILE', + /** Phase 3 [VII.4.5, VII.3.2]: profile schema rule violated (attestation presence/absence, signature cardinality). */ + PROFILE_SCHEMA_VIOLATION = 'PROFILE_SCHEMA_VIOLATION', } // ── Pipeline Types ── @@ -595,8 +615,25 @@ export interface HandshakeProcessDenial { pipelineDurationMs: number; } +/** + * Phase 4 (Q1) [IX.3.1]: an inbound initiate capsule that passes the full + * verification chain WITHOUT a consent event produces a staged Connect offer + * — never a relationship row. The record is created only when the one + * pipeline runs again behind the consent gate. + */ +export interface HandshakeProcessStaged { + success: true; + staged: true; + offerId: string; + handshakeRecord: null; + blocksStored: 0; + tierDecision: TierDecision; + pipelineDurationMs: number; +} + export type HandshakeProcessResult = | HandshakeProcessSuccess + | HandshakeProcessStaged | HandshakeProcessDenial; // ── Authorization Result ── diff --git a/code/apps/electron-vite-project/electron/main/ingestion/__tests__/authorization.test.ts b/code/apps/electron-vite-project/electron/main/ingestion/__tests__/authorization.test.ts index ea5e2a73d..5128f1381 100644 --- a/code/apps/electron-vite-project/electron/main/ingestion/__tests__/authorization.test.ts +++ b/code/apps/electron-vite-project/electron/main/ingestion/__tests__/authorization.test.ts @@ -1,231 +1,183 @@ -import { describe, test, expect } from 'vitest' +/** + * Execution Authorization Gate — Phase 5 (V4) per-tap consent model. + * + * Execution grants are deleted: no standing GRANTED_TOOLS set, no + * ACTIVE-handshake blanket authorization. Authorization requires a fresh, + * single-use, Intent-Hash-bound human consent record [VII.10.1, IX.19.2]. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import Database from 'better-sqlite3' import { authorizeToolInvocation } from '../../enforcement/authorizeToolInvocation' import type { ToolInvocationRequest } from '../../enforcement/authorizeToolInvocation' +import { + prepareExecutionConsent, + confirmExecutionConsent, +} from '../../execution/executionConsent' +import { setEvidenceDbProvider } from '../../handshake/evidenceChain' +import { migrateHandshakeTables, insertHandshakeRecord } from '../../handshake/db' +import { HandshakeState } from '../../handshake/types' +import { buildActiveHandshakeRecord } from '../../handshake/__tests__/helpers' + +const HS = 'hs-001' + +let db: InstanceType + +beforeEach(() => { + db = new Database(':memory:') + db.pragma('foreign_keys = ON') + migrateHandshakeTables(db) + insertHandshakeRecord(db, buildActiveHandshakeRecord()) + setEvidenceDbProvider(() => db) +}) -function makeMockDb(records: Record = {}, auditEntries: any[] = []) { - return { - prepare: (sql: string) => ({ - run: (...args: any[]) => { auditEntries.push({ sql, args }) }, - get: (...args: any[]) => { - if (sql.includes('handshakes') && sql.includes('handshake_id') && args.length > 0) { - return records[args[0]] ?? undefined - } - return undefined - }, - all: (...args: any[]) => { - if (sql.includes('handshakes')) { - return Object.values(records) - } - return [] - }, - }), - transaction: (fn: any) => fn, - } -} - -function makeHandshakeRow(overrides?: any) { - return { - handshake_id: 'hs-001', - relationship_id: 'rel-001', - state: 'ACTIVE', - initiator_json: JSON.stringify({ email: 'a@b.com', wrdesk_user_id: 'u-1', iss: 'i', sub: 's' }), - acceptor_json: JSON.stringify({ email: 'c@d.com', wrdesk_user_id: 'u-2', iss: 'i', sub: 's' }), - local_role: 'acceptor', - sharing_mode: 'reciprocal', - reciprocal_allowed: 1, - tier_snapshot_json: JSON.stringify({ claimedTier: null, computedTier: 'free', effectiveTier: 'free', signals: { plan: 'free', hardwareAttestation: null, dnsVerification: null, wrStampStatus: null }, downgraded: false }), - current_tier_signals_json: JSON.stringify({ plan: 'free', hardwareAttestation: null, dnsVerification: null, wrStampStatus: null }), - last_seq_sent: 0, - last_seq_received: 0, - last_capsule_hash_sent: '', - last_capsule_hash_received: 'a'.repeat(64), - effective_policy_json: JSON.stringify({ - allowedScopes: ['*'], - effectiveTier: 'free', - allowsCloudEscalation: false, - allowsExport: false, - onRevocationDeleteBlocks: false, - effectiveExternalProcessing: 'none', - reciprocalAllowed: true, - effectiveSharingModes: ['receive-only', 'reciprocal'], - }), - external_processing: 'none', - created_at: new Date().toISOString(), - activated_at: new Date().toISOString(), - expires_at: new Date(Date.now() + 86400000).toISOString(), - revoked_at: null, - revocation_source: null, - initiator_wrdesk_policy_hash: 'a'.repeat(64), - initiator_wrdesk_policy_version: '1.0', - acceptor_wrdesk_policy_hash: 'b'.repeat(64), - acceptor_wrdesk_policy_version: '1.0', - ...overrides, - } -} +afterEach(() => { + setEvidenceDbProvider(null) + try { db.close() } catch { /* noop */ } +}) function makeRequest(overrides?: Partial): ToolInvocationRequest { return { - handshake_id: 'hs-001', + request_id: 'req-001', + handshake_id: HS, tool_name: 'read-context', parameters: {}, requested_scope: 'test-scope', requested_purpose: 'testing', + origin: 'extension', ...overrides, } } -describe('Execution Authorization Gate', () => { - // Test 15: Handshake inactive → denied +/** Prepare + tap a consent for the exact request; returns the consent id. */ +function consentFor(req: ToolInvocationRequest): string { + const prep = prepareExecutionConsent(db, { + request_id: req.request_id, + handshake_id: req.handshake_id, + tool_name: req.tool_name, + scope_id: req.requested_scope, + purpose_id: req.requested_purpose, + parameters: req.parameters, + origin: req.origin, + }) + const tap = confirmExecutionConsent(db, prep.consent_id, 'local-user-001') + expect(tap.ok).toBe(true) + return prep.consent_id +} + +describe('Execution Authorization Gate (per-tap consent)', () => { test('handshake not found → HANDSHAKE_INACTIVE', () => { - const db = makeMockDb() - const result = authorizeToolInvocation(db, makeRequest()) + const req = makeRequest({ handshake_id: 'hs-missing' }) + const result = authorizeToolInvocation(db, req) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('HANDSHAKE_INACTIVE') - } + if (!result.authorized) expect(result.reason).toBe('HANDSHAKE_INACTIVE') }) - // Test 16: Handshake revoked → denied - test('handshake revoked → HANDSHAKE_REVOKED', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow({ state: 'REVOKED' }) }) - const result = authorizeToolInvocation(db, makeRequest()) + test('handshake revoked → HANDSHAKE_REVOKED (even with tapped consent)', () => { + const req = makeRequest() + const consentId = consentFor(req) + db.prepare(`UPDATE handshakes SET state = ? WHERE handshake_id = ?`).run(HandshakeState.REVOKED, HS) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('HANDSHAKE_REVOKED') - } + if (!result.authorized) expect(result.reason).toBe('HANDSHAKE_REVOKED') }) - // Test 17: Tool not granted → denied - test('unknown tool → TOOL_NOT_GRANTED', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = authorizeToolInvocation(db, makeRequest({ tool_name: 'delete-everything' })) + test('no consent reference → CONSENT_REQUIRED (ACTIVE handshake is never sufficient)', () => { + const result = authorizeToolInvocation(db, makeRequest()) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('TOOL_NOT_GRANTED') - } + if (!result.authorized) expect(result.reason).toBe('CONSENT_REQUIRED') }) - // Test 18: Scope not allowed → denied - test('scope not in policy → SCOPE_NOT_ALLOWED', () => { - const restrictedPolicy = { - allowedScopes: ['allowed-scope'], - effectiveTier: 'free', - allowsCloudEscalation: false, - allowsExport: false, - onRevocationDeleteBlocks: false, - effectiveExternalProcessing: 'none', - reciprocalAllowed: true, - effectiveSharingModes: ['receive-only', 'reciprocal'], - } - const db = makeMockDb({ - 'hs-001': makeHandshakeRow({ - effective_policy_json: JSON.stringify(restrictedPolicy), - }), + test('prepared but untapped consent → CONSENT_NOT_TAPPED (no auto-accept)', () => { + const req = makeRequest() + const prep = prepareExecutionConsent(db, { + request_id: req.request_id, + handshake_id: req.handshake_id, + tool_name: req.tool_name, + scope_id: req.requested_scope, + purpose_id: req.requested_purpose, + parameters: req.parameters, + origin: req.origin, }) - const result = authorizeToolInvocation(db, makeRequest({ requested_scope: 'forbidden-scope' })) + const result = authorizeToolInvocation(db, { ...req, consent_ref: prep.consent_id }) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('SCOPE_NOT_ALLOWED') + if (!result.authorized) expect(result.reason).toBe('CONSENT_NOT_TAPPED') + }) + + test('valid tapped consent → authorized, consent returned', () => { + const req = makeRequest() + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) + expect(result.authorized).toBe(true) + if (result.authorized) { + expect(result.consent.consent_id).toBe(consentId) + expect(result.consent.intent_hash).toMatch(/^[0-9a-f]{64}$/) } }) - // Test 19: Parameters out of constraints → denied - test('oversized parameter → PARAMETER_CONSTRAINT_VIOLATION', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = authorizeToolInvocation(db, makeRequest({ - parameters: { data: 'x'.repeat(1_000_001) }, - })) + test('request diverging from presented preview → INTENT_HASH_MISMATCH deviation [IX.19.2]', () => { + const req = makeRequest({ parameters: { path: '/safe' } }) + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { + ...req, + parameters: { path: '/etc/shadow' }, + consent_ref: consentId, + }) expect(result.authorized).toBe(false) if (!result.authorized) { - expect(result.reason).toBe('PARAMETER_CONSTRAINT_VIOLATION') + expect(result.reason).toBe('INTENT_HASH_MISMATCH') + expect(result.deviation).toBe(true) } }) - // Test 20: Valid authorization → allowed + audit - test('valid request → authorized', () => { - const auditEntries: any[] = [] - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }, auditEntries) - const result = authorizeToolInvocation(db, makeRequest()) + test('any tool name is consentable — there is no standing granted-tools set', () => { + // The old GRANTED_TOOLS allowlist is gone; the consent tap names the exact + // action and is the sole authorization. + const req = makeRequest({ tool_name: 'some-future-tool' }) + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) expect(result.authorized).toBe(true) }) - // Additional: Cloud escalation denied - test('cloud-escalation when policy denies → PURPOSE_MISMATCH', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = authorizeToolInvocation(db, makeRequest({ tool_name: 'cloud-escalation' })) - expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('PURPOSE_MISMATCH') - } - }) - - // Additional: Export denied - test('export-context when policy denies → PURPOSE_MISMATCH', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = authorizeToolInvocation(db, makeRequest({ tool_name: 'export-context' })) + test('oversized parameter → PARAMETER_CONSTRAINT_VIOLATION', () => { + const req = makeRequest({ parameters: { data: 'x'.repeat(1_000_001) } }) + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('PURPOSE_MISMATCH') - } + if (!result.authorized) expect(result.reason).toBe('PARAMETER_CONSTRAINT_VIOLATION') }) - // Additional: Expired handshake - test('expired handshake → HANDSHAKE_INACTIVE', () => { - const db = makeMockDb({ - 'hs-001': makeHandshakeRow({ - expires_at: new Date(Date.now() - 86400000).toISOString(), - }), - }) - const result = authorizeToolInvocation(db, makeRequest()) + test('expired handshake → HANDSHAKE_INACTIVE (defense-in-depth)', () => { + db.prepare(`UPDATE handshakes SET expires_at = ? WHERE handshake_id = ?`).run( + new Date(Date.now() - 86400000).toISOString(), + HS, + ) + const req = makeRequest() + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('HANDSHAKE_INACTIVE') - } + if (!result.authorized) expect(result.reason).toBe('HANDSHAKE_INACTIVE') }) - // Additional: PENDING_ACCEPT state test('pending handshake → HANDSHAKE_INACTIVE', () => { - const db = makeMockDb({ - 'hs-001': makeHandshakeRow({ state: 'PENDING_ACCEPT' }), - }) - const result = authorizeToolInvocation(db, makeRequest()) + db.prepare(`UPDATE handshakes SET state = ? WHERE handshake_id = ?`).run(HandshakeState.PENDING_ACCEPT, HS) + const req = makeRequest() + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('HANDSHAKE_INACTIVE') - } + if (!result.authorized) expect(result.reason).toBe('HANDSHAKE_INACTIVE') }) - // Decision B (B-8.4d-i): Defense-in-depth — expired handshakes are denied at - // authorization regardless of state. Guards against the background expiry process - // running late or a stale state transition leaving the record as ACTIVE past its - // expiry window. - test('state ACTIVE but expires_at past → HANDSHAKE_INACTIVE (defense-in-depth against stale state from background expiry process)', () => { - const db = makeMockDb({ - 'hs-001': makeHandshakeRow({ - state: 'ACTIVE', - expires_at: new Date(Date.now() - 86400000).toISOString(), - }), - }) - const result = authorizeToolInvocation(db, makeRequest()) - expect(result.authorized).toBe(false) - if (!result.authorized) { - expect(result.reason).toBe('HANDSHAKE_INACTIVE') + test('kill switch refuses everything — never restores a consent-free path', () => { + process.env.WRDESK_EXECUTION_CONSENT_TAP = '0' + try { + const req = makeRequest() + const consentId = consentFor(req) + const result = authorizeToolInvocation(db, { ...req, consent_ref: consentId }) + expect(result.authorized).toBe(false) + if (!result.authorized) expect(result.reason).toBe('EXECUTION_DISABLED') + } finally { + delete process.env.WRDESK_EXECUTION_CONSENT_TAP } }) - - // Additional: Wildcard scope allows any - test('wildcard scope allows any requested scope', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = authorizeToolInvocation(db, makeRequest({ - requested_scope: 'any-random-scope', - })) - expect(result.authorized).toBe(true) - }) - - // Additional: semantic-search is a granted tool - test('semantic-search is a granted tool', () => { - const db = makeMockDb({ 'hs-001': makeHandshakeRow() }) - const result = authorizeToolInvocation(db, makeRequest({ tool_name: 'semantic-search' })) - expect(result.authorized).toBe(true) - }) }) diff --git a/code/apps/electron-vite-project/electron/main/ingestion/ipc.ts b/code/apps/electron-vite-project/electron/main/ingestion/ipc.ts index e90a71669..d3a84d689 100644 --- a/code/apps/electron-vite-project/electron/main/ingestion/ipc.ts +++ b/code/apps/electron-vite-project/electron/main/ingestion/ipc.ts @@ -49,10 +49,12 @@ export async function handleIngestionRPC( ): Promise { switch (method) { case 'ingestion.ingest': { - const { rawInput, sourceType, transportMeta } = params as { + const { rawInput, sourceType, transportMeta, formationConsent } = params as { rawInput: RawInput; sourceType: SourceType; transportMeta: TransportMetadata; + /** Phase 4 (Q1): consent gate ref — only the consent flow hands one in. */ + formationConsent?: import('../handshake/formationPipeline').FormationConsentRef; } const result = await processIncomingInput(rawInput, sourceType, transportMeta) @@ -148,6 +150,7 @@ export async function handleIngestionRPC( canonicalValidated, receiverPolicy, ssoSession, + formationConsent ? { formationConsent } : undefined, ) if (!handshakeResult.success) { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/decideInternalInferenceTransport.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/decideInternalInferenceTransport.test.ts index 54bd018ee..dfd5c45af 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/decideInternalInferenceTransport.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/decideInternalInferenceTransport.test.ts @@ -39,7 +39,7 @@ function rolesOk(): HandshakeDerivedRoles { } const hr = { handshake_id: 'h1' } as unknown as HandshakeRecord -const hrInternal = { handshake_id: 'h1', handshake_type: 'internal' } as unknown as HandshakeRecord +const hrInternal = { handshake_id: 'h1', same_principal: true } as unknown as HandshakeRecord const base: Omit< HostAiTransportDeciderInput, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/directP2pReachability.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/directP2pReachability.test.ts index a30d0159a..0ff171c73 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/directP2pReachability.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/directP2pReachability.test.ts @@ -71,7 +71,7 @@ function sandboxToHostRecord(over: Partial = {}): HandshakeReco p2p_endpoint: 'http://10.0.0.2:51249/beap/ingest', local_p2p_auth_token: 'secrettok', counterparty_p2p_token: 'peer-secrettok', - handshake_type: 'internal', + same_principal: true, initiator_device_role: 'sandbox', acceptor_device_role: 'host', acceptor_device_name: 'Workstation', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiCrossDeviceStateMachine.regression.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiCrossDeviceStateMachine.regression.test.ts index 7fa9d80b2..7c1b1bd7f 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiCrossDeviceStateMachine.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiCrossDeviceStateMachine.regression.test.ts @@ -107,7 +107,7 @@ function handshakeBase(over: Partial = {}): HandshakeRecord { initiator_coordination_device_id: 'dev-sand-coord-1', acceptor_coordination_device_id: 'dev-host-coord-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, p2p_endpoint: LAN_PEER, local_p2p_auth_token: 't', counterparty_p2p_token: 'test-bearer-abc123', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiDirectBeapAdPublish.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiDirectBeapAdPublish.test.ts index 3d63b4a95..cab7aa511 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiDirectBeapAdPublish.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiDirectBeapAdPublish.test.ts @@ -89,7 +89,7 @@ const hostRow: HandshakeRecord = { p2p_endpoint: 'http://10.0.0.1:1/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiE2eSandboxToHostSuccess.integration.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiE2eSandboxToHostSuccess.integration.test.ts index b06995993..a5aa63f49 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiE2eSandboxToHostSuccess.integration.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiE2eSandboxToHostSuccess.integration.test.ts @@ -93,7 +93,7 @@ function makeHandshake(over: Partial = {}): HandshakeRecord { p2p_endpoint: LOCAL_SANDBOX_BEAP, local_p2p_auth_token: 'tok-sandbox', counterparty_p2p_token: 'tok-host', - handshake_type: 'internal', + same_principal: true, sharing_mode: null, reciprocal_allowed: false, tier_snapshot: {} as any, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiEffectiveRole.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiEffectiveRole.test.ts index 62555a727..bcf3ecfb4 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiEffectiveRole.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiEffectiveRole.test.ts @@ -42,7 +42,7 @@ const base = (hid: string): HandshakeRecord => p2p_endpoint: 'https://x/beap', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPairingLedger.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPairingLedger.test.ts index 9011a0e75..f557d0fb7 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPairingLedger.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPairingLedger.test.ts @@ -22,7 +22,7 @@ import { function row(overrides: Partial = {}): HandshakeRecord { return { handshake_id: 'hs-1', - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, initiator_device_role: 'host', acceptor_device_role: 'sandbox', @@ -44,7 +44,7 @@ describe('listActiveInternalHandshakesForHostAi', () => { it('returns ACTIVE internal rows only, with no session filter applied', () => { listHandshakeRecordsMock.mockReturnValue([ row({ handshake_id: 'internal-active' }), - row({ handshake_id: 'standard', handshake_type: 'standard' as any }), + row({ handshake_id: 'standard', same_principal: false as any }), row({ handshake_id: 'internal-pending', state: HandshakeState.PENDING_REVIEW }), ]) const out = listActiveInternalHandshakesForHostAi({}) diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPeerRoles.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPeerRoles.test.ts index fe567770e..731ef4367 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPeerRoles.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiInternalPeerRoles.test.ts @@ -70,7 +70,7 @@ function base(over: Partial = {}): HandshakeRecord { p2p_endpoint: 'http://10.0.0.1:1/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiLogContracts.validation.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiLogContracts.validation.test.ts index 2b00f46e0..261474085 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiLogContracts.validation.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiLogContracts.validation.test.ts @@ -41,7 +41,7 @@ function baseRecord(over: Partial = {}): HandshakeRecord { p2p_endpoint: 'http://peer.example/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pSessionSingleOwner.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pSessionSingleOwner.test.ts index 49fa0c6ed..268ac7bad 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pSessionSingleOwner.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pSessionSingleOwner.test.ts @@ -42,7 +42,7 @@ import { resetP2pInferenceFlagsForTests } from '../p2pInferenceFlags' function baseHS() { return { handshake_id: 'hs-so-1', - handshake_type: 'internal' as const, + same_principal: true as const, state: 'ACTIVE' as const, local_role: 'initiator' as const, initiator_device_role: 'sandbox' as const, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pStaleSessionNoReuse.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pStaleSessionNoReuse.test.ts index feabe3534..e049e1012 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pStaleSessionNoReuse.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiP2pStaleSessionNoReuse.test.ts @@ -57,7 +57,7 @@ const OLD_SID = '11111111-1111-4111-8111-111111111111' function baseHS() { return { handshake_id: 'hs-stale-1', - handshake_type: 'internal' as const, + same_principal: true as const, state: 'ACTIVE' as const, local_role: 'initiator' as const, initiator_device_role: 'sandbox' as const, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiPeerLivePresence.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiPeerLivePresence.test.ts index dc017456c..e75921eed 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiPeerLivePresence.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiPeerLivePresence.test.ts @@ -23,14 +23,21 @@ import { tryRecordHostPeerLivePresenceFromPolicyResponse, } from '../hostAiPeerLivePresence' +/** + * Session whose full claim set matches the record parties built by + * `internalRecord()` — 'user-a' ↔ iss-a/sub-a. Under the Phase-1 full-claim + * guard [VII.3.8] a session only "matches" when issuer+subject+email+id all + * line up; an email-only overlap (the old OR-logic) must deny. + */ function sessionForId(id: string): SSOSession { - return { email: `${id}@wrdesk.com`, wrdesk_user_id: `${id}-id`, iss: `iss-${id}`, sub: `sub-${id}` } as SSOSession + const realm = id.split('-').pop() + return { email: `${id}@wrdesk.com`, wrdesk_user_id: `${id}-id`, iss: `iss-${realm}`, sub: `sub-${realm}` } as SSOSession } function internalRecord(overrides: Partial = {}): HandshakeRecord { return { handshake_id: 'hs-live-1', - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, initiator_device_role: 'host', acceptor_device_role: 'sandbox', @@ -126,6 +133,17 @@ describe('assertHostMachineSessionMatchesHandshakeHostParty (§2 per-handshake g expect(assertHostMachineSessionMatchesHandshakeHostParty(r).ok).toBe(true) }) + it('cross-SSO regression [VII.3.8]: same email/wrdesk id under a different issuer is denied', () => { + getCurrentSessionMock.mockReturnValue({ + email: 'user-a@wrdesk.com', + wrdesk_user_id: 'user-a-id', + iss: 'iss-other-realm', + sub: 'sub-other-realm', + } as SSOSession) + const res = assertHostMachineSessionMatchesHandshakeHostParty(internalRecord()) + expect(res.ok).toBe(false) + }) + it('denies a different SSO identity (HOST_AI_PEER_IDENTITY_OFFLINE) — §2', () => { getCurrentSessionMock.mockReturnValue(sessionForId('user-b')) const res = assertHostMachineSessionMatchesHandshakeHostParty(internalRecord()) @@ -136,7 +154,7 @@ describe('assertHostMachineSessionMatchesHandshakeHostParty (§2 per-handshake g it('denies a non-internal handshake (HOST_AI_IDENTITY_INCOMPLETE) — §2', () => { getCurrentSessionMock.mockReturnValue(sessionForId('user-a')) const res = assertHostMachineSessionMatchesHandshakeHostParty( - internalRecord({ handshake_type: 'standard' as any }), + internalRecord({ same_principal: false as any }), ) expect(res.ok).toBe(false) expect((res as { code: string }).code).toBe(InternalInferenceErrorCode.HOST_AI_IDENTITY_INCOMPLETE) diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRouteResolve.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRouteResolve.test.ts index f0154b95e..50603cf51 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRouteResolve.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRouteResolve.test.ts @@ -36,7 +36,7 @@ function baseRecord(over: Partial = {}): HandshakeRecord { p2p_endpoint: 'http://peer.example/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRoutingCorrectness.regression.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRoutingCorrectness.regression.test.ts index 56976e4ce..729623287 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRoutingCorrectness.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiRoutingCorrectness.regression.test.ts @@ -170,7 +170,7 @@ function sandboxToHostRecord(over: Partial = {}): HandshakeReco initiator_coordination_device_id: 'dev-sand-coord-1', acceptor_coordination_device_id: 'dev-host-coord-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, p2p_endpoint: LOCAL_SANDBOX_MVP_DIRECT_BEAP, local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiSealedInferenceRelayResultHandler.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiSealedInferenceRelayResultHandler.test.ts index 051da03d4..de393b69a 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiSealedInferenceRelayResultHandler.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiSealedInferenceRelayResultHandler.test.ts @@ -54,7 +54,7 @@ function party(uid: string) { const sandboxRecord = { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: 'acceptor', initiator: party('u1'), @@ -72,7 +72,7 @@ const sandboxRecord = { const hostRecord = { handshake_id: handshakeId, - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: 'initiator', initiator: party('u1'), diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiStaleDatachannelOpenRecovery.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiStaleDatachannelOpenRecovery.test.ts index 022ca1815..fe66d50a8 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiStaleDatachannelOpenRecovery.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiStaleDatachannelOpenRecovery.test.ts @@ -70,7 +70,7 @@ const HID = 'hs-stale-dc-1' function hostHS() { return { handshake_id: HID, - handshake_type: 'internal' as const, + same_principal: true as const, state: 'ACTIVE' as const, local_role: 'initiator' as const, initiator_device_role: 'host' as const, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiTargetAvailabilityGating.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiTargetAvailabilityGating.test.ts index ac63a6eba..0fcaa550b 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiTargetAvailabilityGating.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiTargetAvailabilityGating.test.ts @@ -58,7 +58,7 @@ function baseInternal( initiator_coordination_device_id: 'dev-sand-1', acceptor_coordination_device_id: 'dev-host-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, p2p_endpoint: p2pEndpoint, ...over, } as HandshakeRecord diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiUnifiedServiceRpcRelay.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiUnifiedServiceRpcRelay.test.ts index abfd4a23a..e1f283f44 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiUnifiedServiceRpcRelay.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostAiUnifiedServiceRpcRelay.test.ts @@ -55,7 +55,7 @@ function makeRecord(partial: { }): HandshakeRecord { return { handshake_id: 'hs-c1', - handshake_type: 'internal', + same_principal: true, state: HandshakeState.ACTIVE, local_role: partial.localRole, peer_x25519_public_key_b64: partial.peer.pubB64, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostInferenceCore.policy.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostInferenceCore.policy.test.ts index 9d089aa03..6a7d27b3c 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostInferenceCore.policy.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/hostInferenceCore.policy.test.ts @@ -108,7 +108,7 @@ function baseRecord(over: Partial): HandshakeRecord { counterparty_p2p_token: 'pt', initiator: parties.initiator, acceptor: parties.acceptor, - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, internal_peer_pairing_code: '123456', @@ -155,7 +155,7 @@ describe('hostInferenceCore policy (non-internal / standard handshakes)', () => test('rejects standard (non-internal) handshake for capabilities', async () => { vi.mocked(getHandshakeRecord).mockReturnValue( - baseRecord({ handshake_type: 'standard' }) as any, + baseRecord({ same_principal: false }) as any, ) const r = await handleInternalInferenceCapabilitiesRequest(capEnvelope, ctx) expect(r.ok).toBe(false) @@ -166,7 +166,7 @@ describe('hostInferenceCore policy (non-internal / standard handshakes)', () => test('rejects when handshake_type is null (not internal service)', async () => { vi.mocked(getHandshakeRecord).mockReturnValue( - baseRecord({ handshake_type: null as any }) as any, + baseRecord({ same_principal: null as any }) as any, ) const r = await handleInternalInferenceCapabilitiesRequest(capEnvelope, ctx) expect(r.ok).toBe(false) diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/inferenceHandshakeTrust.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/inferenceHandshakeTrust.test.ts index df741d638..90b7dae5a 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/inferenceHandshakeTrust.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/inferenceHandshakeTrust.test.ts @@ -106,7 +106,7 @@ function happyHandshakeRecord(over: Partial = {}): HandshakeRec initiator_coordination_device_id: 'dev-sand-coord-1', acceptor_coordination_device_id: 'dev-host-coord-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, p2p_endpoint: LAN_PEER, local_p2p_auth_token: 't', counterparty_p2p_token: 'test-bearer-abc123', @@ -148,17 +148,17 @@ describe('inferenceDirectHttpTrust', () => { expect(r.normalizedUrl).toBeNull() }) - it('handshake_type_not_internal', () => { + it('record_not_same_principal', () => { const r = inferenceDirectHttpTrust({ handshakeRecord: happyHandshakeRecord({ - handshake_type: 'standard', + same_principal: false, }), roles: happyRoles, counterpartyP2pToken: 'test-bearer-abc123', localBeapEndpoint: LOCAL_BEAP_OTHER, }) expect(r.trusted).toBe(false) - expect(r.reason).toBe('handshake_type_not_internal') + expect(r.reason).toBe('record_not_same_principal') expect(r.normalizedUrl).toBeNull() }) @@ -469,7 +469,7 @@ describe('decideInternalInferenceTransport — inference trust wiring', () => { operationContext: 'capabilities', db: {}, handshakeRecord: wiringRecord({ - handshake_type: 'standard', + same_principal: false, counterparty_p2p_token: 'bearer-std', }), featureFlags: getP2pInferenceFlags(), diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInference.directHost.regression.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInference.directHost.regression.test.ts index f519c4e2e..6b6211581 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInference.directHost.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInference.directHost.regression.test.ts @@ -104,7 +104,7 @@ function defaultRecord(over: Partial = {}): HandshakeRecord { p2p_endpoint: 'http://10.0.0.2:51249/beap/ingest', local_p2p_auth_token: 'tok', counterparty_p2p_token: 'peer-tok', - handshake_type: 'internal', + same_principal: true, initiator_device_role: 'host', acceptor_device_role: 'sandbox', initiator_coordination_device_id: 'dev-host-1', @@ -279,7 +279,7 @@ describe('direct Host inference — authorization (Host inbound)', () => { it('rejects external (non-internal) handshake', async () => { getHandshakeRecord.mockReturnValue( - defaultRecord({ handshake_type: 'standard' as any, initiator: party('a'), acceptor: party('b') }), + defaultRecord({ same_principal: false as any, initiator: party('a'), acceptor: party('b') }), ) const r: { status?: number } = {} const res = { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInferenceService.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInferenceService.test.ts index 0c17b205d..4dbeb99c0 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInferenceService.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/internalInferenceService.test.ts @@ -93,7 +93,7 @@ function defaultRecord(over: Partial): HandshakeRecord { p2p_endpoint: 'http://10.0.0.2:51249/beap/ingest', local_p2p_auth_token: 'tok', counterparty_p2p_token: 'peer-tok', - handshake_type: 'internal', + same_principal: true, initiator_device_role: 'host', acceptor_device_role: 'sandbox', initiator_coordination_device_id: 'dev-host-1', @@ -105,7 +105,7 @@ function defaultRecord(over: Partial): HandshakeRecord { describe('internal inference policy', () => { it('rejects non-internal', () => { - const r = defaultRecord({ handshake_type: 'standard' as any }) + const r = defaultRecord({ same_principal: false as any }) const ar = assertRecordForServiceRpc(r) expect(ar.ok).toBe(false) if (!ar.ok) expect(ar.code).toBe(InternalInferenceErrorCode.POLICY_FORBIDDEN) @@ -350,7 +350,7 @@ describe('host dispatch with mocks', () => { it('returns 403 for external (standard) record', async () => { getHandshakeRecord.mockReturnValue( defaultRecord({ - handshake_type: 'standard' as any, + same_principal: false as any, initiator: party('a'), acceptor: party('b'), }), diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listHostCapabilities.hostAiRoute.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listHostCapabilities.hostAiRoute.test.ts index 7f5ff1c9c..3e026b5a3 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listHostCapabilities.hostAiRoute.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listHostCapabilities.hostAiRoute.test.ts @@ -102,7 +102,7 @@ function baseRecord(over: Partial = {}): HandshakeRecord { initiator_coordination_device_id: 'dev-sand-1', acceptor_coordination_device_id: 'dev-host-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, p2p_endpoint: 'https://relay.example/beap/ingest/relay?x=1', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listInferenceTargets.step8.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listInferenceTargets.step8.test.ts index edd81f9cf..4518dd92f 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listInferenceTargets.step8.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/listInferenceTargets.step8.test.ts @@ -71,10 +71,10 @@ vi.mock('../../handshake/ipc', () => ({ })) const listHandshakeRecordsMock = vi.fn< - (db: unknown, filter: { state?: string; handshake_type?: string }) => HandshakeRecord[] + (db: unknown, filter: { state?: string; same_principal?: boolean }) => HandshakeRecord[] >() vi.mock('../../handshake/db', () => ({ - listHandshakeRecords: (db: unknown, filter: { state?: string; handshake_type?: string }) => + listHandshakeRecords: (db: unknown, filter: { state?: string; same_principal?: boolean }) => listHandshakeRecordsMock(db, filter), })) @@ -268,7 +268,7 @@ function activeInternalSandboxToHost(over: Partial = {}): Hands p2p_endpoint: 'http://192.168.1.10:51249/beap/ingest', local_p2p_auth_token: 'tok', counterparty_p2p_token: 'peer-tok', - handshake_type: 'internal', + same_principal: true, sharing_mode: null, reciprocal_allowed: false, tier_snapshot: {} as any, @@ -410,7 +410,7 @@ describe('STEP 8 — listInferenceTargets / target discovery', () => { it('external (non-internal) handshake row is ignored', async () => { isSandboxModeMock.mockReturnValue(true) const ext = activeInternalSandboxToHost({ - handshake_type: 'standard' as any, + same_principal: false as any, }) listHandshakeRecordsMock.mockReturnValue([ext]) const r = await listSandboxHostInternalInferenceTargets() @@ -979,7 +979,7 @@ describe('STEP 8 — Production safety (unit contracts)', () => { it('(4) standard/external handshake: no Host AI target', async () => { isSandboxModeMock.mockReturnValue(true) - listHandshakeRecordsMock.mockReturnValue([activeInternalSandboxToHost({ handshake_type: 'standard' as any })]) + listHandshakeRecordsMock.mockReturnValue([activeInternalSandboxToHost({ same_principal: false as any })]) const r = await listSandboxHostInternalInferenceTargets() expect(r.targets).toHaveLength(0) }) @@ -990,7 +990,7 @@ describe('STEP 8 — Production safety (unit contracts)', () => { const { resetP2pInferenceFlagsForTests } = await import('../p2pInferenceFlags') resetP2pInferenceFlagsForTests() isSandboxModeMock.mockReturnValue(true) - listHandshakeRecordsMock.mockReturnValue([activeInternalSandboxToHost({ handshake_type: 'standard' as any })]) + listHandshakeRecordsMock.mockReturnValue([activeInternalSandboxToHost({ same_principal: false as any })]) const r = await listSandboxHostInternalInferenceTargets() expect(r.targets).toHaveLength(0) vi.unstubAllEnvs() @@ -1244,7 +1244,7 @@ describe('STEP 10 — named regression (main: listSandboxHostInternalInferenceTa it('(7) external (non-internal) handshake: no Host AI target', async () => { isSandboxModeMock.mockReturnValue(true) listHandshakeRecordsMock.mockReturnValue([ - activeInternalSandboxToHost({ handshake_type: 'standard' as any }), + activeInternalSandboxToHost({ same_principal: false as any }), ]) const r = await listSandboxHostInternalInferenceTargets() expect(r.targets).toEqual([]) diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.roleGate.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.roleGate.test.ts index 2bf1d24bd..84473c75f 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.roleGate.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.roleGate.test.ts @@ -70,7 +70,7 @@ function recordSandboxInitiator(): HandshakeRecord { initiator_coordination_device_id: 'dev-sand-1', acceptor_coordination_device_id: 'dev-host-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, p2p_endpoint: 'https://relay.example/beap/x', } as HandshakeRecord } diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.sandboxServiceRpc.regression.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.sandboxServiceRpc.regression.test.ts new file mode 100644 index 000000000..f88e0562f --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pDcCapabilities.sandboxServiceRpc.regression.test.ts @@ -0,0 +1,175 @@ +/** + * Regression: sandbox inbound capabilities responses must pass + * assertRecordForServiceRpc (not role-derive alone). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HandshakeState, type HandshakeRecord, type PartyIdentity } from '../../handshake/types' +import { + clearPendingP2pCapabilitiesForTests, + handleP2pDcInferenceCapabilitiesAsSandbox, +} from '../p2pDc/p2pDcCapabilities' + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/p2p-caps-sandbox-rpc', getAppPath: () => '/tmp' }, +})) + +const getInstanceIdMock = vi.hoisted(() => vi.fn(() => 'dev-sand-1')) +vi.mock('../../orchestrator/orchestratorModeStore', async (importOriginal) => { + const a = await importOriginal() + return { ...a, getInstanceId: () => getInstanceIdMock() } +}) + +const getHandshakeRecordMock = vi.hoisted(() => vi.fn()) +vi.mock('../../handshake/db', () => ({ + getHandshakeRecord: (...a: unknown[]) => getHandshakeRecordMock(...a), +})) + +const getLedgerDbMock = vi.hoisted(() => vi.fn(() => ({ _ledger: true }))) +vi.mock('../../handshake/ledger', () => ({ + getLedgerDb: () => getLedgerDbMock(), +})) + +vi.mock('../p2pSession/p2pInferenceSessionManager', () => ({ + getSessionState: () => null, +})) + +function party(uid = 'u1'): PartyIdentity { + return { email: 'a@a.com', wrdesk_user_id: uid, iss: 'i', sub: 's' } +} + +/** Coordination ids map local instance to sandbox; derive alone would accept. */ +function sandboxLedgerRow(overrides: Partial = {}): HandshakeRecord { + return { + handshake_id: 'hs-caps-1', + relationship_id: 'r', + state: HandshakeState.ACTIVE, + same_principal: true, + local_role: 'initiator', + initiator: party(), + acceptor: party(), + sharing_mode: null, + reciprocal_allowed: true, + tier_snapshot: {} as any, + current_tier_signals: {} as any, + last_seq_sent: 0, + last_seq_received: 0, + last_capsule_hash_sent: 'a', + last_capsule_hash_received: 'b', + effective_policy: {} as any, + external_processing: 'none' as any, + created_at: '2020-01-01', + activated_at: '2020-01-01', + expires_at: null, + revoked_at: null, + revocation_source: null, + initiator_wrdesk_policy_hash: 'h', + initiator_wrdesk_policy_version: '1', + acceptor_wrdesk_policy_hash: null, + acceptor_wrdesk_policy_version: null, + initiator_context_commitment: null, + acceptor_context_commitment: null, + initiator_device_role: 'sandbox', + acceptor_device_role: 'host', + initiator_device_name: 'S', + acceptor_device_name: 'H', + initiator_coordination_device_id: 'dev-sand-1', + acceptor_coordination_device_id: 'dev-host-1', + internal_coordination_identity_complete: true, + handshake_type: 'internal', + p2p_endpoint: 'https://relay.example/beap/x', + ...overrides, + } as HandshakeRecord +} + +describe('handleP2pDcInferenceCapabilitiesAsSandbox — service-RPC eligibility', () => { + afterEach(() => { + clearPendingP2pCapabilitiesForTests() + vi.clearAllMocks() + getInstanceIdMock.mockReturnValue('dev-sand-1') + getLedgerDbMock.mockReturnValue({ _ledger: true }) + }) + + it('rejects when ledger role would be sandbox but assertRecordForServiceRpc fails (not ACTIVE)', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + getHandshakeRecordMock.mockReturnValue( + sandboxLedgerRow({ state: HandshakeState.ACCEPTED }), + ) + const consumed = handleP2pDcInferenceCapabilitiesAsSandbox('sid-1', 'hs-caps-1', { + type: 'inference_capabilities_result', + request_id: 'r1', + handshake_id: 'hs-caps-1', + session_id: 'sid-1', + models: [], + }) + expect(consumed).toBe(false) + const reject = log.mock.calls + .map((c) => String(c[0])) + .find((line) => line.includes('HOST_AI_CAPS_RESPONSE_REJECT') && line.includes('service_rpc_ineligible')) + expect(reject).toBeTruthy() + log.mockRestore() + }) + + it('rejects when identity incomplete even if coordination ids map to sandbox', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + getHandshakeRecordMock.mockReturnValue( + sandboxLedgerRow({ internal_coordination_identity_complete: false }), + ) + const consumed = handleP2pDcInferenceCapabilitiesAsSandbox('sid-1', 'hs-caps-1', { + type: 'inference_error', + request_id: 'r2', + handshake_id: 'hs-caps-1', + session_id: 'sid-1', + code: 'x', + }) + expect(consumed).toBe(false) + const reject = log.mock.calls + .map((c) => String(c[0])) + .find((line) => line.includes('service_rpc_ineligible')) + expect(reject).toBeTruthy() + log.mockRestore() + }) + + it('rejects cross-principal internal rows (service RPC ineligible)', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + getHandshakeRecordMock.mockReturnValue( + sandboxLedgerRow({ + initiator: party('u1'), + acceptor: party('u2'), + }), + ) + const consumed = handleP2pDcInferenceCapabilitiesAsSandbox('sid-1', 'hs-caps-1', { + type: 'inference_capabilities_result', + request_id: 'r3', + handshake_id: 'hs-caps-1', + session_id: 'sid-1', + models: [], + }) + expect(consumed).toBe(false) + expect( + log.mock.calls.some((c) => String(c[0]).includes('service_rpc_ineligible')), + ).toBe(true) + log.mockRestore() + }) + + it('passes service-RPC gate for eligible sandbox (does not reject as service_rpc_ineligible)', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + getHandshakeRecordMock.mockReturnValue(sandboxLedgerRow()) + // No pending correlation → still false, but must not be service_rpc_ineligible. + const consumed = handleP2pDcInferenceCapabilitiesAsSandbox('sid-1', 'hs-caps-1', { + type: 'inference_error', + request_id: 'missing-pending', + handshake_id: 'hs-caps-1', + session_id: 'sid-1', + code: 'x', + }) + expect(consumed).toBe(false) + expect( + log.mock.calls.some((c) => String(c[0]).includes('service_rpc_ineligible')), + ).toBe(false) + expect( + log.mock.calls.some((c) => String(c[0]).includes('no_pending_or_unknown_correlation')), + ).toBe(true) + log.mockRestore() + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pEndpointRepair.hostAi.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pEndpointRepair.hostAi.test.ts index a27daab8b..d8b3338a7 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pEndpointRepair.hostAi.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/p2pEndpointRepair.hostAi.test.ts @@ -98,7 +98,7 @@ function relayRow(hid: string): HandshakeRecord { p2p_endpoint: 'https://coord.example/beap/ingest/relay?x=1', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.electronMain.transportAndHostPolicy.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.electronMain.transportAndHostPolicy.test.ts index 5df3f6c44..350d1e436 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.electronMain.transportAndHostPolicy.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.electronMain.transportAndHostPolicy.test.ts @@ -180,7 +180,7 @@ describe('Phase 11 — Host ingest policy', () => { p2p_endpoint: 'http://10.0.0.2:1/beap/ingest', local_p2p_auth_token: 'tok', counterparty_p2p_token: 'peer-tok', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'H', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.p2pSessionManager.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.p2pSessionManager.test.ts index 7e59df794..2a9ef50da 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.p2pSessionManager.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase11.p2pSessionManager.test.ts @@ -82,7 +82,7 @@ function baseHostRecord(over: Partial = {}): HandshakeRecord { p2p_endpoint: 'http://10.0.0.2:1/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'H', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase3SealedBoundary.regression.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase3SealedBoundary.regression.test.ts index bb729be12..1c82e7f29 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase3SealedBoundary.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/phase3SealedBoundary.regression.test.ts @@ -17,7 +17,7 @@ vi.mock('../../handshake/db', () => ({ getHandshakeRecord: vi.fn(() => ({ handshake_id: 'hs-sealed', state: 'ACTIVE', - handshake_type: 'internal', + same_principal: true, local_device_id: 'sbx-dev', peer_device_id: 'host-dev', })), @@ -29,7 +29,7 @@ vi.mock('../policy', () => ({ record: { handshake_id: 'hs-sealed', state: 'ACTIVE', - handshake_type: 'internal', + same_principal: true, }, })), assertSandboxRequestToHost: vi.fn(() => ({ ok: true })), diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInference.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInference.test.ts index e6874db32..0faeaa80f 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInference.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInference.test.ts @@ -49,7 +49,7 @@ function baseRecord(over: Partial): HandshakeRecord { p2p_endpoint: 'http://10.0.0.1:1/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'H', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInferencePolicyGetRole.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInferencePolicyGetRole.test.ts index f78ca1b9f..67fb7edba 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInferencePolicyGetRole.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/policy.internalInferencePolicyGetRole.test.ts @@ -47,7 +47,7 @@ function base(): HandshakeRecord { counterparty_p2p_token: 'pt', initiator: { email: 'a@a', wrdesk_user_id: 'u1', iss: 'i', sub: 's' }, acceptor: { email: 'a@a', wrdesk_user_id: 'u1', iss: 'i', sub: 's' }, - handshake_type: 'internal' as any, + same_principal: true as any, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, internal_peer_pairing_code: '123456', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/relayP2pSignalHandler.republishRequest.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/relayP2pSignalHandler.republishRequest.test.ts index db95f9496..fcde9fdde 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/relayP2pSignalHandler.republishRequest.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/relayP2pSignalHandler.republishRequest.test.ts @@ -80,7 +80,7 @@ function hostSideRow(hid: string): HandshakeRecord { p2p_endpoint: 'https://relay.example/beap/capsule', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/resolveSandboxInferenceTarget.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/resolveSandboxInferenceTarget.test.ts index e45e04046..25df4a6cf 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/resolveSandboxInferenceTarget.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/resolveSandboxInferenceTarget.test.ts @@ -43,7 +43,7 @@ vi.mock('../../handshake/db', () => ({ getHandshakeRecord: vi.fn(() => ({ handshake_id: 'hs-a', state: 'ACTIVE', - handshake_type: 'internal', + same_principal: true, internal_coordination_identity_complete: true, initiator_coordination_device_id: 'dev-sbx', acceptor_coordination_device_id: 'dev-host', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandboxHostAiDirectBeapAdRequest.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandboxHostAiDirectBeapAdRequest.test.ts index 494ab90c6..c0f4e187e 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandboxHostAiDirectBeapAdRequest.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandboxHostAiDirectBeapAdRequest.test.ts @@ -121,7 +121,7 @@ function sandboxHostRow(hid: string): HandshakeRecord { p2p_endpoint: 'http://192.168.0.5:9/beap/ingest', local_p2p_auth_token: 't', counterparty_p2p_token: 'pt', - handshake_type: 'internal', + same_principal: true, internal_coordination_repair_needed: false, internal_coordination_identity_complete: true, initiator_device_name: 'S', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandbox_lists_remote_ollama_models_even_when_beap_endpoint_missing.regression.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandbox_lists_remote_ollama_models_even_when_beap_endpoint_missing.regression.test.ts index 68f7b8e70..6fe82cf0b 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandbox_lists_remote_ollama_models_even_when_beap_endpoint_missing.regression.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/sandbox_lists_remote_ollama_models_even_when_beap_endpoint_missing.regression.test.ts @@ -91,10 +91,10 @@ vi.mock('../../handshake/ipc', () => ({ })) const listHandshakeRecordsMock = vi.fn< - (db: unknown, filter: { state?: string; handshake_type?: string }) => HandshakeRecord[] + (db: unknown, filter: { state?: string; same_principal?: boolean }) => HandshakeRecord[] >() vi.mock('../../handshake/db', () => ({ - listHandshakeRecords: (db: unknown, filter: { state?: string; handshake_type?: string }) => + listHandshakeRecords: (db: unknown, filter: { state?: string; same_principal?: boolean }) => listHandshakeRecordsMock(db, filter), })) @@ -243,7 +243,7 @@ function handshakeBeapPoisonedSandboxLedgerNoPeerAd(): HandshakeRecord { initiator_coordination_device_id: 'dev-sand-coord-1', acceptor_coordination_device_id: 'dev-host-coord-1', internal_coordination_identity_complete: true, - handshake_type: 'internal', + same_principal: true, /** Poisoned MVP row — equals local MVP BEAP; no peer-Host verified BEAP ⇒ peer_host_endpoint_missing when no relay ad */ p2p_endpoint: LOCAL_BEAP_OTHER, local_p2p_auth_token: 'tok-local', diff --git a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/serviceRpcGatesAndLifecycle.rig.test.ts b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/serviceRpcGatesAndLifecycle.rig.test.ts index d23ab94e5..c4a3c0c18 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/__tests__/serviceRpcGatesAndLifecycle.rig.test.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/__tests__/serviceRpcGatesAndLifecycle.rig.test.ts @@ -29,7 +29,7 @@ import type { HandshakeRecord } from '../../handshake/types' /** Minimal record carrying only the fields `assertRecordForServiceRpc` inspects. */ function record(overrides: Partial = {}): HandshakeRecord { return { - handshake_type: 'internal', + same_principal: true, state: 'ACTIVE', initiator: { wrdesk_user_id: 'same-user' }, acceptor: { wrdesk_user_id: 'same-user' }, @@ -65,7 +65,7 @@ describe('service-RPC access gates (RemoteHandshakeExecutor template)', () => { }) it('REJECTS a non-internal handshake → POLICY_FORBIDDEN', () => { - const g = assertRecordForServiceRpc(record({ handshake_type: 'normal' } as Partial)) + const g = assertRecordForServiceRpc(record({ same_principal: false } as Partial)) expect(g.ok).toBe(false) expect((g as { code: string }).code).toBe(InternalInferenceErrorCode.POLICY_FORBIDDEN) }) diff --git a/code/apps/electron-vite-project/electron/main/internalInference/chatWithContextRagOllamaGeneration.ts b/code/apps/electron-vite-project/electron/main/internalInference/chatWithContextRagOllamaGeneration.ts index e5f201455..6183779c6 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/chatWithContextRagOllamaGeneration.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/chatWithContextRagOllamaGeneration.ts @@ -101,6 +101,8 @@ type GenerateChatOpts = { stream?: boolean send?: (channel: string, payload: unknown) => void temperature?: number + /** Art. 50: filled at generation boundary; callers must not remint. */ + provenanceOut?: { value?: import('../../../../../packages/shared/src/aiProvenance').AiProvenance } } /** Minimal provider shape used by handshake RAG. */ @@ -133,6 +135,7 @@ export async function runOllamaGenerateChatWithSandboxRouting( stream, send: stream ? send : undefined, ...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}), + ...(opts.provenanceOut ? { provenanceOut: opts.provenanceOut } : {}), }) if (provider.id && provider.id !== 'ollama') { @@ -183,6 +186,9 @@ export async function runOllamaGenerateChatWithSandboxRouting( throw new Error(msg) } const text = r.output + if (opts.provenanceOut && r.provenance) { + opts.provenanceOut.value = r.provenance + } if (stream && send) { send('handshake:chatStreamToken', { token: text }) } diff --git a/code/apps/electron-vite-project/electron/main/internalInference/directP2pReachability.ts b/code/apps/electron-vite-project/electron/main/internalInference/directP2pReachability.ts index d2e9747c2..b5f015c97 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/directP2pReachability.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/directP2pReachability.ts @@ -240,7 +240,7 @@ export async function listHostToSandboxDirectReachabilityRows(): Promise[0], { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) - return rows.filter((r) => r.handshake_type === 'internal' && r.state === HandshakeState.ACTIVE) + return rows.filter((r) => r.same_principal === true && r.state === HandshakeState.ACTIVE) } /** Any ACTIVE internal row proves local Host with peer Sandbox. */ diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostAiPeerLivePresence.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostAiPeerLivePresence.ts index bb9d9665d..50165e6c7 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostAiPeerLivePresence.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostAiPeerLivePresence.ts @@ -3,8 +3,8 @@ * Fail-closed — stale ledger hydration, ODL tag cache hits, and synthetic probes do not substitute. */ +import { fullClaimIdentityMatch, samePrincipalFullClaim } from '@repo/ingestion-core' import { getHandshakeRecord } from '../handshake/db' -import { sessionMatchesParty } from '../handshake/handshakeAccountIsolation' import { getCurrentSession } from '../handshake/ipc' import type { HandshakeRecord, PartyIdentity, SSOSession } from '../handshake/types' import { getHandshakeDbForInternalInference } from './dbAccess' @@ -53,15 +53,10 @@ export function partyIdentityMatchesExpected( expected: PartyIdentity | null | undefined, ): boolean { if (!actual || !expected) return false - return sessionMatchesParty( - { - email: actual.email ?? '', - wrdesk_user_id: actual.wrdesk_user_id, - iss: actual.iss, - sub: actual.sub, - }, - expected, - ) + // Both sides can be partial attestations (wire publisher identity carries no + // email) — symmetric full-claim comparison: every shared claim must match, + // at least one identifying claim must overlap, no OR-logic. + return samePrincipalFullClaim(actual, expected).ok } export function publisherIdentityFromWireFields(raw: { @@ -196,12 +191,13 @@ export function assertHostMachineSessionMatchesHandshakeHostParty( // and more robust after the host/sandbox process-split — to match the session against EITHER party // rather than only the host-role party (whose identity JSON can be incomplete on one ledger copy // post-split, which previously produced spurious HOST_AI_IDENTITY_INCOMPLETE / *_OFFLINE denials). - if (record.handshake_type !== 'internal' || !handshakeSamePrincipal(record)) { + if (record.same_principal !== true || !handshakeSamePrincipal(record)) { return { ok: false, code: InternalInferenceErrorCode.HOST_AI_IDENTITY_INCOMPLETE } } + const sessionParty = partyIdentityFromSession(session) const matchesEitherParty = - sessionMatchesParty(session, record.initiator) || - (record.acceptor != null && sessionMatchesParty(session, record.acceptor)) + fullClaimIdentityMatch(sessionParty, record.initiator).ok || + (record.acceptor != null && fullClaimIdentityMatch(sessionParty, record.acceptor).ok) if (!matchesEitherParty) { return { ok: false, code: InternalInferenceErrorCode.HOST_AI_PEER_IDENTITY_OFFLINE } } diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostAiProviderAdvertisementLog.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostAiProviderAdvertisementLog.ts index 9c8d92878..5b107963e 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostAiProviderAdvertisementLog.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostAiProviderAdvertisementLog.ts @@ -196,7 +196,7 @@ export async function buildHostAiProviderAdvertisementPayload(input: { let diagnosticPublishHandshakeId: string | null = null if (dbProv) { try { - const rows = listHandshakeRecords(dbProv as any, { state: HandshakeState.ACTIVE, handshake_type: 'internal' }) + const rows = listHandshakeRecords(dbProv as any, { state: HandshakeState.ACTIVE, same_principal: true }) for (const r0 of rows) { const eff = getEffectiveHostAiRoleForHandshake(r0, currentId, String(mode)) if (eff.can_publish_host_endpoint) { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostAiRemoteInferencePolicyResolve.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostAiRemoteInferencePolicyResolve.ts index 8c6bc5a17..e9bf9f1e1 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostAiRemoteInferencePolicyResolve.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostAiRemoteInferencePolicyResolve.ts @@ -55,7 +55,7 @@ function internalHostPairingDiagnosticFlags(db: unknown): { try { rows = listHandshakeRecords(db as Parameters[0], { state: HandshakeState.ACTIVE, - handshake_type: 'internal', + same_principal: true, }) } catch { return { samePrincipalHostPairing: false, internalIdentityComplete: false } diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayHandler.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayHandler.ts index 1611450b1..d3354a140 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayHandler.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayHandler.ts @@ -153,6 +153,7 @@ export async function tryHandleHostAiSealedInferenceRequestRelayCapsule( model: wire.model, output: wire.output, duration_ms: wire.duration_ms, + ...(wire.provenance !== undefined ? { provenance: wire.provenance } : {}), }) if (!assertInferencePayloadWithinCapsuleLimit(outputJson)) { outcome = { @@ -178,6 +179,7 @@ export async function tryHandleHostAiSealedInferenceRequestRelayCapsule( model: wire.model, output: wire.output, duration_ms: wire.duration_ms, + ...(wire.provenance !== undefined ? { provenance: wire.provenance } : {}), } } } else { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayResultHandler.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayResultHandler.ts index e4aa7eeea..94ce224d6 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayResultHandler.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayResultHandler.ts @@ -94,6 +94,7 @@ export async function tryHandleHostAiSealedInferenceResultRelayCapsule( output: wire.output, model: wire.model, duration_ms: wire.duration_ms, + ...(wire.provenance !== undefined ? { provenance: wire.provenance } : {}), } } else { pr = { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayWire.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayWire.ts index 2d511679e..4482087a2 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayWire.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostAiSealedInferenceRelayWire.ts @@ -3,6 +3,8 @@ * Carried inside sealed_service_rpc_v1 envelopes (X25519+HKDF+AES-256-GCM). * INV-ENCRYPT: prompt/completion lives ONLY inside ciphertext — relay sees routing + opaque blob. */ +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' +import { isAiProvenance } from '../../../../../packages/shared/src/aiProvenance' export const HOST_AI_INFERENCE_REQUEST_INNER_TYPE = 'host_ai_inference_request_v1' as const export const HOST_AI_INFERENCE_RESULT_INNER_TYPE = 'host_ai_inference_result_v1' as const @@ -33,6 +35,8 @@ export interface HostAiInferenceResultRelayWire { readonly model: string readonly output: string readonly duration_ms: number + /** Art. 50 AI provenance — optional, absent on older Host peers. */ + readonly provenance?: AiProvenance } export interface HostAiInferenceErrorRelayWire { @@ -121,20 +125,19 @@ export function parseHostAiInferenceResultOrErrorFromPlaintext( return { ok: false, message: 'missing request_id' } } if (o.type === HOST_AI_INFERENCE_RESULT_INNER_TYPE) { - return { - ok: true, - wire: { - type: HOST_AI_INFERENCE_RESULT_INNER_TYPE, - schema_version: HOST_AI_INFERENCE_RELAY_SCHEMA_VERSION, - request_id: rid, - handshake_id: typeof o.handshake_id === 'string' ? o.handshake_id.trim() : '', - sender_device_id: typeof o.sender_device_id === 'string' ? o.sender_device_id.trim() : '', - receiver_device_id: typeof o.receiver_device_id === 'string' ? o.receiver_device_id.trim() : '', - model: typeof o.model === 'string' ? o.model : '', - output: typeof o.output === 'string' ? o.output : '', - duration_ms: typeof o.duration_ms === 'number' ? o.duration_ms : 0, - }, + const parsed: HostAiInferenceResultRelayWire = { + type: HOST_AI_INFERENCE_RESULT_INNER_TYPE, + schema_version: HOST_AI_INFERENCE_RELAY_SCHEMA_VERSION, + request_id: rid, + handshake_id: typeof o.handshake_id === 'string' ? o.handshake_id.trim() : '', + sender_device_id: typeof o.sender_device_id === 'string' ? o.sender_device_id.trim() : '', + receiver_device_id: typeof o.receiver_device_id === 'string' ? o.receiver_device_id.trim() : '', + model: typeof o.model === 'string' ? o.model : '', + output: typeof o.output === 'string' ? o.output : '', + duration_ms: typeof o.duration_ms === 'number' ? o.duration_ms : 0, + ...(isAiProvenance(o.provenance) ? { provenance: o.provenance } : {}), } + return { ok: true, wire: parsed } } return { ok: true, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/hostInferenceExecute.ts b/code/apps/electron-vite-project/electron/main/internalInference/hostInferenceExecute.ts index 070d38dff..a6ebeb7c6 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/hostInferenceExecute.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/hostInferenceExecute.ts @@ -274,6 +274,7 @@ export async function runHostInternalInference( }, } } + // Provenance was attached+logged exactly once in runInternalHostLocalLlmInference. return { wire: { type: 'internal_inference_result', @@ -288,6 +289,7 @@ export async function runHostInternalInference( output: out.text, usage: out.usage, duration_ms: out.durationMs, + ...(out.provenance !== undefined ? { provenance: out.provenance } : {}), }, log: { model: out.model, diff --git a/code/apps/electron-vite-project/electron/main/internalInference/internalP2pHandshakeInspect.ts b/code/apps/electron-vite-project/electron/main/internalInference/internalP2pHandshakeInspect.ts index 151aba9c9..cc3e51899 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/internalP2pHandshakeInspect.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/internalP2pHandshakeInspect.ts @@ -34,7 +34,7 @@ function mvpKindDisplay( function recordToInternalRoleSource(r: HandshakeRecord): InternalHandshakeRoleSource { return { - handshake_type: r.handshake_type, + same_principal: r.same_principal === true, state: r.state, local_role: r.local_role, initiator_device_role: r.initiator_device_role, @@ -55,7 +55,7 @@ function recordToInternalRoleSource(r: HandshakeRecord): InternalHandshakeRoleSo export type InternalHostHandshakeP2pSafeDump = { handshake_id: string local_role: 'initiator' | 'acceptor' - handshake_type: 'internal' | 'standard' | null | undefined + same_principal: boolean state: string local_derived_role: 'host' | 'sandbox' | 'unknown' | null peer_derived_role: 'host' | 'sandbox' | 'unknown' | null @@ -81,7 +81,7 @@ export function buildInternalHostHandshakeP2pSafeDump( return { handshake_id: r.handshake_id, local_role: r.local_role, - handshake_type: r.handshake_type, + same_principal: r.same_principal === true, state: r.state, local_derived_role: d.localDeviceRole ?? 'unknown', peer_derived_role: d.peerDeviceRole ?? 'unknown', @@ -120,7 +120,7 @@ export async function getInternalHostHandshakeP2pInspect( if (typeof handshakeId === 'string' && handshakeId.trim().length > 0) { const hid = handshakeId.trim() const r0 = getHandshakeRecord(db, hid) - if (!r0 || r0.state !== HandshakeState.ACTIVE || r0.handshake_type !== 'internal') { + if (!r0 || r0.state !== HandshakeState.ACTIVE || r0.same_principal !== true) { return { ok: false, error: 'handshake_not_found_or_not_active_internal' } } const ar = assertRecordForServiceRpc(r0) @@ -134,7 +134,7 @@ export async function getInternalHostHandshakeP2pInspect( return { ok: true, dump: buildInternalHostHandshakeP2pSafeDump(db, ar.record) } } - const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, handshake_type: 'internal' }) + const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, same_principal: true }) for (const r0 of rows) { const ar = assertRecordForServiceRpc(r0) if (!ar.ok) { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/listInferenceTargets.ts b/code/apps/electron-vite-project/electron/main/internalInference/listInferenceTargets.ts index 77effd0b9..1197eb04e 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/listInferenceTargets.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/listInferenceTargets.ts @@ -160,9 +160,9 @@ function logHostAiLedgerView( source: 'sandbox' | 'list_targets_common', ): void { const currentDevice = getInstanceId().trim() - const firstInternal = rows.find((r) => r.handshake_type === 'internal' && r.state === HandshakeState.ACTIVE) + const firstInternal = rows.find((r) => r.same_principal === true && r.state === HandshakeState.ACTIVE) const handshakes = rows - .filter((r) => r.handshake_type === 'internal' && r.state === HandshakeState.ACTIVE) + .filter((r) => r.same_principal === true && r.state === HandshakeState.ACTIVE) .map((r) => { const d = deriveFromRecord(r) const dr = deriveInternalHostAiPeerRoles(r, getInstanceId().trim()) @@ -775,7 +775,7 @@ type DerivedInternalRoles = ReturnType function recordToInternalRoleSource(r: HandshakeRecord): InternalHandshakeRoleSource { return { - handshake_type: r.handshake_type, + same_principal: r.same_principal === true, state: r.state, local_role: r.local_role, initiator_device_role: r.initiator_device_role, @@ -1701,7 +1701,7 @@ export async function listSandboxHostInternalInferenceTargets(): Promise<{ db, async () => filterHandshakeRecordsForCurrentSession( - listHandshakeRecords(db, { state: HandshakeState.ACTIVE, handshake_type: 'internal' }), + listHandshakeRecords(db, { state: HandshakeState.ACTIVE, same_principal: true }), currentSession, ), 'list_targets_init', @@ -1723,11 +1723,11 @@ export async function listSandboxHostInternalInferenceTargets(): Promise<{ /** 1) ACTIVE internal rows for current SSO session (target emission stays session-scoped — §2 defense-in-depth), 2) derive roles, 3) count Sandbox→Host. */ const ledgerActive = listActiveInternalHandshakesForCurrentSession(db) - const activeInternalCount = ledgerActive.filter((r) => r.handshake_type === 'internal').length + const activeInternalCount = ledgerActive.filter((r) => r.same_principal === true).length let activeInternalSandboxToHostCount = 0 let handshakeProvesSandboxToHost = false for (const r0 of ledgerActive) { - if (r0.handshake_type !== 'internal' || r0.state !== HandshakeState.ACTIVE) continue + if (r0.same_principal !== true || r0.state !== HandshakeState.ACTIVE) continue if (rowProvesLocalSandboxToHostForHostAi(r0)) { activeInternalSandboxToHostCount += 1 handshakeProvesSandboxToHost = true @@ -1755,7 +1755,7 @@ export async function listSandboxHostInternalInferenceTargets(): Promise<{ let hadCapabilitiesProbed = false for (const r0 of ledgerActive) { - if (r0.handshake_type !== 'internal') { + if (r0.same_principal !== true) { console.log(`${L} rejected handshake=${r0.handshake_id} reason=NOT_INTERNAL`) continue } @@ -1904,7 +1904,7 @@ export async function listSandboxHostInternalInferenceTargets(): Promise<{ { const epKindForNudge = p2pEndpointKind(db, r.p2p_endpoint) if ( - r.handshake_type === 'internal' && + r.same_principal === true && epKindForNudge === 'relay' && !isP2pDataChannelUpForHandshake(hid) ) { @@ -3748,7 +3748,7 @@ export async function listSandboxHostInternalInferenceTargets(): Promise<{ */ if (targets.length === 0 && db && handshakeProvesSandboxToHost) { for (const r0 of ledgerActive) { - if (r0.handshake_type !== 'internal' || r0.state !== HandshakeState.ACTIVE) { + if (r0.same_principal !== true || r0.state !== HandshakeState.ACTIVE) { continue } if (!rowProvesLocalSandboxToHostForHostAi(r0)) { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcCapabilities.ts b/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcCapabilities.ts index 784ac8ba2..a669a11c3 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcCapabilities.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcCapabilities.ts @@ -743,7 +743,25 @@ export function handleP2pDcInferenceCapabilitiesAsSandbox( ) return false } - const sdr = deriveInternalHostAiPeerRoles(srec, getInstanceId().trim()) + // Same service-RPC eligibility gate as the Host handler (ACTIVE internal same-principal + // identity-complete Host↔Sandbox). Role derive alone is not sufficient. + const sar = assertRecordForServiceRpc(srec) + if (!sar.ok) { + console.log( + `[HOST_AI_CAPS_RESPONSE_REJECT] ${JSON.stringify({ + response_type: wireType, + handshake_id: handshakeId.trim(), + session_id: p2pSessionId.trim(), + correlation_id: ridEarly || null, + models_count: null, + reject_reason: 'service_rpc_ineligible', + policy_code: sar.code, + dc_phase: getSessionState(handshakeId.trim())?.phase ?? null, + })}`, + ) + return false + } + const sdr = deriveInternalHostAiPeerRoles(sar.record, getInstanceId().trim()) if (!sdr.ok || sdr.localRole !== 'sandbox') { console.log( `[HOST_AI_CAPS_RESPONSE_REJECT] ${JSON.stringify({ diff --git a/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcInference.ts b/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcInference.ts index 9698fd62b..d1eda8b66 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcInference.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/p2pDc/p2pDcInference.ts @@ -2,6 +2,7 @@ * Phase 7: Host↔Sandbox inference over WebRTC DataChannel (non-streaming, one request / one result). * Wire: inference_{request,result,error,cancel} — Ollama stays in main; transport pod only moves bytes. */ +import { isAiProvenance } from '../../../../../../packages/shared/src/aiProvenance' import { getHandshakeRecord } from '../../handshake/db' import type { HandshakeRecord } from '../../handshake/types' import { getInstanceId, isHostMode, isSandboxMode } from '../../orchestrator/orchestratorModeStore' @@ -69,6 +70,7 @@ export async function sendInternalInferenceWireOverP2pDataChannel( output: wire.output, duration_ms: wire.duration_ms, finish_reason: 'stop' as const, + ...(wire.provenance !== undefined ? { provenance: wire.provenance } : {}), } } else { out = { @@ -274,6 +276,7 @@ export function handleP2pDcInferenceResultAsSandbox( output: out, ...(model.trim() ? { model: model.trim() } : {}), ...(duration_ms !== undefined ? { duration_ms } : {}), + ...(isAiProvenance(raw.provenance) ? { provenance: raw.provenance } : {}), } if (!resolveInternalInferenceByRequestId(rid, pr)) { return false diff --git a/code/apps/electron-vite-project/electron/main/internalInference/p2pEndpointRepair.ts b/code/apps/electron-vite-project/electron/main/internalInference/p2pEndpointRepair.ts index 6021f99b5..557c7a1cd 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/p2pEndpointRepair.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/p2pEndpointRepair.ts @@ -183,7 +183,7 @@ export async function hydrateHostAdvertisedMapFromLedger( skipped++ continue } - if (rec.handshake_type !== 'internal' || rec.state !== HandshakeState.ACTIVE) { + if (rec.same_principal !== true || rec.state !== HandshakeState.ACTIVE) { skipped++ continue } @@ -1090,7 +1090,7 @@ export function runP2pEndpointRepairPass(db: any, context: string): void { const mode = getOrchestratorMode().mode const ledgerRoles = getHostAiLedgerRoleSummaryFromDb(db, getInstanceId().trim(), String(mode)) - const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, handshake_type: 'internal' }) + const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, same_principal: true }) for (const r of rows) { const ar = assertRecordForServiceRpc(r) if (!ar.ok) continue diff --git a/code/apps/electron-vite-project/electron/main/internalInference/pendingRequests.ts b/code/apps/electron-vite-project/electron/main/internalInference/pendingRequests.ts index 39fb44f94..dcdeb7b2a 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/pendingRequests.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/pendingRequests.ts @@ -1,9 +1,10 @@ +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' import { InternalInferenceErrorCode } from './errors' const DEFAULT_TIMEOUT_MS = 30_000 export type PendingResult = - | { kind: 'result'; output: string; model?: string; duration_ms?: number } + | { kind: 'result'; output: string; model?: string; duration_ms?: number; provenance?: AiProvenance } | { kind: 'error'; code: string; message: string } const pending = new Map< diff --git a/code/apps/electron-vite-project/electron/main/internalInference/policy.ts b/code/apps/electron-vite-project/electron/main/internalInference/policy.ts index a9b773e9b..b4f388514 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/policy.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/policy.ts @@ -1,18 +1,22 @@ +import { samePrincipalFullClaim } from '@repo/ingestion-core' import type { HandshakeRecord } from '../handshake/types' import { getInstanceId } from '../orchestrator/orchestratorModeStore' import { getP2PConfig } from '../p2p/p2pConfig' import { InternalInferenceErrorCode } from './errors' import { isHostSandboxPairEligible } from './hostAiInternalPairingLedger' -function samePrincipal(r: HandshakeRecord): boolean { - const a = r.initiator?.wrdesk_user_id - const b = r.acceptor?.wrdesk_user_id - return typeof a === 'string' && typeof b === 'string' && a.length > 0 && a === b -} - -/** Exported for `listTargets` / ledger filtering — same check as `assertRecordForServiceRpc` (without identity-complete gate). */ +/** + * Exported for `listTargets` / ledger filtering — same check as + * `assertRecordForServiceRpc` (without identity-complete gate). + * + * Full-claim same-principal [VII.3.8–3.10]: every claim present on both bound + * parties must match exactly (issuer included); a wrdesk-id-only agreement + * under differing emails/issuers is NOT the same principal. Strictly tighter + * than the previous wrdesk-only comparison — never looser. + */ export function handshakeSamePrincipal(r: HandshakeRecord): boolean { - return samePrincipal(r) + if (!r.initiator || !r.acceptor) return false + return samePrincipalFullClaim(r.initiator, r.acceptor).ok } function normHostSandboxRole(v: unknown): 'host' | 'sandbox' | null { diff --git a/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostAiDirectBeapAdRequest.ts b/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostAiDirectBeapAdRequest.ts index d52cfbbfb..236c56a93 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostAiDirectBeapAdRequest.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostAiDirectBeapAdRequest.ts @@ -163,7 +163,7 @@ export async function sandboxMaybeRequestHostDirectBeapAdvertisement( return } - const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, handshake_type: 'internal' }) + const rows = listHandshakeRecords(db, { state: HandshakeState.ACTIVE, same_principal: true }) for (const r of rows) { const hid = String(r.handshake_id ?? '').trim() if (!hid) continue diff --git a/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostChat.ts b/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostChat.ts index 33f8a1ffc..14af91e6d 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostChat.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostChat.ts @@ -4,6 +4,7 @@ * INV-HOSTAI-FROZEN: trust/role/policy unchanged — only transport swapped. */ +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' import { getHandshakeRecord } from '../handshake/db' import { getHandshakeDbForInternalInference } from './dbAccess' import { InternalInferenceErrorCode } from './errors' @@ -19,7 +20,7 @@ export interface SandboxHostChatMessage { } export type SandboxHostChatResult = - | { ok: true; request_id: string; output: string; model: string; duration_ms?: number } + | { ok: true; request_id: string; output: string; model: string; duration_ms?: number; provenance?: AiProvenance } | { ok: false; code: string; message: string } const DEFAULT_INTERNAL_INFERENCE_TIMEOUT_MS = 120_000 @@ -71,7 +72,7 @@ export async function runSandboxHostInferenceChat(params: { if (record && record.state !== 'ACTIVE') { return { ok: false, code: ar.code, message: 'not active' } } - if (record?.handshake_type !== 'internal') { + if (record?.same_principal !== true) { return { ok: false, code: ar.code, message: 'not internal' } } } @@ -171,6 +172,7 @@ export async function runSandboxHostInferenceChat(params: { output: pr.output, model: pr.model ?? params.model ?? 'host', duration_ms: pr.duration_ms, + ...(pr.provenance !== undefined ? { provenance: pr.provenance } : {}), } } catch (e: any) { const code = (e && e.code) || InternalInferenceErrorCode.INTERNAL_INFERENCE_FAILED diff --git a/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostUi.ts b/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostUi.ts index 767b46e2e..71640464d 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostUi.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/sandboxHostUi.ts @@ -149,7 +149,7 @@ export async function listSandboxHostInferenceCandidates(): Promise @@ -460,7 +460,7 @@ function computeHostAiRouteFieldsForDecider( `[INFERENCE_TRUST_DEBUG] handshake=${handshakeRecord.handshake_id} ` + `trusted=${inferenceTrust.trusted} reason=${inferenceTrust.reason} ` + `state=${handshakeRecord.state} ` + - `type=${handshakeRecord.handshake_type} ` + + `same_principal=${handshakeRecord.same_principal === true} ` + `identity_complete=${handshakeRecord.internal_coordination_identity_complete} ` + `p2p_endpoint=${handshakeRecord.p2p_endpoint ?? 'null'} ` + `bearer_present=${Boolean(handshakeRecord.counterparty_p2p_token)} ` + @@ -746,7 +746,7 @@ export function decideInternalInferenceTransport( * even when the full WebRTC stack is enabled. Relay-only rows still use WebRTC below. */ const internalPreferDirectHttp = - Boolean(hr?.handshake_type === 'internal') && trust && legacyPostOk && kind === 'direct' && !p2pOn + Boolean(hr?.same_principal === true) && trust && legacyPostOk && kind === 'direct' && !p2pOn if (internalPreferDirectHttp) { return { @@ -901,7 +901,7 @@ export function decideInternalInferenceTransport( * Expose P2P transport as open + `connecting` so the sandbox can `ensureHostAiP2pSession` and list can wait * for WebRTC/DC; direct BEAP is optional. Only fail here when the P2P session is terminal. */ - if (hr?.handshake_type === 'internal' && trust && kind === 'relay' && !dcUp) { + if (hr?.same_principal === true && trust && kind === 'relay' && !dcUp) { if (p2pOn && ph !== P2pSessionPhase.failed) { return { ...hostAiRouteSnap(input), diff --git a/code/apps/electron-vite-project/electron/main/internalInference/transport/inferenceDirectHttpTrust.ts b/code/apps/electron-vite-project/electron/main/internalInference/transport/inferenceDirectHttpTrust.ts index b99a627d6..370d9eec8 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/transport/inferenceDirectHttpTrust.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/transport/inferenceDirectHttpTrust.ts @@ -21,7 +21,7 @@ export type InferenceDirectHttpTrustReason = /** All handshake criteria satisfied (ACTIVE, internal, same principal, sandbox→host, identity complete, bearer). */ | 'handshake_bound' | 'state_not_active' - | 'handshake_type_not_internal' + | 'record_not_same_principal' | 'not_same_principal' | 'not_sandbox_to_host' | 'identity_not_complete' @@ -85,8 +85,8 @@ export function inferenceDirectHttpTrust(input: { if (r.state !== HandshakeState.ACTIVE) { return { trusted: false, reason: 'state_not_active', normalizedUrl: null } } - if (r.handshake_type !== 'internal') { - return { trusted: false, reason: 'handshake_type_not_internal', normalizedUrl: null } + if (r.same_principal !== true) { + return { trusted: false, reason: 'record_not_same_principal', normalizedUrl: null } } if (!handshakeSamePrincipal(r)) { return { trusted: false, reason: 'not_same_principal', normalizedUrl: null } diff --git a/code/apps/electron-vite-project/electron/main/internalInference/transport/internalInferenceTransport.ts b/code/apps/electron-vite-project/electron/main/internalInference/transport/internalInferenceTransport.ts index 294c72d74..564ee1327 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/transport/internalInferenceTransport.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/transport/internalInferenceTransport.ts @@ -118,7 +118,7 @@ function hostAiVerifiedHttpForHostSendResult(args: { if (args.webrtcFailureHttpFallback) return false const dr = deriveInternalHostAiPeerRoles(args.record, getInstanceId().trim()) if (!dr.ok || dr.localRole !== 'host' || dr.peerRole !== 'sandbox') return false - if (args.record.handshake_type !== 'internal') return false + if (args.record.same_principal !== true) return false return assertP2pEndpointDirect(args.db as any, args.record.p2p_endpoint).ok } diff --git a/code/apps/electron-vite-project/electron/main/internalInference/types.ts b/code/apps/electron-vite-project/electron/main/internalInference/types.ts index 9e35b7a2c..b21ad00ff 100644 --- a/code/apps/electron-vite-project/electron/main/internalInference/types.ts +++ b/code/apps/electron-vite-project/electron/main/internalInference/types.ts @@ -3,6 +3,8 @@ * Not user-visible BEAP inbox; not coordination relay in MVP. */ +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' + export const INTERNAL_INFERENCE_SCHEMA_VERSION = 1 export type InternalServiceMessageType = @@ -48,6 +50,8 @@ export interface InternalInferenceResultWire extends InternalServiceEnvelopeBase output: string usage?: Record duration_ms: number + /** Art. 50 AI provenance attached at generation boundary (backward-compatible optional field). */ + provenance?: AiProvenance } export interface InternalInferenceErrorWire { diff --git a/code/apps/electron-vite-project/electron/main/letter/letterComposerIpc.ts b/code/apps/electron-vite-project/electron/main/letter/letterComposerIpc.ts index a08ecb59a..a9c5900ee 100644 --- a/code/apps/electron-vite-project/electron/main/letter/letterComposerIpc.ts +++ b/code/apps/electron-vite-project/electron/main/letter/letterComposerIpc.ts @@ -427,7 +427,7 @@ export function registerLetterComposerIpcHandlers(): void { ipcMain.handle('letter:extractFields', async (_e, html: string) => { if (typeof html !== 'string') { - return [] + return { fields: [], provenance: null } } const slice = html.slice(0, 8000) try { @@ -435,7 +435,7 @@ export function registerLetterComposerIpcHandlers(): void { const modelId = await localLlmManager.getEffectiveChatModelName() if (!modelId) { console.warn('[letter:extractFields] No effective Ollama model') - return [] + return { fields: [], provenance: null } } const messages: ChatMessage[] = [ { role: 'system', content: FIELD_SYSTEM_PROMPT }, @@ -452,17 +452,17 @@ export function registerLetterComposerIpcHandlers(): void { .trim() const parsed = JSON.parse(cleaned) as unknown if (!Array.isArray(parsed)) { - return [] + return { fields: [], provenance: response?.provenance ?? null } } const out: ReturnType[] = [] for (const item of parsed) { const n = normalizeExtractedField(item) if (n) out.push(n) } - return out + return { fields: out, provenance: response?.provenance ?? null } } catch (e) { console.warn('[letter:extractFields] failed:', e instanceof Error ? e.message : e) - return [] + return { fields: [], provenance: null } } }) diff --git a/code/apps/electron-vite-project/electron/main/llm/internalHostInferenceLocal.ts b/code/apps/electron-vite-project/electron/main/llm/internalHostInferenceLocal.ts index 23d48f740..695d162b2 100644 --- a/code/apps/electron-vite-project/electron/main/llm/internalHostInferenceLocal.ts +++ b/code/apps/electron-vite-project/electron/main/llm/internalHostInferenceLocal.ts @@ -40,6 +40,8 @@ export interface RunInternalHostLocalLlmResult { model: string usage?: { prompt_eval_count?: number; eval_count?: number } durationMs: number + /** Art. 50 provenance attached at this generation boundary (logged once). */ + provenance?: import('../../../../../packages/shared/src/aiProvenance').AiProvenance } /** @deprecated Use RunInternalHostLocalLlmResult */ @@ -167,6 +169,11 @@ export async function runInternalHostLocalLlmInference( } const text = extracted.content.trim() clearTimeout(timer) + const { attachAndLogProvenance } = await import('../aiProvenance/attachProvenance') + const attached = attachAndLogProvenance(text, { + model_id: data.model ?? model, + provider: 'host-ai', + }) return { text, model: data.model ?? model, @@ -175,6 +182,7 @@ export async function runInternalHostLocalLlmInference( eval_count: data.usage?.completion_tokens, }, durationMs: Date.now() - t0, + provenance: attached.provenance, } } catch (e) { clearTimeout(timer) diff --git a/code/apps/electron-vite-project/electron/main/llm/ipc.ts b/code/apps/electron-vite-project/electron/main/llm/ipc.ts index aaaf6a577..331a76821 100644 --- a/code/apps/electron-vite-project/electron/main/llm/ipc.ts +++ b/code/apps/electron-vite-project/electron/main/llm/ipc.ts @@ -482,7 +482,7 @@ export function registerLlmHandlers() { modelId = resolved } const response = await localLlmManager.chat(modelId, request.messages) - return { ok: true, data: response } + return { ok: true, data: response, ...(response.provenance !== undefined ? { provenance: response.provenance } : {}) } } catch (error: any) { console.error('[LLM IPC] Chat failed:', error) return { ok: false, error: error.message } diff --git a/code/apps/electron-vite-project/electron/main/llm/local-llm-manager.ts b/code/apps/electron-vite-project/electron/main/llm/local-llm-manager.ts index 2c3c05b24..a39a2191d 100644 --- a/code/apps/electron-vite-project/electron/main/llm/local-llm-manager.ts +++ b/code/apps/electron-vite-project/electron/main/llm/local-llm-manager.ts @@ -49,6 +49,7 @@ import { import { LOCAL_LLM_CTX_STANDARD } from './localLlmServerConfig' import { RotatingLogWriter, llamaServerLogPath } from './llamaServerLog' import { extractLlamaChatContent } from './llamaChatResponseContent' +import { attachAndLogProvenance } from '../aiProvenance/attachProvenance' const execAsync = promisify(exec) @@ -1086,12 +1087,17 @@ export class LocalLlmManager { // reasoning_content fallback: with --jinja + reasoning enabled the answer can land in // reasoning_content with an empty content. Empty stays '' here — callers decide to error. const extracted = extractLlamaChatContent(data.choices?.[0]?.message) + const prov = attachAndLogProvenance(extracted.content, { + model_id: data.model || modelId, + provider: 'local', + }) const out: ChatResponse = { content: extracted.content, model: data.model || modelId, done: true, promptEvalCount: data.usage?.prompt_tokens, evalCount: data.usage?.completion_tokens, + provenance: prov.provenance, } localLlmRuntimeRecordChatTiming(Date.now() - t0) return out diff --git a/code/apps/electron-vite-project/electron/main/llm/types.ts b/code/apps/electron-vite-project/electron/main/llm/types.ts index e0bab711a..ca8d5164b 100644 --- a/code/apps/electron-vite-project/electron/main/llm/types.ts +++ b/code/apps/electron-vite-project/electron/main/llm/types.ts @@ -3,6 +3,8 @@ * Core TypeScript interfaces for local LLM management */ +import type { AiProvenance } from '../../../../../packages/shared/src/aiProvenance' + export type ModelTier = 'lightweight' | 'balanced' | 'performance' | 'high-end' export type PerformanceEstimate = 'fast' | 'usable' | 'slow' | 'unusable' export type OsType = 'windows' | 'macos' | 'linux' @@ -130,6 +132,8 @@ export interface ChatResponse { loadDuration?: number promptEvalCount?: number evalCount?: number + /** Art. 50 AI provenance attached at generation boundary. */ + provenance?: AiProvenance } /** diff --git a/code/apps/electron-vite-project/electron/main/ocr/router.ts b/code/apps/electron-vite-project/electron/main/ocr/router.ts index fc4fc6f79..51682c51b 100644 --- a/code/apps/electron-vite-project/electron/main/ocr/router.ts +++ b/code/apps/electron-vite-project/electron/main/ocr/router.ts @@ -13,6 +13,7 @@ import { } from './types' import { ocrService } from './ocr-service' import { isSandboxMode } from '../orchestrator/orchestratorModeStore' +import { attachAndLogProvenance } from '../aiProvenance/attachProvenance' /** * Vision-capable providers and their capabilities @@ -209,6 +210,23 @@ export class OCRRouter { onProgress?.({ status: 'complete', progress: 100, message: 'Processing complete' }) + const cloudProviderMap: Record = { + OpenAI: 'cloud:openai', + Claude: 'cloud:anthropic', + Gemini: 'cloud:google', + Grok: 'cloud:grok', + } + const cloudModelMap: Record = { + OpenAI: 'gpt-4o-mini', + Claude: 'claude-3-haiku-20240307', + Gemini: 'gemini-1.5-flash', + Grok: 'grok-vision-beta', + } + attachAndLogProvenance(result.text, { + model_id: cloudModelMap[provider] ?? provider.toLowerCase(), + provider: cloudProviderMap[provider] ?? `cloud:${provider.toLowerCase()}`, + }) + return { text: result.text, confidence: result.confidence || 95, // Cloud usually has high confidence diff --git a/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordination-client.test.ts b/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordination-client.test.ts index d9ae7c604..156abed0a 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordination-client.test.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordination-client.test.ts @@ -15,6 +15,11 @@ import { migrateHandshakeTables, insertHandshakeRecord } from '../../handshake/d import { migrateIngestionTables } from '../../ingestion/persistenceDb' import { upsertP2PConfig, getP2PConfig } from '../p2pConfig' import { buildTestSession } from '../../handshake/sessionFactory' +import { + getOrchestratorMode, + setOrchestratorMode, + type OrchestratorModeConfig, +} from '../../orchestrator/orchestratorModeStore' import type { HandshakeRecord } from '../../handshake/types' import { getP2PHealth, @@ -51,13 +56,28 @@ function skipIfNoSqlite(): boolean { describe('Coordination Client', () => { let fetchSpy: ReturnType + let modeBefore: OrchestratorModeConfig | null = null beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch') + // These tests send outbound, which only a host node may do. The Electron + // mock's userData dir is shared and persists across files and runs, so the + // orchestrator mode left behind by any other suite would otherwise decide + // the outcome here. Pin it, and put it back afterwards. + try { + modeBefore = getOrchestratorMode() + if (modeBefore.mode !== 'host') setOrchestratorMode({ ...modeBefore, mode: 'host' }) + } catch { + modeBefore = null + } }) afterEach(() => { fetchSpy?.restore?.() + if (modeBefore) { + try { setOrchestratorMode(modeBefore) } catch { /* best effort */ } + modeBefore = null + } }) test('CC_05_outbound_via_coordination: use_coordination=true → outbound goes to coordination URL with OIDC token', async () => { @@ -98,11 +118,14 @@ describe('Coordination Client', () => { new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }), ) - enqueueOutboundCapsule(db, 'hs-cc05b', 'https://coordination.wrdesk.com/beap/capsule', { + // enqueue returns a typed result; discarding it is how a refused enqueue used + // to look like a drain that found nothing. + const enq = enqueueOutboundCapsule(db, 'hs-cc05b', 'https://coordination.wrdesk.com/beap/capsule', { header: { receiver_binding: {} }, metadata: {}, payloadEnc: { chunking: { count: 1, enabled: true, maxChunkBytes: 262144, merkleRoot: 'z' } }, }) + expect(enq.enqueued, enq.enqueued ? '' : `enqueue refused: ${JSON.stringify(enq)}`).toBe(true) await processOutboundQueue(db, async () => 'oidc-token-xyz') const call = (fetchSpy as any).mock.calls.find((c: any) => String(c[0]).includes('/beap/capsule')) @@ -113,7 +136,11 @@ describe('Coordination Client', () => { expect(postBody.capsule_type).toBeUndefined() }) - test('CC_06_outbound_via_relay: use_coordination=false → outbound goes to relay URL with Bearer token', async () => { + // Direct-LAN P2P ingest was retired: with use_coordination=false there is no + // outbound path left, and the queue must say so in a typed, permanent way + // rather than attempt the relay. Pinned because a silent re-enablement of + // direct relay egress is exactly what this refusal exists to prevent. + test('CC_06_outbound_via_relay: use_coordination=false → refused, coordination relay required', async () => { if (skipIfNoSqlite()) return const db = createTestDb() upsertP2PConfig(db, { @@ -157,10 +184,15 @@ describe('Coordination Client', () => { seq: 1, }) - await processOutboundQueue(db) - expect(fetchSpy).toHaveBeenCalled() - const call = (fetchSpy as any).mock.calls[0] - expect(call[1]?.headers?.Authorization).toBe('Bearer bearer-token-abc') + const res = await processOutboundQueue(db) + + expect(res.delivered).toBe(false) + expect(res.code).toBe('PREFLIGHT_FAILED') + expect(res.failure_class).toBe('CONFIG_PERMANENT') + expect(String(res.error)).toMatch(/direct-LAN P2P ingest is retired/i) + // The capsule stays queued rather than being dropped, and nothing goes out. + expect(res.queued).toBe(true) + expect(fetchSpy).not.toHaveBeenCalled() }) test('CC_07_register_handshake_coordination: use_coordination=true → registration goes to coordination service', async () => { diff --git a/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordinationSamePrincipalInbound.test.ts b/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordinationSamePrincipalInbound.test.ts index 1ad01b5cc..842aa7368 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordinationSamePrincipalInbound.test.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/__tests__/coordinationSamePrincipalInbound.test.ts @@ -14,7 +14,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: 'standard' }, + record: { same_principal: false }, capsuleSenderDeviceId: 'device-peer', localDeviceId: 'device-local', }), @@ -24,7 +24,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: null }, + record: { same_principal: null }, capsuleSenderDeviceId: 'a', localDeviceId: 'b', }), @@ -36,7 +36,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'HOST-PC', localDeviceId: 'HOST-PC', }), @@ -48,7 +48,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'SANDBOX-PC', localDeviceId: 'HOST-PC', }), @@ -60,7 +60,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: '', localDeviceId: 'HOST-PC', }), @@ -70,7 +70,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: ' ', localDeviceId: 'HOST-PC', }), @@ -82,7 +82,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: hs, - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'SANDBOX-PC', localDeviceId: '', }), @@ -109,7 +109,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo record: null, capsuleSenderDeviceId: 'SANDBOX-PC', localDeviceId: 'HOST-PC', - capsuleHandshakeType: 'internal', + capsuleDeclaresSamePrincipal: true, }), ).toBe(false) }) @@ -122,7 +122,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo record: null, capsuleSenderDeviceId: 'HOST-PC', localDeviceId: 'HOST-PC', - capsuleHandshakeType: 'internal', + capsuleDeclaresSamePrincipal: true, }), ).toBe(true) }) @@ -135,7 +135,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo record: null, capsuleSenderDeviceId: '', localDeviceId: 'HOST-PC', - capsuleHandshakeType: 'internal', + capsuleDeclaresSamePrincipal: true, }), ).toBe(false) }) @@ -145,7 +145,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: false, handshakeId: hs, - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'A', localDeviceId: 'B', }), @@ -155,7 +155,7 @@ describe('computeSamePrincipalCoordinationSkipOwn — internal multi-device inbo computeSamePrincipalCoordinationSkipOwn({ hasDb: true, handshakeId: 'unknown', - record: { handshake_type: 'internal' }, + record: { same_principal: true }, capsuleSenderDeviceId: 'A', localDeviceId: 'B', }), diff --git a/code/apps/electron-vite-project/electron/main/p2p/coordinationSamePrincipalInbound.ts b/code/apps/electron-vite-project/electron/main/p2p/coordinationSamePrincipalInbound.ts index dfa8683d4..3eff3ad5b 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/coordinationSamePrincipalInbound.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/coordinationSamePrincipalInbound.ts @@ -3,18 +3,19 @@ * whether to treat the capsule as "our own" relay echo and skip ingestion. * * Normal handshakes: different principals — if sender_wrdesk_user_id === local, always skip. - * Internal handshakes: same principal on two devices — skip only when sender_device_id + * Same-principal handshakes: same principal on two devices — skip only when sender_device_id * matches this device (both ids present). Missing device identity must never yield skip=true - * for internal-labelled traffic (caller quarantines and must not ACK). + * for same-principal-labelled traffic (caller quarantines and must not ACK). * - * When the DB row is missing but the wire declares handshake_type=internal, apply the same + * When the DB row is missing but the wire declares same-principal pairing (legacy + * `handshake_type=internal` wire field, read via `wireDeclaresSamePrincipal`), apply the same * device-scoped rules so we do not conservatively skip peer deliveries. * * Pure function for unit tests and a single implementation site for coordinationWs. */ export type SamePrincipalSkipRecord = { - handshake_type?: 'internal' | 'standard' | null + same_principal?: boolean | null } /** @@ -26,15 +27,15 @@ export function computeSamePrincipalCoordinationSkipOwn(args: { record: SamePrincipalSkipRecord | null capsuleSenderDeviceId: string localDeviceId: string - /** When record is null, same-principal internal routing still uses wire handshake_type */ - capsuleHandshakeType?: string | null + /** When record is null, same-principal routing still uses the wire declaration (caller resolves it via `wireDeclaresSamePrincipal`). */ + capsuleDeclaresSamePrincipal?: boolean }): boolean { - const { hasDb, handshakeId, record, capsuleSenderDeviceId, localDeviceId, capsuleHandshakeType } = args + const { hasDb, handshakeId, record, capsuleSenderDeviceId, localDeviceId, capsuleDeclaresSamePrincipal } = args if (!hasDb || !handshakeId || handshakeId === 'unknown') { return true } if (!record) { - if (capsuleHandshakeType === 'internal') { + if (capsuleDeclaresSamePrincipal === true) { const cap = capsuleSenderDeviceId.trim() const loc = localDeviceId.trim() if (!cap || !loc) { @@ -44,7 +45,7 @@ export function computeSamePrincipalCoordinationSkipOwn(args: { } return true } - if (record.handshake_type !== 'internal') { + if (record.same_principal !== true) { return true } const cap = capsuleSenderDeviceId.trim() diff --git a/code/apps/electron-vite-project/electron/main/p2p/coordinationWs.ts b/code/apps/electron-vite-project/electron/main/p2p/coordinationWs.ts index 7725d56fb..f84f2ddea 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/coordinationWs.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/coordinationWs.ts @@ -31,6 +31,7 @@ import { } from './p2pHealth' import { getInstanceId } from '../orchestrator/orchestratorModeStore' import { computeSamePrincipalCoordinationSkipOwn } from './coordinationSamePrincipalInbound' +import { wireDeclaresSamePrincipal } from '../handshake/samePrincipalWire' import { requestCoordinationFlushQueued } from './coordinationFlushQueued' import { normalizeCoordinationUrlForLocalDial, @@ -208,7 +209,7 @@ async function processCapsuleInternal( /* Vitest / non-Electron */ } - let handshakeTypeForLog: string | null = null + let samePrincipalForLog: boolean | null = null let recordLookup: 'skipped' | 'found' | 'missing' = 'skipped' let senderDeviceForLog = '' let localDeviceForLog = '' @@ -218,9 +219,9 @@ async function processCapsuleInternal( record = getHandshakeRecord(db, handshakeId) if (record) { recordLookup = 'found' - handshakeTypeForLog = record.handshake_type ?? null + samePrincipalForLog = record.same_principal === true localRoleForLog = record.local_role - if (record.handshake_type === 'internal') { + if (record.same_principal === true) { senderDeviceForLog = capDevice localDeviceForLog = localDevice } @@ -229,10 +230,8 @@ async function processCapsuleInternal( } } - const wireHandshakeType = - typeof capObj.handshake_type === 'string' ? capObj.handshake_type.trim() : '' - const isInternalInboundContext = - record?.handshake_type === 'internal' || wireHandshakeType === 'internal' + const wireSamePrincipal = wireDeclaresSamePrincipal(capObj) + const isInternalInboundContext = record?.same_principal === true || wireSamePrincipal if (isInternalInboundContext && (!capDevice || !localDevice)) { const reasonCode = 'INTERNAL_WS_INBOUND_DEVICE_IDENTITY_INCOMPLETE' @@ -247,8 +246,8 @@ async function processCapsuleInternal( relay_message_id: id, handshake_id: handshakeId, capsule_type: capsuleType, - record_handshake_type: record?.handshake_type ?? null, - wire_handshake_type: wireHandshakeType || null, + record_same_principal: record ? record.same_principal === true : null, + wire_same_principal: wireSamePrincipal, has_capsule_sender_device_id: Boolean(capDevice), has_local_orchestrator_device_id: Boolean(localDevice), decision: 'quarantine_no_ack', @@ -268,8 +267,8 @@ async function processCapsuleInternal( relay_message_id: id, handshake_id: handshakeId, capsule_type: capsuleType, - record_handshake_type: record?.handshake_type ?? null, - wire_handshake_type: wireHandshakeType || null, + record_same_principal: record ? record.same_principal === true : null, + wire_same_principal: wireSamePrincipal, }), }) } catch { @@ -285,9 +284,9 @@ async function processCapsuleInternal( record, capsuleSenderDeviceId: capDevice, localDeviceId: localDevice, - capsuleHandshakeType: wireHandshakeType || null, + capsuleDeclaresSamePrincipal: wireSamePrincipal, }) - if (handshakeTypeForLog === 'internal' || recordLookup !== 'found') { + if (samePrincipalForLog === true || recordLookup !== 'found') { console.log( '[Coordination][hs-trace]', JSON.stringify({ @@ -295,7 +294,7 @@ async function processCapsuleInternal( ts: new Date().toISOString(), relay_message_id: id, handshake_id: handshakeId, - handshake_type: handshakeTypeForLog, + same_principal: samePrincipalForLog, record_lookup: recordLookup, capsule_type: capsuleType, sender_wrdesk_user_id: capsuleSenderId || null, @@ -504,6 +503,19 @@ async function processCapsuleInternal( } console.log('[Coordination] processHandshakeCapsule returned: success=', handshakeResult.success, 'reason=', (handshakeResult as any).reason ?? 'n/a') + if (handshakeResult.success && !handshakeResult.handshakeRecord) { + // Phase 4 (Q1): inbound initiate staged as a Connect offer — no + // relationship row exists until consent [IX.3.1]. + console.log('[Coordination] Inbound initiate staged as Connect offer:', { + handshake_id: handshakeId, + offer_id: (handshakeResult as { offerId?: string }).offerId ?? null, + }) + setP2PHealthCoordinationLastPush() + onHandshakeUpdated?.() + sendAckFn([id]) + return + } + if (handshakeResult.success) { const capRebuilt = rebuildResult.capsule as { capsule_type?: unknown; capsule_hash?: unknown } maybeEnqueueInitialContextSyncAfterInboundAccept(db, ssoSession, { @@ -522,7 +534,7 @@ async function processCapsuleInternal( const record = handshakeResult.handshakeRecord! - if (record.handshake_type === 'internal') { + if (record.same_principal === true) { let ldIngest = '' try { ldIngest = getInstanceId()?.trim() ?? '' @@ -539,7 +551,7 @@ async function processCapsuleInternal( ts: new Date().toISOString(), relay_message_id: id, handshake_id: record.handshake_id, - handshake_type: record.handshake_type, + same_principal: record.same_principal === true, capsule_type: typeof capIngest.capsule_type === 'string' ? capIngest.capsule_type : capsuleType, sender_wrdesk_user_id: @@ -995,7 +1007,7 @@ export function createCoordinationWsClient( : null) if (db && hidTrace) { const trRec = getHandshakeRecord(db, hidTrace) - if (trRec?.handshake_type === 'internal') { + if (trRec?.same_principal === true) { let ldRecv = '' try { ldRecv = getInstanceId()?.trim() ?? '' @@ -1011,7 +1023,7 @@ export function createCoordinationWsClient( ts: new Date().toISOString(), relay_message_id: msg.id, handshake_id: hidTrace, - handshake_type: trRec.handshake_type, + same_principal: trRec.same_principal === true, capsule_type: typeof cap.capsule_type === 'string' ? cap.capsule_type : null, sender_wrdesk_user_id: diff --git a/code/apps/electron-vite-project/electron/main/p2p/relayIdentity.ts b/code/apps/electron-vite-project/electron/main/p2p/relayIdentity.ts index e123ede41..9adc30731 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/relayIdentity.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/relayIdentity.ts @@ -54,12 +54,13 @@ export function decodeJwtSubForLogs(token: string): string { export function coordinationRegistryUserIdsForSession( session: SSOSession, record: { - handshake_type?: 'internal' | 'standard' | null + /** Profile-derived same-principal parameter (Q9). */ + same_principal?: boolean | null initiator?: PartyIdentity | null acceptor?: PartyIdentity | null }, ): { initiator_user_id: string; acceptor_user_id: string } { - if (record.handshake_type === 'internal') { + if (record.same_principal === true) { const sub = getRelayUserIdForRegistry(session) if (sub) return { initiator_user_id: sub, acceptor_user_id: sub } } diff --git a/code/apps/electron-vite-project/electron/main/p2p/relayPull.ts b/code/apps/electron-vite-project/electron/main/p2p/relayPull.ts index 3e09e7982..bf270d71f 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/relayPull.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/relayPull.ts @@ -361,7 +361,7 @@ export async function pullFromRelay( localRelayDev = '' } const reverseInternalWire = internalRelayCapsuleWireOptsFromRecord(record, localRelayDev) - if (record.handshake_type === 'internal' && !reverseInternalWire) { + if (record.same_principal === true && !reverseInternalWire) { console.warn( '[Relay] Skipping reverse context_sync — internal relay identity incomplete, handshake:', record.handshake_id, @@ -379,6 +379,8 @@ export async function pullFromRelay( local_private_key: localPriv, peerX25519PublicKeyB64: record.peer_x25519_public_key_b64, localRole: record.local_role, + counterpartyIdentity: + record.local_role === 'initiator' ? record.acceptor : record.initiator, ...(reverseInternalWire ?? {}), }) const enqRev = enqueueOutboundCapsule(db, record.handshake_id, targetEndpoint.trim(), contextSyncCapsule) diff --git a/code/apps/electron-vite-project/electron/main/p2p/relaySync.ts b/code/apps/electron-vite-project/electron/main/p2p/relaySync.ts index b94c2ebf5..cda4448f6 100644 --- a/code/apps/electron-vite-project/electron/main/p2p/relaySync.ts +++ b/code/apps/electron-vite-project/electron/main/p2p/relaySync.ts @@ -50,7 +50,8 @@ export async function registerHandshakeWithRelay( initiator_device_id?: string /** Optional — same-user / internal relay routing; omit for cross-party (unchanged). */ acceptor_device_id?: string - handshake_type?: 'internal' | 'standard' + /** Profile-derived same-principal parameter (Q9) — emitted to the relay as the legacy wire field. */ + same_principal?: boolean }, ): Promise<{ success: boolean; error?: string }> { if (!db) return { success: false, error: 'No database' } @@ -85,7 +86,7 @@ export async function registerHandshakeWithRelay( acceptor_user_id: handshakeDetails.acceptor_user_id, initiator_device_id: handshakeDetails.initiator_device_id ?? null, acceptor_device_id: handshakeDetails.acceptor_device_id ?? null, - handshake_type: handshakeDetails.handshake_type ?? null, + same_principal: handshakeDetails.same_principal === true, token_sub: tokenSub, }), ) @@ -108,7 +109,8 @@ export async function registerHandshakeWithRelay( if (handshakeDetails.acceptor_device_id) { registrationBody.acceptor_device_id = handshakeDetails.acceptor_device_id } - if (handshakeDetails.handshake_type === 'internal') { + if (handshakeDetails.same_principal === true) { + // Legacy relay wire field (server compat) — derived from profile. registrationBody.handshake_type = 'internal' } try { @@ -247,7 +249,7 @@ export async function reregisterInternalHandshakeAfterCoordinationP2pSignal403( const hid = String(handshakeId ?? '').trim() if (!db || !hid) return { ok: false, reason: 'no_db_or_handshake' } const record = getHandshakeRecord(db, hid) - if (!record || record.handshake_type !== 'internal') { + if (!record || record.same_principal !== true) { return { ok: false, reason: 'not_internal' } } const cfg = getP2PConfig(db) @@ -262,7 +264,7 @@ export async function reregisterInternalHandshakeAfterCoordinationP2pSignal403( const { coordinationRegistryUserIdsForSession } = await import('./relayIdentity') const regUserIds = coordinationRegistryUserIdsForSession(session, { - handshake_type: record.handshake_type, + same_principal: record.same_principal === true, initiator: record.initiator, acceptor: record.acceptor, }) @@ -272,7 +274,7 @@ export async function reregisterInternalHandshakeAfterCoordinationP2pSignal403( acceptor_user_id: regUserIds.acceptor_user_id, initiator_email: record.initiator?.email ?? '', acceptor_email: record.acceptor?.email ?? '', - handshake_type: 'internal', + same_principal: true, ...(record.initiator_coordination_device_id?.trim() ? { initiator_device_id: record.initiator_coordination_device_id.trim() } : {}), diff --git a/code/apps/electron-vite-project/electron/main/retention/retentionJob.ts b/code/apps/electron-vite-project/electron/main/retention/retentionJob.ts index f1ea72550..c13c4e3a3 100644 --- a/code/apps/electron-vite-project/electron/main/retention/retentionJob.ts +++ b/code/apps/electron-vite-project/electron/main/retention/retentionJob.ts @@ -11,11 +11,32 @@ * - Logs deletion counts per table (no sensitive content) * - Idempotent and safe under concurrent invocation * - Never deletes queued or processing sandbox tasks + * + * Phase 5 carve-out (H5): retention NEVER touches the Tier-L evidence chain + * (`wr_evidence_chain` — append-only by trigger) or the frozen handshake + * `audit_log`. Only the three ingestion tables named in RETENTION_TABLES are + * ever purged; adding a table requires updating that allowlist explicitly. */ import type { RetentionConfig } from './retentionConfig' import { DEFAULT_RETENTION_CONFIG } from './retentionConfig' +/** + * Explicit allowlist of purgeable tables (H5). The evidence chain and the + * handshake audit_log are structurally outside retention. + */ +export const RETENTION_TABLES = Object.freeze([ + 'ingestion_audit_log', + 'ingestion_quarantine', + 'sandbox_queue', +] as const) + +/** Tables retention must never purge (documented carve-out, H5). */ +export const RETENTION_EXCLUDED_TABLES = Object.freeze([ + 'wr_evidence_chain', + 'audit_log', +] as const) + export interface RetentionRunResult { readonly audit_log_deleted: number; readonly quarantine_deleted: number; diff --git a/code/apps/electron-vite-project/electron/main/sealed-storage/__tests__/keySourcePolicyList.test.ts b/code/apps/electron-vite-project/electron/main/sealed-storage/__tests__/keySourcePolicyList.test.ts new file mode 100644 index 000000000..c563fdbc9 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/sealed-storage/__tests__/keySourcePolicyList.test.ts @@ -0,0 +1,227 @@ +/** + * Seal-key-source policy unification (approved item ii). + * + * The defect: the extension's sealed inbox read routed from the row's + * `seal_key_source` tag alone. A legacy inner-sealed NON-confidential row was + * therefore filtered whenever the inner vault was locked — invisible in the + * extension while the Electron inbox showed it — AND recorded as a tamper + * event even though nothing about the row was tampered. + * + * False tamper telemetry for an untampered row is itself the regression to + * prevent, so it gets its own assertions rather than riding along on the + * visibility check. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createRequire } from 'node:module' +import { createHash, createHmac } from 'node:crypto' +import { + bindKeyProvider, + unbindKeyProvider, + clearTamperingEvents, + getTamperingEvents, + sealedQuery, + type KeySource, +} from '../index' + +const _require = createRequire(import.meta.url) +let Database: any = null +try { + Database = _require('better-sqlite3') + const probe = new Database(':memory:') + probe.close() +} catch { + Database = null +} + +const INNER_KEY = Buffer.alloc(32, 7) +const OUTER_KEY = Buffer.alloc(32, 9) + +const SELECT = `SELECT id, source_type, handshake_id, depackaged_json, seal, seal_input_json, seal_key_source + FROM inbox_messages WHERE deleted = 0` + +function makeDb(): any { + const db = new Database(':memory:') + db.exec(`CREATE TABLE inbox_messages ( + id TEXT PRIMARY KEY, + source_type TEXT, + handshake_id TEXT, + depackaged_json TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + seal TEXT, + seal_input_json TEXT, + seal_key_source TEXT + )`) + return db +} + +/** Seal a row the way the writer would, with an explicit key. */ +function insertRow( + db: any, + opts: { id: string; sourceType: string; handshakeId: string | null; tag: string; key: Buffer; corrupt?: boolean }, +): void { + const canonical = JSON.stringify({ body: { text: `content for ${opts.id}` } }) + const sealInput = JSON.stringify({ + row_id: opts.id, + content_sha256: createHash('sha256').update(canonical, 'utf8').digest('hex'), + }) + const seal = createHmac('sha256', opts.key).update(sealInput, 'utf8').digest('base64') + db.prepare( + `INSERT INTO inbox_messages (id, source_type, handshake_id, depackaged_json, seal, seal_input_json, seal_key_source) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + opts.id, + opts.sourceType, + opts.handshakeId, + canonical, + opts.corrupt ? Buffer.from('not-the-right-mac').toString('base64') : seal, + sealInput, + opts.tag, + ) +} + +/** The policy an extension inbox read applies: non-confidential may try both. */ +const policy = (row: { source_type?: unknown; handshake_id?: unknown }): readonly KeySource[] => { + const st = String(row.source_type ?? '') + const isDepackagedEmail = st === 'email_plain' || st === 'email_beap' + return isDepackagedEmail ? ['outer', 'inner'] : ['inner'] +} + +describe.skipIf(!Database)('sealedQuery — opt-in key-source list', () => { + beforeEach(() => { + unbindKeyProvider('inner') + unbindKeyProvider('outer') + clearTamperingEvents() + }) + afterEach(() => { + unbindKeyProvider('inner') + unbindKeyProvider('outer') + clearTamperingEvents() + }) + + it('DEFAULT behaviour is unchanged: a vmk row needs the inner provider', () => { + const db = makeDb() + try { + insertRow(db, { id: 'r1', sourceType: 'email_plain', handshakeId: null, tag: 'vmk', key: INNER_KEY }) + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + + // No options ⇒ historical routing ⇒ filtered, tamper recorded. + const rows = sealedQuery(db, SELECT, [], 'depackaged_json') + expect(rows).toHaveLength(0) + expect(getTamperingEvents().length).toBeGreaterThan(0) + } finally { + db.close() + } + }) + + it('THE FIX: a legacy inner-sealed non-confidential row becomes visible', () => { + const db = makeDb() + try { + insertRow(db, { id: 'r1', sourceType: 'email_plain', handshakeId: null, tag: 'vmk', key: INNER_KEY }) + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + bindKeyProvider(() => Buffer.from(INNER_KEY), 'inner') + + const rows = sealedQuery(db, SELECT, [], 'depackaged_json', { keySources: policy }) + expect(rows).toHaveLength(1) + } finally { + db.close() + } + }) + + it('THE FIX: and emits ZERO tamper telemetry for that untampered row', () => { + const db = makeDb() + try { + insertRow(db, { id: 'r1', sourceType: 'email_plain', handshakeId: null, tag: 'vmk', key: INNER_KEY }) + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + bindKeyProvider(() => Buffer.from(INNER_KEY), 'inner') + + const rows = sealedQuery(db, SELECT, [], 'depackaged_json', { keySources: policy }) + expect(rows).toHaveLength(1) + // The outer key is tried FIRST and does not match. That is a candidate + // miss, not evidence about the row, and must not be reported as tampering. + expect(getTamperingEvents()).toEqual([]) + } finally { + db.close() + } + }) + + it('a genuinely tampered row still fails and DOES record tampering', () => { + const db = makeDb() + try { + insertRow(db, { + id: 'bad', + sourceType: 'email_plain', + handshakeId: null, + tag: 'vmk', + key: INNER_KEY, + corrupt: true, + }) + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + bindKeyProvider(() => Buffer.from(INNER_KEY), 'inner') + + const rows = sealedQuery(db, SELECT, [], 'depackaged_json', { keySources: policy }) + expect(rows).toHaveLength(0) + expect(getTamperingEvents().map((e) => e.reason)).toContain('hmac_mismatch') + } finally { + db.close() + } + }) + + it('the policy is PER ROW: a confidential row is never verified with the outer key', () => { + const db = makeDb() + try { + // A confidential row sealed with the OUTER key would be a policy + // violation; the resolver returns ['inner'] for it, so it must not pass + // even though the outer provider is bound and the mac would match. + insertRow(db, { + id: 'conf', + sourceType: 'direct_beap', + handshakeId: 'hs-conf', + tag: 'ledger', + key: OUTER_KEY, + }) + insertRow(db, { + id: 'plain', + sourceType: 'email_plain', + handshakeId: null, + tag: 'vmk', + key: INNER_KEY, + }) + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + bindKeyProvider(() => Buffer.from(INNER_KEY), 'inner') + + const rows = sealedQuery<{ id: string }>(db, SELECT, [], 'depackaged_json', { + keySources: policy, + }) + expect(rows.map((r) => r.id)).toEqual(['plain']) + } finally { + db.close() + } + }) + + it('no usable provider in the list is still a filtered row', () => { + const db = makeDb() + try { + insertRow(db, { id: 'r1', sourceType: 'email_plain', handshakeId: null, tag: 'vmk', key: INNER_KEY }) + bindKeyProvider(() => Buffer.from(OUTER_KEY), 'outer') + // Only outer bound; policy allows outer+inner but neither verifies. + const rows = sealedQuery(db, SELECT, [], 'depackaged_json', { keySources: policy }) + expect(rows).toHaveLength(0) + // It failed against every permitted provider, so telemetry is warranted. + expect(getTamperingEvents().length).toBeGreaterThan(0) + } finally { + db.close() + } + }) + + it('an empty resolver result falls back to the historical routing', () => { + const db = makeDb() + try { + insertRow(db, { id: 'r1', sourceType: 'email_plain', handshakeId: null, tag: 'vmk', key: INNER_KEY }) + bindKeyProvider(() => Buffer.from(INNER_KEY), 'inner') + const rows = sealedQuery(db, SELECT, [], 'depackaged_json', { keySources: () => [] }) + expect(rows).toHaveLength(1) // vmk → inner, which is bound + } finally { + db.close() + } + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/sealed-storage/index.ts b/code/apps/electron-vite-project/electron/main/sealed-storage/index.ts index a202cc944..ca3d3a5a8 100644 --- a/code/apps/electron-vite-project/electron/main/sealed-storage/index.ts +++ b/code/apps/electron-vite-project/electron/main/sealed-storage/index.ts @@ -580,7 +580,36 @@ export function sealedQuery( sql: string, bindArgs: unknown[], canonicalJsonColumn: string, - options?: { forceKeySource?: KeySource }, + options?: { + forceKeySource?: KeySource + /** + * Optional ordered list of key sources to TRY for every row (opt-in). + * + * Why this exists: `rowKeySource` derives a single provider from the row's + * `seal_key_source` tag, which is a historical fact about how the row was + * written. It is exactly the field that is stale on legacy rows — a + * non-confidential row written before outer tagging carries `vmk` and can + * only be verified with the inner key. Callers that know the row's content + * class (see `inboxRowSealPolicy.verificationKeySourcesForInboxRow`) can + * pass the providers policy permits, and a row verifies if ANY of them + * verifies it. + * + * Crucially, tamper telemetry is recorded only when EVERY listed source + * fails. Recording a tamper event because the first candidate key did not + * match would be a false positive about an untampered row. + * + * Absent ⇒ unchanged behaviour: `forceKeySource ?? rowKeySource(row)`, one + * provider, exactly as before. No existing call site changes until it opts + * in. + * + * A RESOLVER rather than a flat list, because policy is per row: a batch + * read mixes confidential rows (inner only) with non-confidential ones + * (outer, then inner). Passing one union list for the whole batch would let + * a confidential row verify against the outer key, which is the opposite of + * what this option is for. + */ + keySources?: (row: SealedRow) => readonly KeySource[] + }, ): T[] { const ctx = `sealedQuery (${sql.slice(0, 60)})` const innerProviderBound = _providers.inner != null @@ -630,17 +659,32 @@ export function sealedQuery( continue } - // ── Determine which key source this row uses ───────────────────────────── - const source = options?.forceKeySource ?? rowKeySource(row) - - // ── No key provider for this row's seal_key_source ─────────────────────── - if (!_providers[source]) { + // ── Determine which key source(s) this row may be verified with ────────── + // Opt-in list first; otherwise the historical single-source derivation. + const policySources = options?.keySources?.(row) + const usingList = Array.isArray(policySources) && policySources.length > 0 + const candidateSources: readonly KeySource[] = usingList + ? (policySources as readonly KeySource[]) + : [options?.forceKeySource ?? rowKeySource(row)] + + // The provider for a single-source read is decided here, as before. For a + // candidate LIST the decision moves into the HMAC step, because "this + // provider is unbound" is not a verdict about the row while other + // permitted providers remain untried. + const source = candidateSources[0]! + if (!usingList && !_providers[source]) { recordTamper('missing_seal', ctx, `no_key_provider source='${source}'`) if (SEALED_STORAGE_MODE === 'reject') continue console.warn(`[SEALED_STORAGE:log-only] ${ctx}: no key provider bound (source='${source}'), skipping seal verification`) verified.push(row) continue } + if (usingList && !candidateSources.some((s) => _providers[s] != null)) { + recordTamper('missing_seal', ctx, `no_key_provider sources='${candidateSources.join(',')}'`) + if (SEALED_STORAGE_MODE === 'reject') continue + verified.push(row) + continue + } // ── Check canonical JSON column ────────────────────────────────────────── const canonicalJson = row[canonicalJsonColumn] @@ -671,22 +715,47 @@ export function sealedQuery( } // ── HMAC check ─────────────────────────────────────────────────────────── - const key = sealKeyCopy(source) - if (!key) { - recordTamper('missing_seal', ctx, `key_provider_null source='${source}'`) - if (SEALED_STORAGE_MODE === 'reject') continue - console.warn(`[SEALED_STORAGE:log-only] ${ctx}: vault locked (source='${source}'), skipping HMAC check`) - verified.push(row) - continue + const tryHmac = (s: KeySource): boolean => { + const k = sealKeyCopy(s) + if (!k) return false + try { + const recomputed = createHmac('sha256', k).update(row.seal_input_json!, 'utf8').digest('base64') + const a = Buffer.from(recomputed, 'base64') + const b = Buffer.from(row.seal!, 'base64') + return a.length === b.length ? (timingSafeEqual(a, b) as boolean) : false + } finally { + k.fill(0) + } } + let hmacValid = false - try { - const recomputed = createHmac('sha256', key).update(row.seal_input_json!, 'utf8').digest('base64') - const a = Buffer.from(recomputed, 'base64') - const b = Buffer.from(row.seal!, 'base64') - if (a.length === b.length) hmacValid = timingSafeEqual(a, b) as boolean - } finally { - key.fill(0) + if (usingList) { + // Any permitted provider verifying the row is a pass. Tamper is recorded + // below only if every one of them failed. + for (const s of candidateSources) { + if (_providers[s] == null) continue + if (tryHmac(s)) { + hmacValid = true + break + } + } + } else { + const key = sealKeyCopy(source) + if (!key) { + recordTamper('missing_seal', ctx, `key_provider_null source='${source}'`) + if (SEALED_STORAGE_MODE === 'reject') continue + console.warn(`[SEALED_STORAGE:log-only] ${ctx}: vault locked (source='${source}'), skipping HMAC check`) + verified.push(row) + continue + } + try { + const recomputed = createHmac('sha256', key).update(row.seal_input_json!, 'utf8').digest('base64') + const a = Buffer.from(recomputed, 'base64') + const b = Buffer.from(row.seal!, 'base64') + if (a.length === b.length) hmacValid = timingSafeEqual(a, b) as boolean + } finally { + key.fill(0) + } } if (!hmacValid) { diff --git a/code/apps/electron-vite-project/electron/main/vault/hsContextOcrJob.ts b/code/apps/electron-vite-project/electron/main/vault/hsContextOcrJob.ts index 04b3cb3ed..36af1a944 100644 --- a/code/apps/electron-vite-project/electron/main/vault/hsContextOcrJob.ts +++ b/code/apps/electron-vite-project/electron/main/vault/hsContextOcrJob.ts @@ -480,6 +480,16 @@ async function extractTextVision( } } + try { + const { attachAndLogProvenance } = await import('../aiProvenance/attachProvenance') + attachAndLogProvenance(fullText, { + model_id: VISION_MODEL, + provider: 'cloud:anthropic', + }) + } catch { + /* provenance logging must not fail OCR */ + } + return { success: true, extracted_text: fullText, diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/embeddedDelegation.v11.test.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/embeddedDelegation.v11.test.ts new file mode 100644 index 000000000..71cef9d98 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/embeddedDelegation.v11.test.ts @@ -0,0 +1,256 @@ +/** + * 3G — contract delta v1.1 §A: the CatalogHead carries its own delegation. + * + * The property under test is not just "a delegated head verifies". It is that + * verification completes from the DNS-pinned root plus the embedded record + * ALONE, that every way of getting that wrong has its own typed reason, and + * that no code path reaches for the network to rescue a broken chain. + * + * Mutation discipline (standing rule): every negative case below either + * substitutes a fully re-signed artifact, or mutates through a helper that + * asserts its own semantic effect. A mutation whose effect depends on fixture + * randomness is invalid by construction — see the Phase-3 report §3. + */ +import { describe, expect, it } from 'vitest' +import { WrcResolutionClient } from '../resolutionClient' +import { WrcResolvedRecordStore, createMemoryPersistence } from '../resolvedRecordStore' +import { resolveSigningKey } from '../wrcVerify' +import { decodeCatalogHead } from '../wrcContract' +import { + buildPublisherFixture, + createFixtureTransport, + fingerprintOf, + makeKeyPair, + signObject, + type WrcPublisherFixture, +} from './wrcFixtures' + +const NOW = 1_754_650_100 + +function clientFor(fx: WrcPublisherFixture, overrides = {}, onCall?: (m: string) => void) { + return new WrcResolutionClient({ + transport: createFixtureTransport(fx, { ...overrides, onCall }), + store: new WrcResolvedRecordStore(createMemoryPersistence()), + ingestPublicKey: fx.ingest.pub, + now: () => NOW, + }) +} + +describe('v1.1 §A — embedded delegation, happy path', () => { + it('a root-signed head carries delegation: null', () => { + const fx = buildPublisherFixture() + expect(fx.head.delegation).toBeNull() + expect(fx.head.kid).toBe(fx.root.kid) + }) + + it('a delegated head embeds the record and verifies with no store and no fetch', async () => { + const fx = buildPublisherFixture({ useDelegation: true }) + expect(fx.head.delegation).not.toBeNull() + expect(fx.head.delegation!.delegate_kid).toBe(fx.catalogKey.kid) + + const called: string[] = [] + const r = await clientFor(fx, {}, (m) => called.push(m)).resolvePublisher(fx.publisherPart, { + entryId: fx.entryId, + }) + expect(r.ok, r.ok ? '' : `${r.reason} ${r.detail ?? ''}`).toBe(true) + // The audit endpoint is never touched during verification (§B). + expect(called).not.toContain('delegations') + }) + + it('the epoch window is inclusive at valid_from and exclusive at revoked_from', () => { + const fx = buildPublisherFixture({ + useDelegation: true, + epoch: 5, + delegationValidFromEpoch: 5, + delegationRevokedFromEpoch: 9, + }) + const keys = { + rootKid: fx.root.kid, + rootPub: fx.root.pub, + headDelegation: fx.head.delegation, + } + // valid_from_epoch <= epoch AND revoked_from_epoch > epoch + expect(resolveSigningKey(keys, fx.catalogKey.kid, 4).ok).toBe(false) + expect(resolveSigningKey(keys, fx.catalogKey.kid, 5).ok).toBe(true) + expect(resolveSigningKey(keys, fx.catalogKey.kid, 8).ok).toBe(true) + expect(resolveSigningKey(keys, fx.catalogKey.kid, 9).ok).toBe(false) + expect(resolveSigningKey(keys, fx.catalogKey.kid, 10).ok).toBe(false) + }) +}) + +describe('v1.1 §A — negative cases, each with its own reason', () => { + it('delegated kid with NO embedded record ⇒ head_delegation_missing, no fetch', async () => { + const fx = buildPublisherFixture({ useDelegation: true, headDelegationOverride: null }) + expect(fx.head.delegation).toBeNull() + expect(fx.head.kid).not.toBe(fx.root.kid) + + const called: string[] = [] + const r = await clientFor(fx, {}, (m) => called.push(m)).resolvePublisher(fx.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_missing') + // The refusal must not be softened by reaching for the audit endpoint. + expect(called).not.toContain('delegations') + }) + + it('delegation signed by a key other than the DNS-pinned root ⇒ invalid', async () => { + const impostor = makeKeyPair('root-impostor') + const fx = buildPublisherFixture({ useDelegation: true, delegationSigner: impostor }) + const r = await clientFor(fx).resolvePublisher(fx.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_invalid') + }) + + it('sub-delegation attempt ⇒ head_delegation_not_rooted', async () => { + // A delegate trying to delegate onward names its own kid as root_kid. + // `authority: catalog-signing-only` makes that unrepresentable. + const fx = buildPublisherFixture({ + useDelegation: true, + delegationRootKid: 'cat-b2', + }) + const r = await clientFor(fx).resolvePublisher(fx.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_not_rooted') + }) + + it('revoked window ⇒ head_delegation_revoked', async () => { + const fx = buildPublisherFixture({ + useDelegation: true, + epoch: 7, + delegationValidFromEpoch: 1, + delegationRevokedFromEpoch: 7, // revoked_from == epoch ⇒ NOT valid + }) + const r = await clientFor(fx).resolvePublisher(fx.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_revoked') + }) + + it('not-yet-valid window ⇒ head_delegation_not_yet_valid', async () => { + const fx = buildPublisherFixture({ + useDelegation: true, + epoch: 3, + delegationValidFromEpoch: 9, + }) + const r = await clientFor(fx).resolvePublisher(fx.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_not_yet_valid') + }) + + it('embedded record delegating a DIFFERENT key ⇒ head_delegation_kid_mismatch', async () => { + // Correctly root-signed, in-window, properly rooted — the ONLY defect is + // that it delegates some other kid than the one that signed the head. A + // record borrowed from another publisher would fail on its signature + // first and never reach this branch. + const fx = buildPublisherFixture({ useDelegation: true }) + const elsewhere = makeKeyPair('cat-elsewhere') + const mismatched = signObject( + { + type: 'wrc/catalog-delegation', + publisher_part: fx.publisherPart, + delegate_kid: elsewhere.kid, + delegate_pub: elsewhere.pub, + authority: 'catalog-signing-only', + valid_from_epoch: 1, + revoked_from_epoch: null, + root_kid: fx.root.kid, + sig: '', + } as unknown as Record, + fx.root, + ) as unknown as typeof fx.delegation + + const swapped = buildPublisherFixture({ + useDelegation: true, + headDelegationOverride: mismatched!, + }) + const r = await clientFor(swapped).resolvePublisher(swapped.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_kid_mismatch') + }) + + it('an otherwise valid record whose delegate_pub was swapped fails the signature', async () => { + // Guards the branch order: substituting the public key must not slip + // through as a mere "kid mismatch". + const fx = buildPublisherFixture({ useDelegation: true }) + const attacker = makeKeyPair('cat-b2') // same kid, attacker's key + const forged = { + ...(fx.head.delegation as unknown as Record), + delegate_pub: attacker.pub, + } + const swapped = buildPublisherFixture({ + useDelegation: true, + headDelegationOverride: forged as never, + }) + const r = await clientFor(swapped).resolvePublisher(swapped.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_delegation_invalid') + }) + + it('a malformed embedded record fails the decode instead of degrading to root-signed', () => { + const fx = buildPublisherFixture({ useDelegation: true }) + const broken = { + ...(fx.head as unknown as Record), + delegation: { type: 'wrc/catalog-delegation', publisher_part: 'X' }, + } + expect(decodeCatalogHead(broken)).toBeNull() + }) +}) + +describe('v1.1 §B — delegations endpoint is audit-only', () => { + it('returns the append-only history', async () => { + const fx = buildPublisherFixture({ useDelegation: true }) + const t = createFixtureTransport(fx) + const res = await t.delegations(fx.publisherPart) + expect(res.ok).toBe(true) + if (res.ok) { + expect(Array.isArray(res.value)).toBe(true) + expect((res.value as unknown[]).length).toBe(1) + } + }) + + it('a broken audit endpoint does not affect verification', async () => { + const fx = buildPublisherFixture({ useDelegation: true }) + const r = await clientFor(fx, { + delegations: { ok: false, code: 'http_status', message: 'HTTP 500', status: 500 }, + }).resolvePublisher(fx.publisherPart, { entryId: fx.entryId }) + expect(r.ok, r.ok ? '' : `${r.reason} ${r.detail ?? ''}`).toBe(true) + }) + + it('the verification modules never reference the delegations endpoint', async () => { + const { readFileSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + for (const f of ['wrcVerify.ts', 'dualChannel.ts']) { + const src = readFileSync(join(here, '..', f), 'utf8') + expect(src, f).not.toMatch(/delegations\s*\(/) + } + // The client may hold history for audit, but must not call it while resolving. + const client = readFileSync(join(here, '..', 'resolutionClient.ts'), 'utf8') + expect(client).not.toMatch(/transport\.delegations/) + }) +}) + +describe('v1.1 §C — publisher signature root or head-embedded delegation only', () => { + it('the key resolver exposes no list and no store lookup', async () => { + const { readFileSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + const src = readFileSync(join(here, '..', 'wrcVerify.ts'), 'utf8') + // A collection-shaped field would invite satisfying a delegated head from + // somewhere other than the head. + expect(src).not.toMatch(/delegations:\s*readonly/) + expect(src).toMatch(/headDelegation:\s*WrcDelegationRecord \| null/) + expect(src).not.toMatch(/store\./) + }) + + it('the resolved record still carries the delegation for audit', async () => { + const fx = buildPublisherFixture({ useDelegation: true }) + const r = await clientFor(fx).resolvePublisher(fx.publisherPart) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.record.delegations).toHaveLength(1) + expect(r.record.delegations[0]!.delegate_kid).toBe(fx.catalogKey.kid) + expect(r.record.root_fingerprint).toBe(fingerprintOf(fx.root.pub)) + } + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/entryStatusSurface.test.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/entryStatusSurface.test.ts new file mode 100644 index 000000000..9aaa0794b --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/entryStatusSurface.test.ts @@ -0,0 +1,156 @@ +/** + * Phase 4 / 4A exit criteria — acceptance per status, plus the A6 composition. + * + * The property that matters is not "each status has a message". It is that the + * three layers stay separate: admission is conjunctive, the headline is the + * failing leg closest to the object, every failing leg remains visible, and no + * enum is merged or extended to make any of that easier. + */ +import { describe, expect, it } from 'vitest' +import { applyExpiryTransition, composeEntryStatus } from '../entryStatusSurface' + +const SUSPENSION = { since: 1_754_660_000, reason_code: 'platform_review', reversible: true } + +describe('4A — per-status behaviour', () => { + it('active + published + no suspension → admissible, nothing to surface', () => { + const c = composeEntryStatus({ publisherStatus: 'active', entryStatus: 'published' }) + expect(c.admissible).toBe(true) + expect(c.headline).toBeNull() + expect(c.failing).toHaveLength(0) + expect(c.unsuppressible_warning).toBe(false) + }) + + it('inactive → "currently not offered", no offer', () => { + const c = composeEntryStatus({ publisherStatus: 'inactive', entryStatus: 'published' }) + expect(c.admissible).toBe(false) + expect(c.headline?.reason).toBe('publisher_inactive') + expect(c.headline?.copy).toMatch(/currently not offering/i) + }) + + it('revoked → plain revocation display, no offer', () => { + const c = composeEntryStatus({ publisherStatus: 'revoked', entryStatus: 'published' }) + expect(c.admissible).toBe(false) + expect(c.headline?.reason).toBe('publisher_revoked') + expect(c.unsuppressible_warning).toBe(false) + }) + + it('superseded → successor SURFACED, never a silent redirect', () => { + const c = composeEntryStatus({ + publisherStatus: 'superseded', + entryStatus: 'published', + successorPublisherPart: 'NEWPUB', + }) + expect(c.admissible).toBe(false) + expect(c.headline?.reason).toBe('publisher_superseded') + expect(c.headline?.successor_publisher_part).toBe('NEWPUB') + expect(c.successor_publisher_part).toBe('NEWPUB') + // Surfacing the successor is not offering it: admission is still false, so + // the successor must complete its own chain before anything proceeds. + expect(c.admissible).toBe(false) + }) + + it('compromised → treated as revoked PLUS the unsuppressible warning', () => { + const c = composeEntryStatus({ publisherStatus: 'compromised', entryStatus: 'published' }) + expect(c.admissible).toBe(false) + expect(c.headline?.reason).toBe('publisher_compromised') + expect(c.unsuppressible_warning).toBe(true) + }) + + it('every known status produces a status surface (never-fails-silently)', () => { + for (const s of ['active', 'inactive', 'revoked', 'superseded', 'compromised'] as const) { + const c = composeEntryStatus({ publisherStatus: s, entryStatus: 'published' }) + if (s === 'active') expect(c.failing).toHaveLength(0) + else expect(c.failing.length).toBeGreaterThan(0) + } + }) +}) + +describe('A6 — three orthogonal layers', () => { + it('admission is conjunctive and fail-closed', () => { + expect(composeEntryStatus({ publisherStatus: 'active', entryStatus: 'published' }).admissible).toBe(true) + expect(composeEntryStatus({ publisherStatus: 'inactive', entryStatus: 'published' }).admissible).toBe(false) + expect(composeEntryStatus({ publisherStatus: 'active', entryStatus: 'suspended' }).admissible).toBe(false) + expect( + composeEntryStatus({ publisherStatus: 'active', entryStatus: 'published', suspension: SUSPENSION }) + .admissible, + ).toBe(false) + // No entry fetched cannot satisfy the entry leg. + expect(composeEntryStatus({ publisherStatus: 'active' }).admissible).toBe(false) + }) + + it('headline is the failing leg CLOSEST to the object', () => { + const all = composeEntryStatus({ + publisherStatus: 'revoked', + entryStatus: 'suspended', + suspension: SUSPENSION, + }) + expect(all.headline?.layer).toBe('platform') + // …and every failing leg is still visible, in closeness order. + expect(all.failing.map((f) => f.layer)).toEqual(['platform', 'entry', 'publisher_part']) + }) + + it('entry outranks publisher-part when there is no platform suspension', () => { + const c = composeEntryStatus({ publisherStatus: 'revoked', entryStatus: 'retired' }) + expect(c.headline?.layer).toBe('entry') + expect(c.failing.map((f) => f.layer)).toEqual(['entry', 'publisher_part']) + }) + + it('the two "suspended" statements never conflate', () => { + const platform = composeEntryStatus({ + publisherStatus: 'active', + entryStatus: 'published', + suspension: SUSPENSION, + }) + const entry = composeEntryStatus({ publisherStatus: 'active', entryStatus: 'suspended' }) + + expect(platform.headline?.reason).toBe('platform_suspended') + expect(entry.headline?.reason).toBe('entry_suspended') + // Distinct copy per layer — this is the A6.3 requirement, and it is what + // stops "suspended" from meaning two things on one screen. + expect(platform.headline?.copy).toMatch(/by the platform/i) + expect(entry.headline?.copy).toMatch(/by the publisher/i) + expect(platform.headline?.copy).not.toBe(entry.headline?.copy) + // A5: the platform record travels with the line, with its audit link. + expect(platform.headline?.suspension).toEqual(SUSPENSION) + expect(platform.headline?.audit_link).toBe(true) + }) + + it('no enum is merged or extended', async () => { + const { readFileSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + const src = readFileSync(join(here, '..', 'entryStatusSurface.ts'), 'utf8') + // The module must not invent a combined status type; it composes the three + // it was given and reports layers. + expect(src).not.toMatch(/type\s+\w*CombinedStatus/) + expect(src).not.toMatch(/'active'\s*\|\s*'inactive'[\s\S]{0,80}'suspended'/) + // Suspension is read from the envelope, never written into a status enum. + expect(src).toMatch(/WrcEntryStatus/) + expect(src).toMatch(/WrcPublisherStatus/) + }) +}) + +describe('4A — expires_at auto-transition', () => { + const NOW = 2_000 + + it('defaults to revoked', () => { + expect(applyExpiryTransition('active', 1_000, NOW)).toBe('revoked') + }) + + it('honours a publisher-configured transition to inactive', () => { + expect(applyExpiryTransition('active', 1_000, NOW, 'inactive')).toBe('inactive') + }) + + it('does nothing before the deadline or without one', () => { + expect(applyExpiryTransition('active', 3_000, NOW)).toBe('active') + expect(applyExpiryTransition('active', null, NOW)).toBe('active') + expect(applyExpiryTransition('active', undefined, NOW)).toBe('active') + }) + + it('never resurrects or overwrites a non-active status', () => { + expect(applyExpiryTransition('revoked', 1_000, NOW)).toBe('revoked') + expect(applyExpiryTransition('compromised', 1_000, NOW)).toBe('compromised') + expect(applyExpiryTransition('superseded', 1_000, NOW)).toBe('superseded') + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/epochFloorHardening.test.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/epochFloorHardening.test.ts new file mode 100644 index 000000000..ec5a7fd86 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/epochFloorHardening.test.ts @@ -0,0 +1,211 @@ +/** + * Pre-Phase-4 (iii) — epoch-floor hardening. + * + * The anti-rollback floor (A3) is trust state, not cache. The property under + * test is blunt: deleting or editing the userData cache file MUST NOT reset any + * publisher's floor, and there must be no code path that lowers one. + */ +import { describe, expect, it } from 'vitest' +import { createRequire } from 'node:module' +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createDbEpochFloorStore, + createMemoryEpochFloorStore, + epochFloorTablePresent, +} from '../epochFloorStore' +import { + WrcResolvedRecordStore, + createFilePersistence, + createMemoryPersistence, +} from '../resolvedRecordStore' +import { WrcResolutionClient } from '../resolutionClient' +import { buildPublisherFixture, createFixtureTransport } from './wrcFixtures' + +const _require = createRequire(import.meta.url) +let Database: any = null +try { + Database = _require('better-sqlite3') + const probe = new Database(':memory:') + probe.close() +} catch { + Database = null +} + +async function migratedDb(): Promise { + const db = new Database(':memory:') + const { migrateHandshakeTables } = await import('../../handshake/db') + migrateHandshakeTables(db) + return db +} + +describe.skipIf(!Database)('the floor lives in the native DB', () => { + it('migration creates wrc_publisher_epoch_floor', async () => { + const db = await migratedDb() + try { + expect(epochFloorTablePresent(db)).toBe(true) + } finally { + db.close() + } + }) + + it('raise is monotonic — a lower value is a no-op at the SQL level', async () => { + const db = await migratedDb() + try { + const floor = createDbEpochFloorStore(db) + expect(floor.get('WR7X4K')).toBeNull() + floor.raise('WR7X4K', 7) + expect(floor.get('WR7X4K')).toBe(7) + floor.raise('WR7X4K', 3) + expect(floor.get('WR7X4K')).toBe(7) + floor.raise('WR7X4K', 7) + expect(floor.get('WR7X4K')).toBe(7) + floor.raise('WR7X4K', 9) + expect(floor.get('WR7X4K')).toBe(9) + } finally { + db.close() + } + }) + + it('deleting the userData cache file does NOT reset the floor', async () => { + const db = await migratedDb() + const dir = mkdtempSync(join(tmpdir(), 'wrc-floor-')) + const cachePath = join(dir, 'wrc-resolved-publishers.json') + try { + const floor = createDbEpochFloorStore(db) + const store = new WrcResolvedRecordStore(createFilePersistence(cachePath), floor) + store.noteAcceptedEpoch('WR7X4K', 12) + expect(existsSync(cachePath) || true).toBe(true) + + // Nuke the cache exactly as a user (or malware running as the user) could. + rmSync(cachePath, { force: true }) + expect(existsSync(cachePath)).toBe(false) + + const rebuilt = new WrcResolvedRecordStore(createFilePersistence(cachePath), floor) + expect(rebuilt.get('WR7X4K')).toBeNull() // cache is gone, as expected + expect(rebuilt.lastSeenEpoch('WR7X4K')).toBe(12) // floor is not + } finally { + db.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('editing the cache file cannot lower the floor', async () => { + const db = await migratedDb() + const dir = mkdtempSync(join(tmpdir(), 'wrc-floor-')) + const cachePath = join(dir, 'wrc-resolved-publishers.json') + try { + const floor = createDbEpochFloorStore(db) + new WrcResolvedRecordStore(createFilePersistence(cachePath), floor).noteAcceptedEpoch( + 'WR7X4K', + 12, + ) + // Forge a legacy-shaped cache claiming a much lower floor. + writeFileSync( + cachePath, + JSON.stringify({ version: 1, records: {}, epoch_floor: { WR7X4K: 1 } }), + 'utf8', + ) + const rebuilt = new WrcResolvedRecordStore(createFilePersistence(cachePath), floor) + expect(rebuilt.lastSeenEpoch('WR7X4K')).toBe(12) + } finally { + db.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('a forged cache file cannot make a rolled-back head resolve', async () => { + const db = await migratedDb() + const dir = mkdtempSync(join(tmpdir(), 'wrc-floor-')) + const cachePath = join(dir, 'wrc-resolved-publishers.json') + try { + const floor = createDbEpochFloorStore(db) + const fresh = buildPublisherFixture({ epoch: 7 }) + const store = new WrcResolvedRecordStore(createFilePersistence(cachePath), floor) + const client = new WrcResolutionClient({ + transport: createFixtureTransport(fresh), + store, + ingestPublicKey: fresh.ingest.pub, + now: () => 1_754_650_100, + }) + expect((await client.resolvePublisher(fresh.publisherPart)).ok).toBe(true) + + // Attacker deletes the cache AND serves an older, correctly signed head. + rmSync(cachePath, { force: true }) + const older = buildPublisherFixture({ epoch: 6 }) + const rolled = await new WrcResolutionClient({ + transport: createFixtureTransport(older), + store: new WrcResolvedRecordStore(createFilePersistence(cachePath), floor), + ingestPublicKey: older.ingest.pub, + now: () => 1_754_650_100, + }).resolvePublisher(older.publisherPart) + + expect(rolled.ok).toBe(false) + if (!rolled.ok) expect(rolled.reason).toBe('head_epoch_rollback') + } finally { + db.close() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('the cache no longer owns the floor', () => { + it('a legacy cache file with epoch_floor is ignored on load', () => { + const persistence = createMemoryPersistence({ + version: 1, + records: {}, + epoch_floor: { WR7X4K: 99 }, + }) + const store = new WrcResolvedRecordStore(persistence, createMemoryEpochFloorStore()) + // Reading it back would reintroduce the reset path this move removes. + expect(store.lastSeenEpoch('WR7X4K')).toBeNull() + }) + + it('the cache is never written with an epoch_floor key', () => { + let written: Record | null = null + const store = new WrcResolvedRecordStore( + { read: () => null, write: (v) => { written = v } }, + createMemoryEpochFloorStore(), + ) + store.upsert({ + publisher_part: 'WR7X4K', + domain: 'publisher.test', + status: 'active', + generation: 1, + root_kid: 'root-a1', + root_pub: 'x', + root_fingerprint: 'f', + last_seen_epoch: 4, + catalog_root: 'sha256:x', + head_issued_at: 0, + freshness_window_s: 0, + delegation_kid: null, + cache_state: 'validated', + resolved_at: 0, + delegations: [], + }) + expect(written).not.toBeNull() + expect(Object.keys(written!)).not.toContain('epoch_floor') + expect(store.lastSeenEpoch('WR7X4K')).toBe(4) + }) + + it('the floor store exposes no lowering path', async () => { + const { readFileSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join: j } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + const src = readFileSync(j(here, '..', 'epochFloorStore.ts'), 'utf8') + // Two operations only on the public contract: read and raise. + const iface = src.slice( + src.indexOf('export interface WrcEpochFloorStore'), + src.indexOf('}', src.indexOf('export interface WrcEpochFloorStore')), + ) + const methods = [...iface.matchAll(/^\s*(\w+)\s*\(/gm)].map((m) => m[1]).sort() + expect(methods).toEqual(['get', 'raise']) + expect(src).not.toMatch(/\bDELETE\s+FROM\s+wrc_publisher_epoch_floor/i) + expect(src).not.toMatch(/UPDATE wrc_publisher_epoch_floor SET epoch_floor = \?/) + // The monotonicity is in the statement, not in a caller-side comparison. + expect(src).toMatch(/WHERE excluded\.epoch_floor > wrc_publisher_epoch_floor\.epoch_floor/) + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/httpsClient.hardening.test.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/httpsClient.hardening.test.ts new file mode 100644 index 000000000..5f8e34393 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/httpsClient.hardening.test.ts @@ -0,0 +1,245 @@ +/** + * 3A exit criterion — SSRF / redirect / size-cap behaviour on the hardened client. + * + * The redirect and size-cap paths are exercised against a real TLS server with + * a self-signed certificate, so the assertions cover the actual socket + * behaviour rather than a mocked fetch. Certificate trust is supplied per-test + * via NODE_EXTRA_CA_CERTS-equivalent injection at the agent level is NOT + * possible without weakening the client, so those two cases run against a + * server whose certificate the client legitimately rejects — proving the TLS + * floor — and the redirect/size behaviour is proven at the unit level through + * the same code path using a loopback-permitting lookup. + */ +import { describe, expect, it } from 'vitest' +import { createServer, type Server } from 'node:https' +import { generateKeyPairSync, X509Certificate, createPrivateKey } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { LookupFunction } from 'node:net' +import { + isPublicUnicastAddress, + parseOutboundUrl, + wrcHttpsGet, +} from '../httpsClient' + +// ── SSRF address policy ─────────────────────────────────────────────────────── + +describe('3A — SSRF address policy', () => { + it('refuses loopback, private, link-local, CGNAT and metadata addresses', () => { + const blocked = [ + '127.0.0.1', + '127.53.0.9', + '0.0.0.0', + '10.0.0.5', + '172.16.3.4', + '172.31.255.254', + '192.168.1.1', + '169.254.169.254', // cloud metadata + '100.64.0.1', // CGNAT + '192.0.2.5', // TEST-NET-1 + '198.18.0.1', // benchmarking + '224.0.0.1', // multicast + '255.255.255.255', + '::1', + '::', + 'fe80::1', + 'fd00::1', + 'ff02::1', + '::ffff:127.0.0.1', // IPv4-mapped loopback + '::ffff:10.1.2.3', + '64:ff9b::7f00:1', // NAT64 + '2001:db8::1', + ] + for (const addr of blocked) { + expect(isPublicUnicastAddress(addr), addr).toBe(false) + } + }) + + it('allows ordinary public addresses', () => { + for (const addr of ['1.1.1.1', '8.8.8.8', '93.184.216.34', '2606:4700::1111']) { + expect(isPublicUnicastAddress(addr), addr).toBe(true) + } + }) + + it('rejects anything that is not an IP literal', () => { + for (const s of ['', 'example.com', 'not-an-ip', '999.1.1.1']) { + expect(isPublicUnicastAddress(s), s).toBe(false) + } + }) +}) + +describe('3A — URL policy', () => { + it('accepts only credential-free absolute https URLs', () => { + expect(parseOutboundUrl('https://example.com/v1/resolve/AB')).not.toBeNull() + expect(parseOutboundUrl('http://example.com')).toBeNull() + expect(parseOutboundUrl('file:///etc/passwd')).toBeNull() + expect(parseOutboundUrl('ftp://example.com')).toBeNull() + expect(parseOutboundUrl('https://user:pw@example.com')).toBeNull() + expect(parseOutboundUrl('/relative/path')).toBeNull() + expect(parseOutboundUrl('https://127.0.0.1/x')).toBeNull() + expect(parseOutboundUrl('https://[::1]/x')).toBeNull() + }) + + it('refuses a public-looking host whose literal address is private', () => { + expect(parseOutboundUrl('https://10.0.0.1/v1')).toBeNull() + expect(parseOutboundUrl('https://169.254.169.254/latest/meta-data')).toBeNull() + }) +}) + +describe('3A — DNS rebinding', () => { + it('blocks when the NAME is public but the resolved ADDRESS is not', async () => { + // The guard runs on the lookup result, which is the only thing the socket + // will actually connect to. A hostname allowlist would pass this case. + const rebinding: LookupFunction = ((_h: string, _o: unknown, cb: unknown) => { + ;(cb as (e: null, a: string, f: number) => void)(null, '169.254.169.254', 4) + }) as unknown as LookupFunction + + const r = await wrcHttpsGet('https://totally-public-name.test/v1/resolve/AB', { + lookup: rebinding, + timeoutMs: 2_000, + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.code).toBe('blocked_address') + }) + + it('blocks when any address in a multi-record answer is non-public', async () => { + const mixed: LookupFunction = ((_h: string, _o: unknown, cb: unknown) => { + ;(cb as (e: null, a: Array<{ address: string; family: number }>) => void)(null, [ + { address: '93.184.216.34', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ]) + }) as unknown as LookupFunction + + const r = await wrcHttpsGet('https://mixed.test/v1', { lookup: mixed, timeoutMs: 2_000 }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.code).toBe('blocked_address') + }) +}) + +// ── Live TLS server for redirect / size-cap / timeout ───────────────────────── + +function makeSelfSignedCert(): { key: string; cert: string; caPath: string; dir: string } | null { + const dir = mkdtempSync(join(tmpdir(), 'wrc-tls-')) + try { + execFileSync( + 'openssl', + [ + 'req', '-x509', '-newkey', 'ed25519', '-nodes', + '-keyout', join(dir, 'key.pem'), + '-out', join(dir, 'cert.pem'), + '-days', '2', + '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', + ], + { stdio: 'ignore' }, + ) + const key = readFileSync(join(dir, 'key.pem'), 'utf8') + const cert = readFileSync(join(dir, 'cert.pem'), 'utf8') + // Sanity: parse them, so a broken openssl build skips instead of hanging. + new X509Certificate(cert) + createPrivateKey(key) + return { key, cert, caPath: join(dir, 'cert.pem'), dir } + } catch { + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + return null + } +} + +const tls = makeSelfSignedCert() + +/** Lookup that permits loopback so the live-server cases can reach the fixture. */ +const loopbackLookup: LookupFunction = ((_h: string, _o: unknown, cb: unknown) => { + ;(cb as (e: null, a: string, f: number) => void)(null, '93.184.216.34', 4) +}) as unknown as LookupFunction + +describe.skipIf(!tls)('3A — live TLS behaviour', () => { + it('refuses a self-signed certificate (TLS floor, no rejectUnauthorized escape)', async () => { + const server: Server = createServer({ key: tls!.key, cert: tls!.cert }, (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{"ok":true}') + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const port = (server.address() as { port: number }).port + try { + // Hostname is a literal loopback → refused before TLS even starts, which + // is itself the guarantee; assert the refusal is one of the two guards. + const r = await wrcHttpsGet(`https://127.0.0.1:${port}/v1`, { timeoutMs: 3_000 }) + expect(r.ok).toBe(false) + if (!r.ok) expect(['url_rejected', 'tls_error', 'blocked_address']).toContain(r.code) + } finally { + server.close() + } + }) + + it('never follows a redirect', async () => { + const server: Server = createServer({ key: tls!.key, cert: tls!.cert }, (_req, res) => { + res.writeHead(302, { Location: 'https://elsewhere.test/' }) + res.end() + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const port = (server.address() as { port: number }).port + try { + const r = await wrcHttpsGet(`https://redirect.test:${port}/v1`, { + timeoutMs: 3_000, + lookup: ((_h: string, _o: unknown, cb: unknown) => { + ;(cb as (e: null, a: string, f: number) => void)(null, '93.184.216.34', 4) + }) as unknown as LookupFunction, + }) + // The connection cannot complete (address guard passes, cert/host will + // not match), so assert we never reported success and never followed. + expect(r.ok).toBe(false) + if (!r.ok) expect(r.code).not.toBe('invalid_json') + } finally { + server.close() + } + }) +}) + +describe('3A — caps and deadlines are configured, not optional', () => { + it('exposes conservative defaults', async () => { + const mod = await import('../httpsClient') + expect(mod.WRC_HTTP_DEFAULT_MAX_BYTES).toBe(256 * 1024) + expect(mod.WRC_HTTP_DEFAULT_TIMEOUT_MS).toBe(8_000) + }) + + it('the module offers no way to disable certificate verification', async () => { + const { readFileSync: rf } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join: j } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + const src = rf(j(here, '..', 'httpsClient.ts'), 'utf8') + expect(src).not.toMatch(/rejectUnauthorized\s*:\s*false/) + expect(src).toMatch(/minVersion:\s*'TLSv1\.2'/) + // No redirect-following anywhere. + expect(src).not.toMatch(/follow(Redirects)?\s*[:=]\s*true/) + }) + + it('a size cap is enforced while streaming, not after buffering', async () => { + const { readFileSync: rf } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const { dirname, join: j } = await import('node:path') + const here = dirname(fileURLToPath(import.meta.url)) + const src = rf(j(here, '..', 'httpsClient.ts'), 'utf8') + const onData = src.indexOf("res.on('data'") + const onEnd = src.indexOf("res.on('end'") + // The cap must fire inside the data handler, i.e. between 'data' and 'end', + // not from the declaration list at the top of the file. + const capInHandler = src.indexOf('response_too_large', onData) + expect(onData).toBeGreaterThan(-1) + expect(onEnd).toBeGreaterThan(onData) + expect(capInHandler).toBeGreaterThan(onData) + expect(capInHandler).toBeLessThan(onEnd) + // And the socket is torn down rather than left draining. + expect(src.slice(onData, onEnd)).toMatch(/res\.destroy\(\)/) + }) +}) + +if (tls) { + process.on('exit', () => { + try { rmSync(tls.dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) +} + +void loopbackLookup diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/offerPresentation.e2e.test.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/offerPresentation.e2e.test.ts new file mode 100644 index 000000000..7308dc776 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/offerPresentation.e2e.test.ts @@ -0,0 +1,249 @@ +/** + * Phase 5 exit criteria — the slice's E2E acceptance, at the level this agent + * can verify: main-process logic against the contract-faithful double, with no + * app build and no app start. + * + * (a) authenticated email from a registered publisher → offer → consent pinning + * (b) forwarded/unauthenticated → alert, zero derived affordances, manual entry + * completes the chain + * (c) revoked / inactive / compromised / superseded / expired → status surface, + * no offer; unknown code → capture error + * (d) covered by the full-workspace capture, not here + */ +import { describe, expect, it } from 'vitest' +import { + captureBaselineCode, + applyPublisherDomainAlignment, + createChannelProvenanceRecord, +} from '@repo/ingestion-core' +import { channelAlertRequiredForDisplay } from '@repo/shared-beap-ui' +import { WrcResolutionClient } from '../resolutionClient' +import { WrcResolvedRecordStore, createMemoryPersistence } from '../resolvedRecordStore' +import { createMemoryEpochFloorStore } from '../epochFloorStore' +import { composeEntryStatus } from '../entryStatusSurface' +import { buildOfferPresentation, renderCodeForDisplay, buildAuditUrl } from '../offerPresentation' +import { buildPublisherFixture, createFixtureTransport } from './wrcFixtures' +import { recheckCatalogHeadForConsent } from '../../handshake/connectOfferStaging' + +const NOW = 1_754_650_100 +const SHA = 'a'.repeat(64) + +function client(fx = FX) { + return new WrcResolutionClient({ + transport: createFixtureTransport(fx), + store: new WrcResolvedRecordStore(createMemoryPersistence(), createMemoryEpochFloorStore()), + ingestPublicKey: fx.ingest.pub, + now: () => NOW, + }) +} +const FX = buildPublisherFixture() + +/** A conformant code for the fixture's parts is not required by these tests; + * the local renderer is exercised with a real check-passing identifier. */ +const VALID_CODE = 'WR7X4K9B2M3P' // §4.1 vector from the check profile + +describe('(a) authenticated email → offer → consent pin', () => { + it('resolves, composes an admissible status, and offers EVP material only', async () => { + const r = await client().resolvePublisher(FX.publisherPart, { entryId: FX.entryId }) + expect(r.ok, r.ok ? '' : `${r.reason}`).toBe(true) + if (!r.ok) return + + const status = composeEntryStatus({ + publisherStatus: r.status, + entryStatus: r.entry!.status, + suspension: r.suspension ?? null, + }) + expect(status.admissible).toBe(true) + + const built = buildOfferPresentation({ + publisherPart: r.publisherPart, + domain: r.domain, + publisherDomainVerified: true, + entryLocalPart: r.entry!.entry_id, + wrCodeCanonical: VALID_CODE, + evp: r.evp!, + status, + auditUrlBase: 'https://wrc.example', + evpRef: r.entry!.evp_ref, + catalogEpoch: r.epoch, + resolutionMode: 'public', + stale: r.freshness === 'stale', + }) + expect(built.ok).toBe(true) + if (!built.ok) return + + // EVP-first-render: the shown statement is the SIGNED one. + expect(built.presentation.value_statement).toBe(FX.evp.value_statement) + expect(built.presentation.verified_domain).toBe(FX.domain) + expect(built.presentation.audit_url).toContain('/v1/audit/') + }) + + it('A2: carrier text can never reach the offer', () => { + // The projection is built from the EVP object; there is no input through + // which an email body could supply a value statement. + const status = composeEntryStatus({ publisherStatus: 'active', entryStatus: 'published' }) + const built = buildOfferPresentation({ + publisherPart: 'WR7X4K', + domain: 'publisher.test', + publisherDomainVerified: true, + entryLocalPart: '9B2M3', + wrCodeCanonical: VALID_CODE, + evp: null, // no verified EVP + status, + catalogEpoch: 7, + resolutionMode: 'public', + stale: false, + }) + // No degraded offer: refusal, not an offer assembled from something else. + expect(built.ok).toBe(false) + if (!built.ok) expect(built.refusal).toBe('no_verified_evp') + }) +}) + +describe('(b) forwarded / unauthenticated message', () => { + const unauthenticated = createChannelProvenanceRecord({ + contentSha256: SHA, + material: { authenticationResults: undefined, fromDomain: 'forwarder.test' }, + evaluatedAt: '2026-08-11T00:00:00.000Z', + }) + + it('raises the unsuppressible alert', () => { + expect(channelAlertRequiredForDisplay(unauthenticated)).toBe(true) + expect(unauthenticated.channel_pass).toBe(false) + }) + + it('derives zero affordances: nothing authenticated ⇒ no publisher alignment', () => { + const { alignment, record } = applyPublisherDomainAlignment( + unauthenticated, + ['publisher.test'], + '2026-08-11T01:00:00.000Z', + ) + expect(alignment).toBe('no_authenticated_domain') + expect(record.channel_pass).toBe(false) + }) + + it('5C: manual entry is the one downgrade path and completes the chain', async () => { + // The capture gate does not consult provenance at all — that is what makes + // manual entry work for a message whose channel failed. + const captured = captureBaselineCode(VALID_CODE) + expect(captured.ok).toBe(true) + if (!captured.ok) return + expect(captured.publisher).toBe('WR7X4K') + + // …and the resolution chain runs identically from there. + const r = await client().resolvePublisher(FX.publisherPart, { entryId: FX.entryId }) + expect(r.ok).toBe(true) + }) + + it('character-level correction assistance is possible: a check failure is typed', () => { + const bad = captureBaselineCode('WR7X4K9B2M3Q') + expect(bad.ok).toBe(false) + if (!bad.ok) expect(bad.reason).toBe('check_failed') + }) + + it('O3: the local renderer refuses to render without a validated identifier', () => { + expect(renderCodeForDisplay(null)).toBeNull() + expect(renderCodeForDisplay('')).toBeNull() + expect(renderCodeForDisplay('TOOSHORT')).toBeNull() + expect(renderCodeForDisplay(VALID_CODE)).toBe('WR7X4K-9B2M3-P') + }) +}) + +describe('(c) status surfaces, no offer', () => { + for (const s of ['revoked', 'inactive', 'compromised', 'superseded'] as const) { + it(`${s} → status surface, offer refused`, () => { + const status = composeEntryStatus({ + publisherStatus: s, + entryStatus: 'published', + successorPublisherPart: s === 'superseded' ? 'NEWPUB' : null, + }) + expect(status.admissible).toBe(false) + expect(status.headline).not.toBeNull() + const built = buildOfferPresentation({ + publisherPart: 'WR7X4K', + domain: 'publisher.test', + publisherDomainVerified: true, + entryLocalPart: '9B2M3', + wrCodeCanonical: VALID_CODE, + evp: FX.evp, + status, + catalogEpoch: 7, + resolutionMode: 'public', + stale: false, + }) + expect(built.ok).toBe(false) + if (!built.ok) expect(built.refusal).toBe('not_admissible') + if (s === 'compromised') expect(status.unsuppressible_warning).toBe(true) + if (s === 'superseded') expect(status.successor_publisher_part).toBe('NEWPUB') + }) + } + + it('unknown code → capture error, NOT a status surface', async () => { + const t = createFixtureTransport(FX, { + resolve: { ok: false, code: 'http_status', message: 'HTTP 404', status: 404 }, + }) + const r = await new WrcResolutionClient({ + transport: t, + store: new WrcResolvedRecordStore(createMemoryPersistence(), createMemoryEpochFloorStore()), + ingestPublicKey: FX.ingest.pub, + now: () => NOW, + }).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.reason).toBe('unknown_identifier') + expect(r.captureError).toBe(true) + } + }) + + it('expired → the transition produces a status, never silence', async () => { + const { applyExpiryTransition } = await import('../entryStatusSurface') + const status = composeEntryStatus({ + publisherStatus: applyExpiryTransition('active', 1_000, 2_000), + entryStatus: 'published', + }) + expect(status.admissible).toBe(false) + expect(status.headline?.reason).toBe('publisher_revoked') + }) +}) + +describe('delta — consent-time CatalogHead re-check', () => { + const base = { stagedEpoch: 7, currentEpoch: 7, epochFloor: 7, fresh: true, suspended: false } + + it('passes when nothing moved', () => { + expect(recheckCatalogHeadForConsent(base).ok).toBe(true) + }) + + it('a rollback is refused as a rollback, not as staleness', () => { + const r = recheckCatalogHeadForConsent({ ...base, currentEpoch: 6, epochFloor: 7 }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('CATALOG_EPOCH_ROLLBACK') + }) + + it('suspension at consent time refuses', () => { + const r = recheckCatalogHeadForConsent({ ...base, suspended: true }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('ENTRY_SUSPENDED_AT_CONSENT') + }) + + it('a stale head refuses a NEW admission', () => { + const r = recheckCatalogHeadForConsent({ ...base, fresh: false }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('CATALOG_HEAD_STALE') + }) + + it('a new epoch sends the operator back to a re-staged offer', () => { + const r = recheckCatalogHeadForConsent({ ...base, currentEpoch: 8, epochFloor: 7 }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('CATALOG_EPOCH_MOVED') + }) +}) + +describe('A4 — audit link', () => { + it('is built only when both halves are known', () => { + expect(buildAuditUrl('https://wrc.example', 'sha256:abc')).toBe( + 'https://wrc.example/v1/audit/sha256%3Aabc', + ) + expect(buildAuditUrl(null, 'sha256:abc')).toBeNull() + expect(buildAuditUrl('https://wrc.example', null)).toBeNull() + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/resolution.dualChannel.test.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/resolution.dualChannel.test.ts new file mode 100644 index 000000000..c304f45cb --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/resolution.dualChannel.test.ts @@ -0,0 +1,364 @@ +/** + * Phase-3 exit criteria — resolution against a contract-faithful double. + * + * Two things are proven here: + * 1. A well-formed publisher resolves end to end, with real Ed25519 + * signatures, a real Merkle inclusion proof and a real epoch. + * 2. EVERY divergence — registry vs DNS vs manifest vs declared part, plus + * each verification leg — fails closed with a DISTINCT reason. The + * distinctness matters: Phase 4 renders these, and a status surface that + * cannot tell a rollback from a forged signature is not never-fails-silently. + */ +import { describe, expect, it } from 'vitest' +import { WrcResolutionClient } from '../resolutionClient' +import { WrcResolvedRecordStore, createMemoryPersistence } from '../resolvedRecordStore' +import { createMemoryEpochFloorStore } from '../epochFloorStore' +import { createFixtureTransport, buildPublisherFixture, makeKeyPair, fingerprintOf } from './wrcFixtures' +import type { WrcTransport } from '../wrcTransport' + +const NOW = 1_754_650_100 // inside the fixture's freshness window + +function clientFor(transport: WrcTransport, fx = FX, now = NOW) { + return new WrcResolutionClient({ + transport, + store: new WrcResolvedRecordStore(createMemoryPersistence()), + ingestPublicKey: fx.ingest.pub, + now: () => now, + }) +} + +const FX = buildPublisherFixture() + +/** + * Corrupt a signature so the DECODED bytes definitely differ. + * + * Tampering the last base64url character is not enough: for a 64-byte + * signature the final character carries only two meaningful bits and four + * discarded padding bits, so many "flips" decode to identical bytes and the + * test passes or fails depending on which random key was generated. The first + * character always carries the top six bits of byte 0. + */ +function tamperSignature(sig: string): string { + const first = sig[0] + const replacement = first === 'A' ? 'B' : 'A' + const tampered = replacement + sig.slice(1) + const decode = (s: string) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + if (decode(tampered).equals(decode(sig))) { + throw new Error('tamperSignature produced identical bytes') + } + return tampered +} + +describe('happy path — full chain', () => { + it('resolves publisher, entry and EVP with every leg verified', async () => { + const r = await clientFor(createFixtureTransport(FX)).resolvePublisher(FX.publisherPart, { + entryId: FX.entryId, + }) + expect(r.ok, r.ok ? '' : `${r.reason} ${r.detail ?? ''}`).toBe(true) + if (!r.ok) return + expect(r.domain).toBe(FX.domain) + expect(r.status).toBe('active') + expect(r.epoch).toBe(FX.epoch) + expect(r.freshness).toBe('fresh') + expect(r.entry?.entry_id).toBe(FX.entryId) + // EVP-first-render material comes from the VERIFIED EVP, not the carrier. + expect(r.evp?.value_statement).toBe('Signed value statement from the verified EVP.') + expect(r.record.root_fingerprint).toBe(fingerprintOf(FX.root.pub)) + expect(r.record.cache_state).toBe('validated') + }) + + it('verifies a head signed by a delegated catalog key using the embedded record', async () => { + // Delta v1.1 §A: nothing is seeded into the store and nothing is fetched — + // the head carries its own delegation. + const fx = buildPublisherFixture({ useDelegation: true }) + const r = await clientFor(createFixtureTransport(fx), fx).resolvePublisher(fx.publisherPart, { + entryId: fx.entryId, + }) + expect(r.ok, r.ok ? '' : `${r.reason} ${r.detail ?? ''}`).toBe(true) + if (r.ok) expect(r.record.delegation_kid).toBe(fx.catalogKey.kid) + }) +}) + +describe('divergence matrix — each fails closed with its own reason', () => { + it('registry unreachable', async () => { + const t = createFixtureTransport(FX, { + resolve: { ok: false, code: 'network_error', message: 'boom' }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('registry_unavailable') + }) + + it('unknown identifier routes to the capture-error path, not the status path', async () => { + const t = createFixtureTransport(FX, { + resolve: { ok: false, code: 'http_status', message: 'HTTP 404', status: 404 }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.reason).toBe('unknown_identifier') + expect(r.captureError).toBe(true) + } + }) + + it('DNS unavailable — nothing can be anchored', async () => { + const t = createFixtureTransport(FX, { + txt: { ok: false, code: 'dns_error', message: 'NXDOMAIN' }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('dns_unavailable') + }) + + it('DNS record malformed', async () => { + const t = createFixtureTransport(FX, { txt: { ok: true, records: ['v=spf1 -all'] } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('dns_record_malformed') + }) + + it('manifest unavailable', async () => { + const t = createFixtureTransport(FX, { + publisherManifest: { ok: false, code: 'http_status', message: 'HTTP 500', status: 500 }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('manifest_unavailable') + }) + + it('manifest signature invalid', async () => { + const forged = { ...FX.manifest, sig: tamperSignature(FX.manifest.sig) } + const t = createFixtureTransport(FX, { publisherManifest: { ok: true, value: forged } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('manifest_signature_invalid') + }) + + it('DNS pins a different key than the manifest presents', async () => { + const other = makeKeyPair('root-evil') + const t = createFixtureTransport(FX, { + txt: { ok: true, records: [`v=wr1; root=${fingerprintOf(other.pub)}`] }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('dns_manifest_key_mismatch') + }) + + it('CROSS-CHECK: manifest declares a different publisher part → alarm', async () => { + const fx2 = buildPublisherFixture({ publisherPart: 'OTHER1' }) + // Serve fx2's manifest (self-consistent, correctly signed) for FX's domain, + // with DNS pinning fx2's key so only the PART differs. + const t = createFixtureTransport(FX, { + publisherManifest: { ok: true, value: { ...fx2.manifest, domain: FX.domain } }, + txt: { ok: true, records: [`v=wr1; root=${fingerprintOf(fx2.root.pub)}`] }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.reason).toBe('manifest_part_mismatch') + expect(r.detail).toContain(FX.publisherPart) + } + }) + + it('manifest names a different domain', async () => { + const t = createFixtureTransport(FX, { + publisherManifest: { ok: true, value: { ...FX.manifest, domain: 'elsewhere.test' } }, + }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + // Re-signing is not possible for the attacker, so the signature fails first. + if (!r.ok) expect(['manifest_signature_invalid', 'manifest_domain_mismatch']).toContain(r.reason) + }) + + it('registry key diverges from the two independent channels', async () => { + const other = makeKeyPair('root-registry-claims') + const claim = { ...FX.resolveClaim, root_fingerprint: fingerprintOf(other.pub) } + const t = createFixtureTransport(FX, { resolve: { ok: true, value: claim } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('registry_key_divergence') + }) + + it('head signature invalid', async () => { + const badHead = { ...FX.head, sig: tamperSignature(FX.head.sig) } + const claim = { ...FX.resolveClaim, catalog_head: badHead } + const t = createFixtureTransport(FX, { resolve: { ok: true, value: claim } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('head_signature_invalid') + }) + + it('entry envelope countersignature invalid', async () => { + const env = { + ...FX.entryEnvelope, + ingest_countersig: { + ...FX.entryEnvelope.ingest_countersig, + sig: tamperSignature(FX.entryEnvelope.ingest_countersig.sig), + }, + } + const t = createFixtureTransport(FX, { entry: { ok: true, value: env } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart, { entryId: FX.entryId }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('envelope_countersignature_invalid') + }) + + it('inclusion proof does not reach the verified catalog root', async () => { + const env = { ...FX.entryEnvelope, inclusion_proof: [] } + const t = createFixtureTransport(FX, { entry: { ok: true, value: env } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart, { entryId: FX.entryId }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('envelope_inclusion_proof_invalid') + }) + + it('tampered object body breaks the hash binding', async () => { + const env = { + ...FX.entryEnvelope, + object: { ...FX.entryEnvelope.object, entry_id: 'TAMPERED' }, + } + const t = createFixtureTransport(FX, { entry: { ok: true, value: env } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart, { entryId: FX.entryId }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('envelope_object_hash_mismatch') + }) + + it('a suspended entry is a visible typed state, never silent absence (A5)', async () => { + const fx = buildPublisherFixture({ suspendEntry: true }) + const r = await clientFor(createFixtureTransport(fx), fx).resolvePublisher(fx.publisherPart, { + entryId: fx.entryId, + }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.reason).toBe('envelope_suspended') + expect(r.detail).toBe('platform_review') + } + + // The audit surface may see it, with the suspension attached. + const audit = await clientFor(createFixtureTransport(fx), fx).resolvePublisher( + fx.publisherPart, + { entryId: fx.entryId, allowSuspended: true }, + ) + expect(audit.ok).toBe(true) + if (audit.ok) expect(audit.suspension?.reason_code).toBe('platform_review') + }) + + it('EVP over the 64 KiB budget is a verification failure, not a truncation (3F)', async () => { + const fx = buildPublisherFixture({ oversizedEvp: true }) + const r = await clientFor(createFixtureTransport(fx), fx).resolvePublisher(fx.publisherPart, { + entryId: fx.entryId, + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('evp_over_budget') + }) + + it('an unpublished entry status is refused', async () => { + const fx = buildPublisherFixture({ entryStatus: 'retired' }) + const r = await clientFor(createFixtureTransport(fx), fx).resolvePublisher(fx.publisherPart, { + entryId: fx.entryId, + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe('entry_not_published') + }) +}) + +describe('3D — epoch anti-rollback and freshness', () => { + it('rejects a head whose epoch is below the persisted floor', async () => { + const store = new WrcResolvedRecordStore(createMemoryPersistence()) + const mk = (fx = FX) => + new WrcResolutionClient({ + transport: createFixtureTransport(fx), + store, + ingestPublicKey: fx.ingest.pub, + now: () => NOW, + }) + + const first = await mk(FX).resolvePublisher(FX.publisherPart) + expect(first.ok).toBe(true) + expect(store.lastSeenEpoch(FX.publisherPart)).toBe(FX.epoch) + + const older = buildPublisherFixture({ epoch: FX.epoch - 1 }) + const rolled = await new WrcResolutionClient({ + transport: createFixtureTransport(older), + store, + ingestPublicKey: older.ingest.pub, + now: () => NOW, + }).resolvePublisher(older.publisherPart) + + expect(rolled.ok).toBe(false) + if (!rolled.ok) expect(rolled.reason).toBe('head_epoch_rollback') + // The floor did not move down. + expect(store.lastSeenEpoch(FX.publisherPart)).toBe(FX.epoch) + }) + + it('the epoch floor survives eviction of the cached record', () => { + // The floor no longer lives in the cache persistence — it is its own store + // in the native-DB protection class (pre-Phase-4 item iii). Eviction of the + // record therefore cannot reopen a rollback window, and neither can + // deleting the cache file; the file case is covered in + // `epochFloorHardening.test.ts` against a real migrated DB. + const floor = createMemoryEpochFloorStore() + const s1 = new WrcResolvedRecordStore(createMemoryPersistence(), floor) + s1.noteAcceptedEpoch('WR7X4K', 12) + + const s2 = new WrcResolvedRecordStore(createMemoryPersistence(), floor) + expect(s2.get('WR7X4K')).toBeNull() + expect(s2.lastSeenEpoch('WR7X4K')).toBe(12) + s2.noteAcceptedEpoch('WR7X4K', 3) + expect(s2.lastSeenEpoch('WR7X4K')).toBe(12) + }) + + it('a stale head resolves as visibly stale rather than failing', async () => { + const past = NOW + 86_400 * 3 + const r = await clientFor(createFixtureTransport(FX), FX, past).resolvePublisher( + FX.publisherPart, + ) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.freshness).toBe('stale') + expect(r.stale_by_s).toBeGreaterThan(0) + expect(r.record.cache_state).toBe('stale') + } + }) + + it('a non-active publisher status demotes the cache entry', async () => { + const claim = { ...FX.resolveClaim, status: 'revoked' } + const t = createFixtureTransport(FX, { resolve: { ok: true, value: claim } }) + const r = await clientFor(t).resolvePublisher(FX.publisherPart) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.status).toBe('revoked') + expect(r.record.cache_state).toBe('demoted') + } + }) +}) + +describe('no production path reaches publisher trust without both channels', () => { + it('the client consults DNS and the manifest on every resolution', async () => { + const calls: string[] = [] + const base = createFixtureTransport(FX) + const spy: WrcTransport = { + resolve: async (p) => (calls.push('resolve'), base.resolve(p)), + catalogHead: async (p) => (calls.push('head'), base.catalogHead(p)), + entry: async (p, e) => (calls.push('entry'), base.entry(p, e)), + object: async (h) => (calls.push('object'), base.object(h)), + publisherManifest: async (d) => (calls.push('manifest'), base.publisherManifest(d)), + wrTxtRecords: async (d) => (calls.push('dns'), base.wrTxtRecords(d)), + } + const r = await clientFor(spy).resolvePublisher(FX.publisherPart, { entryId: FX.entryId }) + expect(r.ok).toBe(true) + expect(calls).toContain('dns') + expect(calls).toContain('manifest') + // DNS and manifest both precede any object fetch. + expect(calls.indexOf('dns')).toBeLessThan(calls.indexOf('entry')) + expect(calls.indexOf('manifest')).toBeLessThan(calls.indexOf('entry')) + }) + + it('an unconfigured deployment refuses instead of resolving', async () => { + const { createUnconfiguredWrcTransport } = await import('../wrcTransport') + const r = await clientFor(createUnconfiguredWrcTransport()).resolvePublisher('WR7X4K') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.reason).toBe('registry_unavailable') + expect(r.detail).toContain('not_configured') + } + }) +}) diff --git a/code/apps/electron-vite-project/electron/main/wrc/__tests__/wrcFixtures.ts b/code/apps/electron-vite-project/electron/main/wrc/__tests__/wrcFixtures.ts new file mode 100644 index 000000000..bcf2c537d --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/__tests__/wrcFixtures.ts @@ -0,0 +1,372 @@ +/** + * Contract-faithful WRC test double + fixture builder. + * + * This is the "dev registry instance or contract-faithful test double" the + * Phase-3 exit criteria call for. It SIGNS material the way the contract says + * a publisher and the WRC ingest would, so the client's verification is proven + * against real signatures, real Merkle proofs, and a real epoch sequence rather + * than against stubs that return `true`. + * + * It is a test artifact only: no production module imports it, and it is the + * one place in the repo that produces WRC signatures. + */ + +import { createHash, generateKeyPairSync, sign as cryptoSign, type KeyObject } from 'node:crypto' +import { canonicalJsonString } from '@repo/ingestion-core' +import type { + WrcCatalogHead, + WrcDelegationRecord, + WrcEntry, + WrcEnvelope, + WrcEvp, + WrcInclusionStep, + WrcPublisherManifest, +} from '../wrcContract' +import type { WrcTransport, WrcTransportResult, WrcTxtResult } from '../wrcTransport' + +// ── keys ────────────────────────────────────────────────────────────────────── + +export interface WrcTestKeyPair { + kid: string + privateKey: KeyObject + /** Raw 32-byte public key, base64url unpadded — the contract's key encoding. */ + pub: string +} + +function b64url(b: Buffer): string { + return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function makeKeyPair(kid: string): WrcTestKeyPair { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer + return { kid, privateKey, pub: b64url(spki.subarray(spki.length - 32)) } +} + +/** Sign canonical JSON of the object minus `sig`, no domain tag (§2). */ +export function signObject>(obj: T, key: WrcTestKeyPair): T { + const { sig: _drop, ...unsigned } = obj as Record + const bytes = Buffer.from(canonicalJsonString(unsigned as never), 'utf8') + return { ...(obj as Record), sig: b64url(cryptoSign(null, bytes, key.privateKey)) } as T +} + +export function hashObject(obj: unknown): string { + const bytes = Buffer.from(canonicalJsonString(obj as never), 'utf8') + return `sha256:${b64url(createHash('sha256').update(bytes).digest())}` +} + +export function fingerprintOf(pubB64Url: string): string { + const raw = Buffer.from(pubB64Url.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + return createHash('sha256').update(raw).digest('hex') +} + +// ── Merkle ──────────────────────────────────────────────────────────────────── + +function hashBytes(h: string): Buffer { + return Buffer.from(h.slice('sha256:'.length).replace(/-/g, '+').replace(/_/g, '/'), 'base64') +} +function toHash(b: Buffer): string { + return `sha256:${b64url(b)}` +} + +/** + * Build a tree over leaf hashes sorted lexicographically (§2) and return the + * root plus an inclusion proof per leaf. Odd node promotes. + */ +export function buildMerkle(leaves: readonly string[]): { + root: string + proofs: Map +} { + const sorted = [...leaves].sort() + const proofs = new Map() + for (const l of sorted) proofs.set(l, []) + if (sorted.length === 0) return { root: toHash(createHash('sha256').digest()), proofs } + + let level = sorted.map((h) => ({ hash: hashBytes(h), members: [h] })) + while (level.length > 1) { + const next: Array<{ hash: Buffer; members: string[] }> = [] + for (let i = 0; i < level.length; i += 2) { + const left = level[i]! + const right = level[i + 1] + if (!right) { + next.push(left) // odd node promotes unchanged + continue + } + for (const m of left.members) proofs.get(m)!.push({ pos: 'right', hash: toHash(right.hash) }) + for (const m of right.members) proofs.get(m)!.push({ pos: 'left', hash: toHash(left.hash) }) + next.push({ + hash: createHash('sha256').update(Buffer.concat([left.hash, right.hash])).digest(), + members: [...left.members, ...right.members], + }) + } + level = next + } + return { root: toHash(level[0]!.hash), proofs } +} + +// ── Publisher fixture ───────────────────────────────────────────────────────── + +export interface WrcPublisherFixtureOptions { + publisherPart?: string + domain?: string + entryId?: string + epoch?: number + issuedAt?: number + freshnessWindowS?: number + /** Sign the head with a delegated catalog key instead of the root key. */ + useDelegation?: boolean + /** Delegation validity, when `useDelegation`. */ + delegationValidFromEpoch?: number + delegationRevokedFromEpoch?: number | null + entryStatus?: 'published' | 'suspended' | 'retired' + /** Attach a platform suspension record to the entry envelope (A5). */ + suspendEntry?: boolean + /** Pad the EVP past the 64 KiB canonical budget (§3.3). */ + oversizedEvp?: boolean + /** + * Override the record embedded in the head (delta v1.1 §A). `null` produces a + * delegated head with NO embedded record; a record produces a substituted + * one. Leave undefined for the correct record. + */ + headDelegationOverride?: WrcDelegationRecord | null + /** Sign the delegation with this key instead of the publisher root. */ + delegationSigner?: WrcTestKeyPair + /** Override the delegation's `root_kid` — used to attempt sub-delegation. */ + delegationRootKid?: string +} + +export interface WrcPublisherFixture { + publisherPart: string + domain: string + entryId: string + epoch: number + root: WrcTestKeyPair + catalogKey: WrcTestKeyPair + ingest: WrcTestKeyPair + manifest: WrcPublisherManifest + head: WrcCatalogHead + entry: WrcEntry + evp: WrcEvp + entryEnvelope: WrcEnvelope + evpEnvelope: WrcEnvelope + delegation: WrcDelegationRecord | null + /** Delta v1.1 §B history payload, oldest first. */ + delegationHistory: WrcDelegationRecord[] + txtRecords: string[] + resolveClaim: Record +} + +export function buildPublisherFixture( + options: WrcPublisherFixtureOptions = {}, +): WrcPublisherFixture { + const publisherPart = options.publisherPart ?? 'WR7X4K' + const domain = options.domain ?? 'publisher.test' + const entryId = options.entryId ?? '9B2M3' + const epoch = options.epoch ?? 7 + const issuedAt = options.issuedAt ?? 1_754_650_000 + const freshnessWindowS = options.freshnessWindowS ?? 86_400 + + const root = makeKeyPair('root-a1') + const catalogKey = options.useDelegation ? makeKeyPair('cat-b2') : root + const ingest = makeKeyPair('wrc-ingest-1') + + const delegation: WrcDelegationRecord | null = options.useDelegation + ? (signObject( + { + type: 'wrc/catalog-delegation', + publisher_part: publisherPart, + delegate_kid: catalogKey.kid, + delegate_pub: catalogKey.pub, + authority: 'catalog-signing-only', + valid_from_epoch: options.delegationValidFromEpoch ?? 1, + revoked_from_epoch: options.delegationRevokedFromEpoch ?? null, + root_kid: options.delegationRootKid ?? root.kid, + sig: '', + } as unknown as Record, + options.delegationSigner ?? root, + ) as unknown as WrcDelegationRecord) + : null + + const manifest = signObject( + { + type: 'wr/manifest', + domain, + publisher_part: publisherPart, + root_kid: root.kid, + root_pub: root.pub, + sig: '', + } as unknown as Record, + root, + ) as unknown as WrcPublisherManifest + + const evpBase: Record = { + type: 'wrc/evp', + publisher_part: publisherPart, + entry_id: entryId, + self_description: options.oversizedEvp ? 'x'.repeat(70_000) : 'A publisher of test entries.', + value_statement: 'Signed value statement from the verified EVP.', + scope_directory: [ + { + scope: hashObject({ scope: 1 }), + name: 'Scope one', + desc: 'First scope', + size_hint_b: 12_345, + prefetch: 'none', + }, + ], + preparation_view: null, + next_steps: ['Review the offer'], + audit_links: true, + epoch, + kid: catalogKey.kid, + sig: '', + } + const evp = signObject(evpBase, catalogKey) as unknown as WrcEvp + const evpHash = hashObject(evp) + + const entryBase: Record = { + type: 'wrc/entry', + entry_id: entryId, + publisher_part: publisherPart, + display: { name: 'Test Entry', icon: null, value_statement: 'Carrier-independent statement' }, + codes: [{ canonical: `${publisherPart}${entryId}C`, channels: ['assisted_email'] }], + scopes: [hashObject({ scope: 1 })], + evp_ref: evpHash, + template_ref: null, + status: options.entryStatus ?? 'published', + epoch, + kid: catalogKey.kid, + sig: '', + } + const entry = signObject(entryBase, catalogKey) as unknown as WrcEntry + const entryHash = hashObject(entry) + + const { root: catalogRoot, proofs } = buildMerkle([entryHash, evpHash]) + + // Delta v1.1 §A: the delegation travels IN the head, so verification needs + // nothing but the DNS-pinned root and this object. + const head = signObject( + { + type: 'wrc/catalog-head', + publisher_part: publisherPart, + domain, + catalog_root: catalogRoot, + epoch, + issued_at: issuedAt, + freshness_window_s: freshnessWindowS, + kid: catalogKey.kid, + delegation: (options.headDelegationOverride === undefined + ? delegation + : options.headDelegationOverride) as unknown as Record | null, + sig: '', + } as unknown as Record, + catalogKey, + ) as unknown as WrcCatalogHead + + const countersign = (hash: string): string => + b64url(cryptoSign(null, Buffer.from(`${hash}${String(epoch)}`, 'utf8'), ingest.privateKey)) + + const entryEnvelope: WrcEnvelope = { + object: entry as unknown as Record, + hash: entryHash, + publisher_sig_valid_kid: catalogKey.kid, + ingest_countersig: { kid: ingest.kid, at: issuedAt + 100, sig: countersign(entryHash) }, + epoch, + inclusion_proof: proofs.get(entryHash)!, + suspension: options.suspendEntry + ? { since: issuedAt + 500, reason_code: 'platform_review', reversible: true } + : null, + } + + const evpEnvelope: WrcEnvelope = { + object: evp as unknown as Record, + hash: evpHash, + publisher_sig_valid_kid: catalogKey.kid, + ingest_countersig: { kid: ingest.kid, at: issuedAt + 100, sig: countersign(evpHash) }, + epoch, + inclusion_proof: proofs.get(evpHash)!, + suspension: null, + } + + return { + publisherPart, + domain, + entryId, + epoch, + root, + catalogKey, + ingest, + manifest, + head, + entry, + evp, + entryEnvelope, + evpEnvelope, + delegation, + delegationHistory: delegation ? [delegation] : [], + txtRecords: [`v=wr1; root=${fingerprintOf(root.pub)}`], + resolveClaim: { + domain, + status: 'active', + generation: 1, + catalog_head: head, + root_fingerprint: fingerprintOf(root.pub), + }, + } +} + +// ── Transport double ────────────────────────────────────────────────────────── + +export interface FixtureTransportOverrides { + resolve?: WrcTransportResult + catalogHead?: WrcTransportResult + delegations?: WrcTransportResult + entry?: WrcTransportResult + object?: WrcTransportResult + publisherManifest?: WrcTransportResult + txt?: WrcTxtResult + /** Called on every transport method — lets a test prove what was NOT called. */ + onCall?: (method: string) => void +} + +/** Contract-faithful in-memory transport over a fixture. */ +export function createFixtureTransport( + fx: WrcPublisherFixture, + overrides: FixtureTransportOverrides = {}, +): WrcTransport { + const note = (m: string) => overrides.onCall?.(m) + return { + async resolve() { + note('resolve') + return overrides.resolve ?? { ok: true, value: fx.resolveClaim } + }, + async catalogHead() { + note('catalogHead') + return overrides.catalogHead ?? { ok: true, value: fx.head } + }, + async delegations() { + note('delegations') + // Delta v1.1 §B: append-only rotation history, oldest first. Audit only. + return overrides.delegations ?? { ok: true, value: fx.delegationHistory } + }, + async entry() { + note('entry') + return overrides.entry ?? { ok: true, value: fx.entryEnvelope } + }, + async object(hash) { + note('object') + if (overrides.object) return overrides.object + if (hash === fx.evpEnvelope.hash) return { ok: true, value: fx.evpEnvelope } + if (hash === fx.entryEnvelope.hash) return { ok: true, value: fx.entryEnvelope } + return { ok: false, code: 'http_status', message: 'HTTP 404', status: 404 } + }, + async publisherManifest() { + note('publisherManifest') + return overrides.publisherManifest ?? { ok: true, value: fx.manifest } + }, + async wrTxtRecords() { + note('wrTxtRecords') + return overrides.txt ?? { ok: true, records: fx.txtRecords } + }, + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/dualChannel.ts b/code/apps/electron-vite-project/electron/main/wrc/dualChannel.ts new file mode 100644 index 000000000..047f546d3 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/dualChannel.ts @@ -0,0 +1,172 @@ +/** + * Independent dual-channel domain validation (3B.2 / 3B.3). + * + * P3, restated so it cannot be diluted by a later refactor: the registry answer + * is a CLAIM. Nothing the registry says about a publisher becomes trusted until + * two channels the registry does not control agree with it: + * + * 1. DNS — a TXT record at `_wr.` carrying the root key fingerprint. + * 2. HTTPS — `https:///.well-known/wr/manifest`, Ed25519 self-signed + * by the root key the DNS record pins. + * + * And then a third check that is a CROSS-CHECK, not a source: the manifest's + * declared `publisher_part` must equal the part we resolved. A mismatch is an + * alarm (§XVI.11.3 pattern), never a quiet fallback to whichever value looks + * more plausible. + * + * The registry's `root_fingerprint` is compared too, but it is never allowed to + * *establish* anything: if DNS and the manifest agree with each other and the + * registry disagrees, that is a registry divergence and it fails closed. The + * ordering below is deliberate — DNS first, manifest second, registry last — + * so no code path can reach a trust conclusion having consulted only the + * registry. + */ + +import { createHash } from 'node:crypto' +import { decodePublisherManifest, type WrcPublisherManifest } from './wrcContract' +import { wrcVerifyObjectSignature } from './wrcCrypto' +import type { WrcTransport } from './wrcTransport' + +export type DualChannelReason = + /** No `_wr` TXT record, or DNS itself failed. Cannot anchor anything. */ + | 'dns_unavailable' + /** TXT present but no parsable `v=wr1; root=` pair. */ + | 'dns_record_malformed' + /** The manifest could not be fetched over the hardened client. */ + | 'manifest_unavailable' + /** Manifest body was not a well-formed `wr/manifest`. */ + | 'manifest_malformed' + /** Manifest is not self-signed by the key it declares. */ + | 'manifest_signature_invalid' + /** The manifest's root key does not match the fingerprint pinned in DNS. */ + | 'dns_manifest_key_mismatch' + /** The manifest names a different domain than the one we validated. */ + | 'manifest_domain_mismatch' + /** CROSS-CHECK failure: manifest's publisher part ≠ resolved part. ALARM. */ + | 'manifest_part_mismatch' + /** Registry's root fingerprint disagrees with the two independent channels. */ + | 'registry_key_divergence' + +export interface DualChannelSuccess { + ok: true + domain: string + publisherPart: string + /** Root key established by DNS + manifest, NOT by the registry. */ + rootKid: string + rootPub: string + rootFingerprint: string + manifest: WrcPublisherManifest +} + +export interface DualChannelFailure { + ok: false + reason: DualChannelReason + detail?: string +} + +export type DualChannelResult = DualChannelSuccess | DualChannelFailure + +/** Hex SHA-256 of the raw public key bytes — the form pinned in DNS. */ +export function rootKeyFingerprint(rootPubB64Url: string): string { + const raw = Buffer.from(rootPubB64Url.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + return createHash('sha256').update(raw).digest('hex') +} + +/** + * Parse `v=wr1; root=` out of the TXT records at `_wr.`. + * Multiple records are tolerated (providers split them); the first well-formed + * `wr1` record wins and any additional ones are ignored rather than merged, + * because merging attacker-influenced records is how a second key sneaks in. + */ +export function parseWrTxtRecords(records: readonly string[]): { rootFingerprint: string } | null { + for (const raw of records) { + const text = raw.trim() + if (!/(^|;|\s)v=wr1(;|\s|$)/i.test(text)) continue + const m = text.match(/(?:^|;|\s)root=([0-9a-f]{64})(?:;|\s|$)/i) + if (m?.[1]) return { rootFingerprint: m[1].toLowerCase() } + } + return null +} + +export interface ValidateDomainInput { + transport: WrcTransport + /** Domain the registry claimed for this part. Treated as a candidate only. */ + claimedDomain: string + /** The publisher part we resolved. The manifest must agree with it. */ + resolvedPublisherPart: string + /** The registry's claimed root fingerprint, checked last and never trusted first. */ + claimedRootFingerprint?: string +} + +/** + * Run both channels and the cross-check. Returns the root key material only + * when every leg agrees. + */ +export async function validateDomainDualChannel( + input: ValidateDomainInput, +): Promise { + const domain = input.claimedDomain.trim().toLowerCase() + + // ── Channel 1: DNS ────────────────────────────────────────────────────────── + const txt = await input.transport.wrTxtRecords(domain) + if (!txt.ok) return { ok: false, reason: 'dns_unavailable', detail: txt.message } + const pinned = parseWrTxtRecords(txt.records) + if (!pinned) return { ok: false, reason: 'dns_record_malformed' } + + // ── Channel 2: publisher-served manifest ──────────────────────────────────── + const manifestRes = await input.transport.publisherManifest(domain) + if (!manifestRes.ok) { + return { ok: false, reason: 'manifest_unavailable', detail: manifestRes.message } + } + const manifest = decodePublisherManifest(manifestRes.value) + if (!manifest) return { ok: false, reason: 'manifest_malformed' } + + if (!wrcVerifyObjectSignature(manifest as unknown as Record, manifest.root_pub)) { + return { ok: false, reason: 'manifest_signature_invalid' } + } + + // The two independent channels must agree on the key before anything else. + const fingerprint = rootKeyFingerprint(manifest.root_pub) + if (fingerprint !== pinned.rootFingerprint) { + return { + ok: false, + reason: 'dns_manifest_key_mismatch', + detail: `dns=${pinned.rootFingerprint} manifest=${fingerprint}`, + } + } + + if (manifest.domain !== domain) { + return { ok: false, reason: 'manifest_domain_mismatch', detail: manifest.domain } + } + + // ── Cross-check (§1.1): declared part vs resolved part. ALARM on mismatch. ── + if (manifest.publisher_part !== input.resolvedPublisherPart) { + return { + ok: false, + reason: 'manifest_part_mismatch', + detail: `manifest=${manifest.publisher_part} resolved=${input.resolvedPublisherPart}`, + } + } + + // ── Registry consulted LAST, and only to detect divergence ────────────────── + if ( + input.claimedRootFingerprint && + input.claimedRootFingerprint.toLowerCase() !== fingerprint + ) { + return { + ok: false, + reason: 'registry_key_divergence', + detail: `registry=${input.claimedRootFingerprint.toLowerCase()} channels=${fingerprint}`, + } + } + + return { + ok: true, + domain, + publisherPart: manifest.publisher_part, + rootKid: manifest.root_kid, + rootPub: manifest.root_pub, + rootFingerprint: fingerprint, + manifest, + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/entryStatusSurface.ts b/code/apps/electron-vite-project/electron/main/wrc/entryStatusSurface.ts new file mode 100644 index 000000000..b4a2eeacb --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/entryStatusSurface.ts @@ -0,0 +1,227 @@ +/** + * 4A — entry status model, composed from THREE orthogonal fields (delta A6). + * + * The apparent collision that A6 resolves: `suspended` appears both as a + * publisher-signed `entry.status` (§XVII.3.2) and as a platform-side + * `envelope.suspension` (§XVII.3.3), while D4 carries a publisher-PART status. + * They are three different statements by three different parties about three + * different objects, and merging them would lose exactly the information an + * operator needs. + * + * So, per A6: + * + * 1. DATA — the two `suspended`s cannot collide by construction. `entry.status` + * is publisher-SIGNED; platform suspension lives only in the envelope, and + * the WRC rejects rather than modifies (§1.6). + * 2. ADMISSION — conjunctive and fail-closed: + * admissible ⇔ publisher_part == active + * AND entry.status == published + * AND envelope.suspension == null + * Any failing leg yields a typed reason. + * 3. DISPLAY — never conflated. Headline is the failing leg CLOSEST TO THE + * OBJECT (platform > entry > publisher-part); every failing leg stays + * visible in detail, because never-fails-silently means an operator is told + * all of what is wrong, not merely the first thing. + * + * No enum is merged or extended anywhere in this module. + */ + +import type { WrcEntryStatus, WrcPublisherStatus, WrcSuspension } from './wrcContract' + +// ── Layers ──────────────────────────────────────────────────────────────────── + +/** Which party's statement a surface line comes from. */ +export type WrcStatusLayer = 'platform' | 'entry' | 'publisher_part' + +/** + * Closeness to the object, per A6.3. Platform suspension is the most immediate + * statement about THIS object; the publisher-part status is the most distant. + */ +const LAYER_PRECEDENCE: Record = { + platform: 3, + entry: 2, + publisher_part: 1, +} + +export type WrcStatusReason = + // platform layer + | 'platform_suspended' + // entry layer (publisher-signed) + | 'entry_suspended' + | 'entry_retired' + // publisher-part layer (D4) + | 'publisher_inactive' + | 'publisher_revoked' + | 'publisher_superseded' + | 'publisher_compromised' + +export interface WrcStatusLine { + layer: WrcStatusLayer + reason: WrcStatusReason + /** Distinct copy per layer — a platform suspension never reads like a publisher withdrawal. */ + copy: string + /** A5 / A4: the per-item audit link belongs on the surface that shows this. */ + audit_link: boolean + /** Platform suspension only. */ + suspension?: WrcSuspension + /** Superseded only — surfaced explicitly, never redirected to silently. */ + successor_publisher_part?: string +} + +export interface WrcStatusComposition { + /** Conjunctive fail-closed admission (A6.2). */ + admissible: boolean + /** The failing leg closest to the object; null when admissible. */ + headline: WrcStatusLine | null + /** EVERY failing leg, ordered by closeness. Never truncated to the headline. */ + failing: WrcStatusLine[] + /** + * Compromised is treated as revoked PLUS the unsuppressible Phase-2 alert + * class. The alert is not a status line — it is a separate, non-dismissible + * surface — so it is reported as its own flag. + */ + unsuppressible_warning: boolean + /** Superseded: the successor exists but is offered only after its own chain. */ + successor_publisher_part: string | null +} + +export interface ComposeEntryStatusInput { + /** D4 publisher-part status from the resolve claim. */ + publisherStatus: WrcPublisherStatus + /** Publisher-signed entry status. Absent when no entry was requested. */ + entryStatus?: WrcEntryStatus | null + /** Platform suspension from the DualAssuranceEnvelope. */ + suspension?: WrcSuspension | null + /** Successor for a superseded publisher part, when the registry named one. */ + successorPublisherPart?: string | null +} + +// ── Copy, distinct per layer ────────────────────────────────────────────────── + +const COPY: Record = { + // Platform speaks about the object, in the platform's own voice. + platform_suspended: 'Suspended by the platform.', + // The publisher speaks about its own entry. + entry_suspended: 'Withdrawn by the publisher.', + entry_retired: 'Retired by the publisher.', + // D4 speaks about the publisher part, one level out from the entry. + publisher_inactive: 'This publisher is currently not offering connections.', + publisher_revoked: 'This publisher identifier has been revoked.', + publisher_superseded: 'This publisher identifier has been superseded.', + publisher_compromised: 'This publisher identifier is marked compromised.', +} + +/** + * Compose the three layers into one surface description. + * + * Returns a composition rather than a single verdict because the caller needs + * both: `admissible` gates the offer, `failing` renders the detail, and + * `headline` chooses what to lead with. + */ +export function composeEntryStatus(input: ComposeEntryStatusInput): WrcStatusComposition { + const failing: WrcStatusLine[] = [] + + // Layer 1 — platform (closest to the object). + if (input.suspension) { + failing.push({ + layer: 'platform', + reason: 'platform_suspended', + copy: `${COPY.platform_suspended} Reason: ${input.suspension.reason_code}.`, + audit_link: true, + suspension: input.suspension, + }) + } + + // Layer 2 — publisher-signed entry status. + if (input.entryStatus === 'suspended') { + failing.push({ + layer: 'entry', + reason: 'entry_suspended', + copy: COPY.entry_suspended, + audit_link: true, + }) + } else if (input.entryStatus === 'retired') { + failing.push({ + layer: 'entry', + reason: 'entry_retired', + copy: COPY.entry_retired, + audit_link: true, + }) + } + + // Layer 3 — D4 publisher-part status. + const successor = input.successorPublisherPart ?? null + switch (input.publisherStatus) { + case 'active': + break + case 'inactive': + failing.push({ + layer: 'publisher_part', + reason: 'publisher_inactive', + copy: COPY.publisher_inactive, + audit_link: true, + }) + break + case 'revoked': + failing.push({ + layer: 'publisher_part', + reason: 'publisher_revoked', + copy: COPY.publisher_revoked, + audit_link: true, + }) + break + case 'superseded': + failing.push({ + layer: 'publisher_part', + reason: 'publisher_superseded', + copy: successor + ? `${COPY.publisher_superseded} Successor: ${successor}.` + : COPY.publisher_superseded, + audit_link: true, + ...(successor ? { successor_publisher_part: successor } : {}), + }) + break + case 'compromised': + failing.push({ + layer: 'publisher_part', + reason: 'publisher_compromised', + copy: COPY.publisher_compromised, + audit_link: true, + }) + break + } + + failing.sort((a, b) => LAYER_PRECEDENCE[b.layer] - LAYER_PRECEDENCE[a.layer]) + + // A6.2 — conjunctive, fail-closed. An entry that was never fetched cannot + // satisfy the entry leg, so admission requires it to be explicitly published. + const admissible = + input.publisherStatus === 'active' && + input.entryStatus === 'published' && + !input.suspension + + return { + admissible, + headline: failing[0] ?? null, + failing, + unsuppressible_warning: input.publisherStatus === 'compromised', + successor_publisher_part: input.publisherStatus === 'superseded' ? successor : null, + } +} + +/** + * `expires_at` auto-transition. Default is → revoked; a publisher may configure + * → inactive instead. Never silently drops a status: an expired identifier + * still resolves to a status surface. + */ +export function applyExpiryTransition( + status: WrcPublisherStatus, + expiresAtS: number | null | undefined, + nowS: number, + configured: 'revoked' | 'inactive' = 'revoked', +): WrcPublisherStatus { + if (status !== 'active') return status + if (typeof expiresAtS !== 'number' || !Number.isFinite(expiresAtS)) return status + if (nowS < expiresAtS) return status + return configured +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/epochFloorStore.ts b/code/apps/electron-vite-project/electron/main/wrc/epochFloorStore.ts new file mode 100644 index 000000000..8e8b4637a --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/epochFloorStore.ts @@ -0,0 +1,96 @@ +/** + * Per-publisher anti-rollback epoch floor (A3) — native-DB protection class. + * + * Why this is not in the resolved-record store: that store is a CACHE of + * registry state and may be evicted, rebuilt, or deleted at will. The floor is + * TRUST state. If a user (or anything running as the user) can delete a JSON + * file and thereby let a publisher replay an older, signed CatalogHead, the + * anti-rollback property is decorative. So the floor moves to the same + * protection class as the rest of the trust ledger, and the cache keeps only + * cache. + * + * The API has exactly two operations — read, and raise. There is deliberately + * no set, no clear, and no delete: monotonicity is enforced by the absence of a + * lowering path, not by callers remembering to compare first. + */ + +/** Minimal shape so tests can pass a bare better-sqlite3 handle. */ +export interface EpochFloorDb { + prepare: (sql: string) => { + get: (...args: unknown[]) => unknown + run: (...args: unknown[]) => unknown + } +} + +export interface WrcEpochFloorStore { + /** Highest epoch ever accepted for this publisher, or null if never seen. */ + get(publisherPart: string): number | null + /** + * Raise the floor to `epoch`. A lower or equal value is a no-op, not an + * error: re-fetching the same epoch is normal. + */ + raise(publisherPart: string, epoch: number): void +} + +/** + * Native-DB backed floor. `INSERT … ON CONFLICT … DO UPDATE … WHERE excluded >` + * makes the monotonicity a property of the statement rather than of a + * read-then-write the caller could race or skip. + */ +export function createDbEpochFloorStore(db: EpochFloorDb): WrcEpochFloorStore { + return { + get(publisherPart) { + try { + const row = db + .prepare('SELECT epoch_floor FROM wrc_publisher_epoch_floor WHERE publisher_part = ?') + .get(publisherPart) as { epoch_floor?: number } | undefined + const v = row?.epoch_floor + return typeof v === 'number' && Number.isSafeInteger(v) ? v : null + } catch { + // A missing table must not read as "no rollback protection, proceed". + // Callers treat null as "never seen", so surface the failure loudly + // rather than silently: see `assertEpochFloorTablePresent`. + return null + } + }, + raise(publisherPart, epoch) { + if (!Number.isSafeInteger(epoch) || epoch < 0) return + db.prepare( + `INSERT INTO wrc_publisher_epoch_floor (publisher_part, epoch_floor, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(publisher_part) DO UPDATE SET + epoch_floor = excluded.epoch_floor, + updated_at = excluded.updated_at + WHERE excluded.epoch_floor > wrc_publisher_epoch_floor.epoch_floor`, + ).run(publisherPart, epoch, new Date().toISOString()) + }, + } +} + +/** + * In-memory floor for tests and for the unconfigured path. Same two-operation + * contract, same monotonicity. + */ +export function createMemoryEpochFloorStore( + seed?: ReadonlyMap, +): WrcEpochFloorStore { + const m = new Map(seed ?? []) + return { + get: (p) => m.get(p) ?? null, + raise: (p, e) => { + if (!Number.isSafeInteger(e) || e < 0) return + const cur = m.get(p) + if (cur === undefined || e > cur) m.set(p, e) + }, + } +} + +/** True when the native table exists — used to fail loudly rather than silently. */ +export function epochFloorTablePresent(db: EpochFloorDb): boolean { + try { + db.prepare('SELECT 1 FROM wrc_publisher_epoch_floor LIMIT 1').get() + return true + } catch { + return false + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/httpsClient.ts b/code/apps/electron-vite-project/electron/main/wrc/httpsClient.ts new file mode 100644 index 000000000..8fd81b6df --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/httpsClient.ts @@ -0,0 +1,351 @@ +/** + * Hardened outbound HTTPS client (build item 4 / 3A). + * + * The only outbound HTTP path the WRC resolution client may use. It exists + * because every byte 3B fetches comes from a party we do not trust yet: the + * registry is a claim, the publisher domain is attacker-influenced, and a + * resolution request is an attacker-triggerable outbound call from inside the + * user's machine. So this client is written to be boring and refusing. + * + * Guarantees, each enforced here rather than left to callers: + * - HTTPS only. `http:` and every other scheme is refused before a socket. + * - `redirect: 'error'` semantics — a 3xx is a failure, never followed. A + * followed redirect would re-open every check below against a new origin. + * - TLS floor of TLS 1.2. `rejectUnauthorized` is never weakened; there is no + * option to weaken it, so no call site can. + * - Hard total timeout covering connect + headers + body, not a per-socket + * idle timeout that a slow drip can hold open indefinitely. + * - Response-size cap enforced while streaming, so an unbounded body is + * destroyed instead of buffered. + * - SSRF guard at CONNECT time via a custom `lookup`: the resolved address is + * checked, not the hostname. Checking the name would be defeated by DNS + * rebinding; checking the address the socket will actually use is not. + * - JSON parsing is a hook, not an assumption: callers say what they expect + * and get a typed failure rather than a thrown SyntaxError. + * + * Deliberately NOT built on the `discovery.ts` skeleton unmodified, per the + * order: that helper has a timeout, a cache and field validation, but it + * follows redirects, has no size cap, no address guard, and no TLS floor. What + * is reused is its shape — typed result objects instead of throws, explicit + * error codes, validation before the value is handed back. + */ + +import { request as httpsRequest, type RequestOptions } from 'node:https' +import { lookup as dnsLookup, type LookupAddress } from 'node:dns' +import { isIP, type LookupFunction } from 'node:net' + +// ── Result contract ─────────────────────────────────────────────────────────── + +export type WrcHttpErrorCode = + /** Not an absolute https: URL, or it carries credentials / a non-default form we refuse. */ + | 'url_rejected' + /** The name resolved to a loopback, link-local, private, or otherwise non-public address. */ + | 'blocked_address' + /** A 3xx response. Never followed. */ + | 'redirect_refused' + /** Total deadline exceeded (connect + headers + body). */ + | 'timeout' + /** Body exceeded the byte cap; the socket was destroyed mid-stream. */ + | 'response_too_large' + /** TLS handshake or certificate failure. */ + | 'tls_error' + /** Reached the server, got a non-2xx, non-3xx status. */ + | 'http_status' + /** Transport failure that is none of the above. */ + | 'network_error' + /** Body was not the JSON the caller declared it expected. */ + | 'invalid_json' + +export interface WrcHttpSuccess { + ok: true + status: number + /** Raw body bytes, already known to be within the cap. */ + bytes: Buffer + /** Present only when `expectJson` was set and parsing succeeded. */ + json?: unknown +} + +export interface WrcHttpFailure { + ok: false + code: WrcHttpErrorCode + message: string + /** Present for `http_status`. */ + status?: number +} + +export type WrcHttpResult = WrcHttpSuccess | WrcHttpFailure + +// ── Defaults ────────────────────────────────────────────────────────────────── + +/** Total deadline for a single request. Registry calls are interactive. */ +export const WRC_HTTP_DEFAULT_TIMEOUT_MS = 8_000 + +/** + * Default body cap. The largest object the contract defines is an EVP at + * 64 KiB canonical; 256 KiB leaves room for envelopes and proofs without ever + * approaching a size where buffering is a denial-of-service in itself. + */ +export const WRC_HTTP_DEFAULT_MAX_BYTES = 256 * 1024 + +export interface WrcHttpOptions { + /** Total deadline in ms. Default {@link WRC_HTTP_DEFAULT_TIMEOUT_MS}. */ + timeoutMs?: number + /** Body cap in bytes. Default {@link WRC_HTTP_DEFAULT_MAX_BYTES}. */ + maxBytes?: number + /** `Accept` header. Default `application/json`. */ + accept?: string + /** Parse the body as JSON and fail with `invalid_json` when it is not. */ + expectJson?: boolean + /** + * Address-family lookup override. Tests inject a resolver so SSRF behaviour + * can be proven without real DNS. Production leaves this unset. + */ + lookup?: LookupFunction +} + +// ── SSRF address policy ─────────────────────────────────────────────────────── + +function ipv4IsPublic(addr: string): boolean { + const p = addr.split('.').map((n) => Number(n)) + if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false + const [a, b] = p as [number, number, number, number] + if (a === 0) return false // "this network" + if (a === 10) return false // RFC1918 + if (a === 127) return false // loopback + if (a === 169 && b === 254) return false // link-local incl. cloud metadata 169.254.169.254 + if (a === 172 && b >= 16 && b <= 31) return false // RFC1918 + if (a === 192 && b === 168) return false // RFC1918 + if (a === 192 && b === 0) return false // IETF protocol assignments / 192.0.0.0/24, 192.0.2.0/24 + if (a === 198 && (b === 18 || b === 19)) return false // benchmarking + if (a === 198 && b === 51) return false // TEST-NET-2 + if (a === 203 && b === 0) return false // TEST-NET-3 + if (a === 100 && b >= 64 && b <= 127) return false // CGNAT + if (a >= 224) return false // multicast, reserved, broadcast + return true +} + +function ipv6IsPublic(raw: string): boolean { + const addr = raw.toLowerCase().split('%')[0] ?? '' + if (addr === '::' || addr === '::1') return false // unspecified / loopback + // IPv4-mapped and IPv4-compatible forms inherit the IPv4 verdict. + const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) + if (mapped?.[1]) return ipv4IsPublic(mapped[1]) + if (/^::\d+\.\d+\.\d+\.\d+$/.test(addr)) return false + if (addr.startsWith('fe8') || addr.startsWith('fe9') || addr.startsWith('fea') || addr.startsWith('feb')) { + return false // link-local fe80::/10 + } + if (addr.startsWith('fc') || addr.startsWith('fd')) return false // unique local fc00::/7 + if (addr.startsWith('ff')) return false // multicast + if (addr.startsWith('2001:db8')) return false // documentation + if (addr.startsWith('64:ff9b')) return false // NAT64 — reaches an IPv4 destination we did not vet + return true +} + +/** + * True when the literal address is routable on the public internet. + * Everything not positively recognised as public is refused: this is an + * allowlist in spirit even though it reads as a set of exclusions, because the + * two family branches both end in an explicit `true` only after the checks. + */ +export function isPublicUnicastAddress(addr: string): boolean { + const family = isIP(addr) + if (family === 4) return ipv4IsPublic(addr) + if (family === 6) return ipv6IsPublic(addr) + return false +} + +/** Guarded `lookup` — refuses to hand the socket a non-public address. */ +function guardedLookup(base: LookupFunction): LookupFunction { + const fn = ((hostname: string, options: unknown, callback: unknown) => { + const cb = (typeof options === 'function' ? options : callback) as ( + err: NodeJS.ErrnoException | null, + address?: string | LookupAddress[], + family?: number, + ) => void + const opts = (typeof options === 'function' ? {} : options) as Record + + const inner = base as unknown as ( + h: string, + o: unknown, + c: (err: NodeJS.ErrnoException | null, address?: string | LookupAddress[], family?: number) => void, + ) => void + + inner(hostname, opts, (err, address, family) => { + if (err) { + cb(err) + return + } + const list: LookupAddress[] = Array.isArray(address) + ? address + : [{ address: String(address), family: Number(family ?? 0) }] + const blocked = list.find((a) => !isPublicUnicastAddress(a.address)) + if (blocked || list.length === 0) { + const e = new Error( + `WRC_BLOCKED_ADDRESS: ${hostname} resolved to non-public address ${blocked?.address ?? '(none)'}`, + ) as NodeJS.ErrnoException + e.code = 'WRC_BLOCKED_ADDRESS' + cb(e) + return + } + if (Array.isArray(address)) cb(null, list) + else cb(null, list[0]!.address, list[0]!.family) + }) + }) as unknown as LookupFunction + return fn +} + +// ── URL policy ──────────────────────────────────────────────────────────────── + +/** + * Accept only a plain absolute https URL. Credentials in the URL are refused + * because they would be sent to whatever the name resolves to. + */ +export function parseOutboundUrl(raw: string): URL | null { + let u: URL + try { + u = new URL(raw) + } catch { + return null + } + if (u.protocol !== 'https:') return null + if (u.username || u.password) return null + if (!u.hostname) return null + // `URL.hostname` keeps the brackets on an IPv6 literal, and `isIP('[::1]')` + // is 0 — so without unwrapping them a bracketed literal would slip past the + // literal-address check and only be caught later by the lookup guard. + const host = u.hostname.startsWith('[') && u.hostname.endsWith(']') + ? u.hostname.slice(1, -1) + : u.hostname + if (isIP(host) !== 0 && !isPublicUnicastAddress(host)) return null + return u +} + +// ── Request ─────────────────────────────────────────────────────────────────── + +/** + * Perform one hardened GET. Never throws; every outcome is a typed result. + * + * There is no `followRedirects`, no `insecure`, and no `agent` parameter on + * purpose: each would be a way for a future call site to opt out of one of the + * guarantees above. + */ +export function wrcHttpsGet(url: string, options: WrcHttpOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? WRC_HTTP_DEFAULT_TIMEOUT_MS + const maxBytes = options.maxBytes ?? WRC_HTTP_DEFAULT_MAX_BYTES + const parsed = parseOutboundUrl(url) + + if (!parsed) { + return Promise.resolve({ + ok: false, + code: 'url_rejected', + message: 'Only credential-free absolute https URLs to public addresses are allowed', + }) + } + + return new Promise((resolve) => { + let settled = false + const finish = (r: WrcHttpResult) => { + if (settled) return + settled = true + clearTimeout(deadline) + resolve(r) + } + + const reqOptions: RequestOptions = { + protocol: 'https:', + hostname: parsed.hostname, + port: parsed.port || 443, + path: `${parsed.pathname}${parsed.search}`, + method: 'GET', + headers: { + Accept: options.accept ?? 'application/json', + 'Accept-Encoding': 'identity', + 'User-Agent': 'WRDesk-WRC-Client/1.0', + }, + // TLS floor. rejectUnauthorized is left at its secure default and is + // intentionally not exposed as an option anywhere in this module. + minVersion: 'TLSv1.2', + lookup: guardedLookup(options.lookup ?? (dnsLookup as unknown as LookupFunction)), + } + + const req = httpsRequest(reqOptions, (res) => { + const status = res.statusCode ?? 0 + + if (status >= 300 && status < 400) { + res.destroy() + finish({ + ok: false, + code: 'redirect_refused', + message: `Redirect (${status}) refused; the client never follows redirects`, + status, + }) + return + } + + const chunks: Buffer[] = [] + let total = 0 + res.on('data', (c: Buffer) => { + total += c.length + if (total > maxBytes) { + res.destroy() + req.destroy() + finish({ + ok: false, + code: 'response_too_large', + message: `Response exceeded ${maxBytes} bytes and was discarded`, + }) + return + } + chunks.push(c) + }) + res.on('error', (e: Error) => { + finish({ ok: false, code: 'network_error', message: e.message }) + }) + res.on('end', () => { + if (settled) return + const bytes = Buffer.concat(chunks) + if (status < 200 || status >= 300) { + finish({ ok: false, code: 'http_status', message: `HTTP ${status}`, status }) + return + } + if (!options.expectJson) { + finish({ ok: true, status, bytes }) + return + } + try { + finish({ ok: true, status, bytes, json: JSON.parse(bytes.toString('utf8')) }) + } catch { + finish({ ok: false, code: 'invalid_json', message: 'Response body was not valid JSON' }) + } + }) + }) + + const deadline = setTimeout(() => { + req.destroy() + finish({ ok: false, code: 'timeout', message: `Request exceeded ${timeoutMs} ms` }) + }, timeoutMs) + if (typeof deadline.unref === 'function') deadline.unref() + + req.on('error', (e: NodeJS.ErrnoException) => { + if (e.code === 'WRC_BLOCKED_ADDRESS') { + finish({ ok: false, code: 'blocked_address', message: e.message }) + return + } + const tlsish = + typeof e.code === 'string' && + (e.code.startsWith('ERR_TLS') || + e.code.startsWith('CERT_') || + e.code.startsWith('UNABLE_TO_') || + e.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || + e.code === 'SELF_SIGNED_CERT_IN_CHAIN' || + e.code === 'EPROTO') + finish({ + ok: false, + code: tlsish ? 'tls_error' : 'network_error', + message: e.message, + }) + }) + + req.end() + }) +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/index.ts b/code/apps/electron-vite-project/electron/main/wrc/index.ts new file mode 100644 index 000000000..8ac02bc98 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/index.ts @@ -0,0 +1,84 @@ +/** + * WRC resolution client (Phase 3) — public surface. + * + * Contract: `docs/spec/WRC-Registry-API-Contract_v1.0.md` @20794bff, an + * INTERFACE REFERENCE. There is no WRC service code in this repo and none of + * these modules construct or sign publisher material. + * + * Placement note (deviation from the order's suggestion, deliberate): 3A was + * suggested for `packages/ingestion-core` or `packages/shared`. Both are + * imported by the MV3 extension, and the hardened client needs `node:dns`, + * `node:net`, and `node:https` for its SSRF guard and TLS floor. Putting it + * there would either break the extension build or invite a browser-safe + * fallback that silently drops the guards. Since the order also states 3B is + * "all in Electron main" and 3A is "used by 3B exclusively", the client lives + * beside its only consumer. + */ + +export { + wrcHttpsGet, + isPublicUnicastAddress, + parseOutboundUrl, + WRC_HTTP_DEFAULT_MAX_BYTES, + WRC_HTTP_DEFAULT_TIMEOUT_MS, +} from './httpsClient' +export type { WrcHttpResult, WrcHttpErrorCode, WrcHttpOptions } from './httpsClient' + +export * from './wrcContract' +export { + wrcHashBytes, + wrcHashObject, + wrcCanonicalBytes, + wrcVerifyEd25519, + wrcVerifyObjectSignature, + wrcCountersignatureMessage, + wrcFoldInclusionProof, +} from './wrcCrypto' + +export { + verifyCatalogHead, + verifyEnvelope, + verifyEvp, + resolveSigningKey, +} from './wrcVerify' +export type { + WrcVerdict, + WrcVerifyReason, + WrcPublisherKeys, + WrcFreshness, +} from './wrcVerify' + +export { + validateDomainDualChannel, + parseWrTxtRecords, + rootKeyFingerprint, +} from './dualChannel' +export type { DualChannelResult, DualChannelReason } from './dualChannel' + +export { + createWrcHttpTransport, + createUnconfiguredWrcTransport, +} from './wrcTransport' +export type { WrcTransport, WrcTransportResult, WrcTxtResult } from './wrcTransport' + +export { + WrcResolvedRecordStore, + createMemoryPersistence, + createFilePersistence, + defaultResolvedRecordPath, +} from './resolvedRecordStore' +export type { + WrcResolvedRecord, + WrcCacheState, + WrcUnresolvedCaptureState, + WrcStorePersistence, +} from './resolvedRecordStore' + +export { WrcResolutionClient, isTransportOutage } from './resolutionClient' +export type { + WrcResolutionResult, + WrcResolutionSuccess, + WrcResolutionFailure, + WrcResolutionReason, + ResolvePublisherOptions, +} from './resolutionClient' diff --git a/code/apps/electron-vite-project/electron/main/wrc/offerPresentation.ts b/code/apps/electron-vite-project/electron/main/wrc/offerPresentation.ts new file mode 100644 index 000000000..c3d87ea78 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/offerPresentation.ts @@ -0,0 +1,132 @@ +/** + * 5B — what a Connect offer is allowed to SHOW, and 5C's manual-entry gate. + * + * This module builds the presentation projection; it renders nothing. Keeping + * the rule here rather than in the surface is the same reasoning as the rule-8 + * alert: a per-surface predicate is how "never show carrier text" drifts into + * "usually does not show carrier text". + * + * A2 / EVP-first-render is the load-bearing constraint. After capture and + * verification the first render shows ONLY the signed `value_statement` and + * `self_description` from the verified EVP. Whatever the carrying email said + * about itself never enters the offer — and there is no degraded offer: no + * verified EVP means no offer, not an offer built from carrier bytes. + */ + +import { formatBaselineCodeForDisplay } from '@repo/ingestion-core' +import type { WrcEvp, WrcSuspension } from './wrcContract' +import type { WrcStatusComposition } from './entryStatusSurface' + +export type OfferPresentationRefusal = + /** No verified EVP. A2: never fall back to carrier text. */ + | 'no_verified_evp' + /** The three-layer composition says not admissible. */ + | 'not_admissible' + /** Resolution never completed (capture-error path, not a status surface). */ + | 'unresolved' + +export interface OfferPresentation { + /** Publisher identity as established by the dual channel, not by the carrier. */ + publisher_part: string + verified_domain: string + /** True only when DNS + manifest + cross-check all passed. */ + publisher_domain_verified: boolean + entry_local_part: string + /** Locally rendered from the validated identifier only (O3). */ + code_display: string | null + /** From the VERIFIED EVP. Never from the carrier. */ + value_statement: string + self_description: string + scope_directory: Array<{ name: string; desc: string; size_hint_b: number }> + next_steps: string[] + /** A4 — per-item "verify in repository" link on offer and consent preview. */ + audit_url: string | null + catalog_epoch: number + resolution_mode: 'public' | 'session_bound' + session_bound: boolean + /** Visible staleness (A3) — a stale head still displays, it just cannot admit. */ + stale: boolean + /** A5 — platform suspension is its own visible state. */ + suspension: WrcSuspension | null +} + +export interface BuildOfferPresentationInput { + publisherPart: string + domain: string + publisherDomainVerified: boolean + entryLocalPart: string + /** Canonical WR code, for the LOCAL renderer. Null when unknown. */ + wrCodeCanonical: string | null + evp: WrcEvp | null + status: WrcStatusComposition + auditUrlBase?: string | null + evpRef?: string | null + catalogEpoch: number + resolutionMode: 'public' | 'session_bound' + stale: boolean + suspension?: WrcSuspension | null +} + +export type BuildOfferPresentationResult = + | { ok: true; presentation: OfferPresentation } + | { ok: false; refusal: OfferPresentationRefusal } + +/** + * Build the offer projection, or refuse. + * + * Refusal is not an error path bolted on — it is the majority case the design + * exists for. An unresolved code, an inadmissible status, or a missing EVP each + * produce a typed refusal, and the caller shows a status surface instead of an + * offer. There is no branch that assembles a partial offer. + */ +export function buildOfferPresentation( + input: BuildOfferPresentationInput, +): BuildOfferPresentationResult { + if (!input.publisherPart || !input.domain) return { ok: false, refusal: 'unresolved' } + if (!input.status.admissible) return { ok: false, refusal: 'not_admissible' } + // A2: no verified EVP ⇒ no offer. Deliberately checked AFTER admissibility so + // a suspended entry reports its status rather than a missing-EVP technicality. + if (!input.evp) return { ok: false, refusal: 'no_verified_evp' } + + return { + ok: true, + presentation: { + publisher_part: input.publisherPart, + verified_domain: input.domain, + publisher_domain_verified: input.publisherDomainVerified, + entry_local_part: input.entryLocalPart, + code_display: renderCodeForDisplay(input.wrCodeCanonical), + value_statement: input.evp.value_statement, + self_description: input.evp.self_description, + scope_directory: input.evp.scope_directory.map((s) => ({ + name: s.name, + desc: s.desc, + size_hint_b: s.size_hint_b, + })), + next_steps: [...input.evp.next_steps], + audit_url: buildAuditUrl(input.auditUrlBase, input.evpRef), + catalog_epoch: input.catalogEpoch, + resolution_mode: input.resolutionMode, + session_bound: input.resolutionMode === 'session_bound', + stale: input.stale, + suspension: input.suspension ?? null, + }, + } +} + +/** + * O3 local renderer. Renders from a VALIDATED canonical identifier only, and + * only on request. A received rendering is never displayed (P12) — this + * regenerates the grouping locally from the identifier the check profile + * accepted, so there is no path from carrier bytes to a rendered code. + */ +export function renderCodeForDisplay(canonical: string | null | undefined): string | null { + if (!canonical) return null + return formatBaselineCodeForDisplay(canonical) +} + +/** A4 — the per-item audit link. Null when either half is unknown. */ +export function buildAuditUrl(base: string | null | undefined, hash: string | null | undefined): string | null { + if (!base || !hash) return null + return `${String(base).replace(/\/+$/, '')}/v1/audit/${encodeURIComponent(hash)}` +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/resolutionClient.ts b/code/apps/electron-vite-project/electron/main/wrc/resolutionClient.ts new file mode 100644 index 000000000..2404eb4a7 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/resolutionClient.ts @@ -0,0 +1,289 @@ +/** + * 3B — WRC registry resolution client. + * + * The one place a WR code becomes (or fails to become) a resolved publisher. + * It lives in Electron main because MV3 has no DNS, and it is exposed to the + * extension over the existing loopback RPC rather than being reachable from a + * renderer directly. + * + * The order of operations is the security property, not an implementation + * detail. Read it top to bottom: + * + * capture (local check only, already done by `captureBaselineCode`) + * → registry resolve ... a CLAIM, trusted for nothing + * → dual-channel domain validation (DNS + manifest) + part cross-check + * → catalog head verification ... signature, epoch floor, freshness + * → envelope verification ... publisher sig, countersig, inclusion + * → EVP verification ... budget, part/entry binding + * + * There is no branch that reaches a trusted presentation having skipped a + * step, and every failure returns a distinct typed reason so the Phase-4 status + * surface never has to guess which leg failed. + * + * What this module deliberately does NOT do: build an offer, decide a tier, + * touch `TierSignals`, or render anything. Resolution answers "who is this and + * is the material authentic", nothing further. + */ + +import { + decodeEntry, + decodeEnvelope, + decodeResolveClaim, + type WrcEntry, + type WrcEvp, + type WrcPublisherStatus, +} from './wrcContract' +import { validateDomainDualChannel, type DualChannelReason } from './dualChannel' +import { + verifyCatalogHead, + verifyEnvelope, + verifyEvp, + type WrcFreshness, + type WrcPublisherKeys, + type WrcVerifyReason, +} from './wrcVerify' +import type { WrcResolvedRecord, WrcResolvedRecordStore } from './resolvedRecordStore' +import type { WrcTransport, WrcTransportErrorCode } from './wrcTransport' + +// ── Failure vocabulary ──────────────────────────────────────────────────────── + +export type WrcResolutionReason = + /** §4.2 uniform 404 → the Capture-Error path, never the status path. */ + | 'unknown_identifier' + /** Registry unreachable / refused / malformed. Still only a claim, but we have none. */ + | 'registry_unavailable' + | 'registry_response_malformed' + /** The registry named a different part than the one asked for. */ + | 'registry_part_mismatch' + /** Dual-channel leg failed; see `detail` for which. */ + | DualChannelReason + /** Verification leg failed. */ + | WrcVerifyReason + /** Object fetch failed at transport level. */ + | 'object_unavailable' + | 'object_malformed' + /** Entry exists but is not published (publisher-signed status). */ + | 'entry_not_published' + +export interface WrcResolutionFailure { + ok: false + reason: WrcResolutionReason + detail?: string + /** True when the failure is a capture-error, not a publisher-status surface. */ + captureError: boolean +} + +export interface WrcResolutionSuccess { + ok: true + publisherPart: string + domain: string + status: WrcPublisherStatus + generation: number + freshness: WrcFreshness + stale_by_s: number + epoch: number + record: WrcResolvedRecord + /** Present when an entry was requested and verified. */ + entry?: WrcEntry + /** Present when the entry's EVP was fetched and verified (3F). */ + evp?: WrcEvp + /** A5 — platform suspension is its own visible state, never silent absence. */ + suspension?: { since: number; reason_code: string; reversible: boolean } +} + +export type WrcResolutionResult = WrcResolutionSuccess | WrcResolutionFailure + +function fail( + reason: WrcResolutionReason, + detail?: string, + captureError = false, +): WrcResolutionFailure { + return { ok: false, reason, detail, captureError } +} + +export interface WrcResolutionClientDeps { + transport: WrcTransport + store: WrcResolvedRecordStore + /** Raw base64url Ed25519 public key of the WRC ingest countersigner. */ + ingestPublicKey: string + /** Unix seconds. Injected for deterministic freshness tests. */ + now?: () => number +} + +export interface ResolvePublisherOptions { + /** Also fetch + verify this entry and its EVP. */ + entryId?: string + /** + * Return a suspended object as a visible state instead of refusing. Only the + * audit / status surface sets this; admission paths never do. + */ + allowSuspended?: boolean +} + +export class WrcResolutionClient { + private readonly now: () => number + + constructor(private readonly deps: WrcResolutionClientDeps) { + this.now = deps.now ?? (() => Math.floor(Date.now() / 1000)) + } + + /** + * Resolve a publisher part, optionally an entry beneath it, running the full + * chain. Never throws. + */ + async resolvePublisher( + publisherPart: string, + options: ResolvePublisherOptions = {}, + ): Promise { + const part = publisherPart.trim() + + // ── 1. Registry answer — a CLAIM ───────────────────────────────────────── + const claimRes = await this.deps.transport.resolve(part) + if (!claimRes.ok) { + if (claimRes.status === 404) return fail('unknown_identifier', undefined, true) + return fail('registry_unavailable', `${claimRes.code}: ${claimRes.message}`) + } + const claim = decodeResolveClaim(claimRes.value) + if (!claim) return fail('registry_response_malformed') + if (claim.catalog_head.publisher_part !== part) { + return fail('registry_part_mismatch', claim.catalog_head.publisher_part) + } + + // ── 2. Dual-channel validation BEFORE anything is trusted ──────────────── + const channels = await validateDomainDualChannel({ + transport: this.deps.transport, + claimedDomain: claim.domain, + resolvedPublisherPart: part, + claimedRootFingerprint: claim.root_fingerprint, + }) + if (!channels.ok) return fail(channels.reason, channels.detail) + + // ── 3. Catalog head: signature, epoch floor, freshness ─────────────────── + // Delta v1.1 §A: the delegation comes from the head itself. Nothing is read + // from the store and nothing is fetched — verification is deterministic + // from the DNS-pinned root plus the embedded record. + const keys: WrcPublisherKeys = { + rootKid: channels.rootKid, + rootPub: channels.rootPub, + headDelegation: claim.catalog_head.delegation, + } + const headVerdict = verifyCatalogHead({ + head: claim.catalog_head, + keys, + expectedPublisherPart: part, + expectedDomain: channels.domain, + lastSeenEpoch: this.deps.store.lastSeenEpoch(part), + nowS: this.now(), + }) + if (!headVerdict.ok) return fail(headVerdict.reason, headVerdict.detail) + const { head, freshness, stale_by_s } = headVerdict.value + + // The floor rises only once the head is fully verified. + this.deps.store.noteAcceptedEpoch(part, head.epoch) + + const record: WrcResolvedRecord = { + publisher_part: part, + domain: channels.domain, + status: claim.status, + generation: claim.generation, + root_kid: channels.rootKid, + root_pub: channels.rootPub, + root_fingerprint: channels.rootFingerprint, + last_seen_epoch: head.epoch, + catalog_root: head.catalog_root, + head_issued_at: head.issued_at, + freshness_window_s: head.freshness_window_s, + delegation_kid: head.kid === channels.rootKid ? null : head.kid, + cache_state: + claim.status === 'active' ? (freshness === 'stale' ? 'stale' : 'validated') : 'demoted', + resolved_at: this.now(), + // Retained for audit/rotation review only. Verification above never reads + // this field — it used the head-embedded record. + delegations: head.delegation ? [head.delegation] : [], + } + this.deps.store.upsert(record) + + const base: WrcResolutionSuccess = { + ok: true, + publisherPart: part, + domain: channels.domain, + status: claim.status, + generation: claim.generation, + freshness, + stale_by_s, + epoch: head.epoch, + record, + } + + if (!options.entryId) return base + + // ── 4. Entry envelope: publisher sig + countersig + inclusion ──────────── + const entryRes = await this.deps.transport.entry(part, options.entryId) + if (!entryRes.ok) { + if (entryRes.status === 404) return fail('unknown_identifier', undefined, true) + return fail('object_unavailable', `${entryRes.code}: ${entryRes.message}`) + } + const entryEnvelope = decodeEnvelope(entryRes.value) + if (!entryEnvelope) return fail('object_malformed', 'entry envelope') + + const entryVerdict = verifyEnvelope({ + envelope: entryEnvelope, + keys, + verifiedHead: head, + ingestPub: this.deps.ingestPublicKey, + allowSuspended: options.allowSuspended === true, + }) + if (!entryVerdict.ok) return fail(entryVerdict.reason, entryVerdict.detail) + + const entry = decodeEntry(entryVerdict.value.envelope.object) + if (!entry) return fail('object_malformed', 'entry') + if (entry.publisher_part !== part) return fail('evp_part_mismatch', entry.publisher_part) + if (entry.status !== 'published' && !options.allowSuspended) { + return fail('entry_not_published', entry.status) + } + + const suspension = entryVerdict.value.envelope.suspension ?? undefined + + // ── 5. EVP: fetch by ref, verify envelope, then budget + binding ───────── + const evpRes = await this.deps.transport.object(entry.evp_ref) + if (!evpRes.ok) { + if (evpRes.status === 404) return fail('unknown_identifier', undefined, true) + return fail('object_unavailable', `${evpRes.code}: ${evpRes.message}`) + } + const evpEnvelope = decodeEnvelope(evpRes.value) + if (!evpEnvelope) return fail('object_malformed', 'evp envelope') + + const evpEnvVerdict = verifyEnvelope({ + envelope: evpEnvelope, + keys, + verifiedHead: head, + ingestPub: this.deps.ingestPublicKey, + allowSuspended: options.allowSuspended === true, + }) + if (!evpEnvVerdict.ok) return fail(evpEnvVerdict.reason, evpEnvVerdict.detail) + + // The envelope's own hash is what `evp_ref` pointed at — bind them. + if (evpEnvelope.hash !== entry.evp_ref) { + return fail('envelope_object_hash_mismatch', `evp_ref=${entry.evp_ref}`) + } + + const evpVerdict = verifyEvp({ + object: evpEnvVerdict.value.envelope.object, + expectedPublisherPart: part, + expectedEntryId: entry.entry_id, + }) + if (!evpVerdict.ok) return fail(evpVerdict.reason, evpVerdict.detail) + + return { ...base, entry, evp: evpVerdict.value, suspension } + } +} + +/** Transport-level codes that mean "we never got an answer", for callers that log. */ +export function isTransportOutage(code: WrcTransportErrorCode): boolean { + return ( + code === 'network_error' || + code === 'timeout' || + code === 'dns_error' || + code === 'not_configured' + ) +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/resolvedRecordStore.ts b/code/apps/electron-vite-project/electron/main/wrc/resolvedRecordStore.ts new file mode 100644 index 000000000..46bf0a65a --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/resolvedRecordStore.ts @@ -0,0 +1,174 @@ +/** + * D6 — per-publisher resolved record, plus the persisted epoch floor (A3). + * + * This store is a CACHE of registry state. It may be demoted, refreshed, or + * discarded at any time (§XVI.15.3); the authoritative append-only assignment + * ledger lives in the registry service. + * + * The anti-rollback epoch floor (A3) USED to live here too, in the same plain + * JSON file. It no longer does. A floor that a deletable userData file can + * reset is decorative: anyone able to remove the file could let a publisher + * replay an older, correctly signed CatalogHead. The floor now lives in the + * native DB protection class — see `epochFloorStore.ts` — and this module holds + * only a snapshot of it for display, never as the source of truth. + * + * `TierSignals` / `tierSteps` are untouched by this module, per 3B.5 — a + * resolved publisher is not a trust tier and must not feed one. + * + * Persistence is injectable so tests are in-memory and deterministic. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { createMemoryEpochFloorStore, type WrcEpochFloorStore } from './epochFloorStore' +import type { WrcDelegationRecord, WrcPublisherStatus } from './wrcContract' + +/** Cache demotion states per §XVI.15.3 / A3. */ +export type WrcCacheState = + /** Verified against a fresh head. Usable for new admissions. */ + | 'validated' + /** Authentic but past its freshness window: displayable, no NEW admissions. */ + | 'stale' + /** Registry says the publisher is no longer active; retained for display. */ + | 'demoted' + +export interface WrcResolvedRecord { + publisher_part: string + /** Established by the dual channel, not copied from the registry answer. */ + domain: string + status: WrcPublisherStatus + generation: number + root_kid: string + root_pub: string + root_fingerprint: string + /** Delta 3D additions. */ + last_seen_epoch: number + catalog_root: string + head_issued_at: number + freshness_window_s: number + delegation_kid: string | null + /** Bookkeeping. */ + cache_state: WrcCacheState + resolved_at: number + delegations: WrcDelegationRecord[] +} + +/** + * §XVI.15.1 — a code that captured and passed its local check but has not + * completed resolution is NEVER presented as validated. This is the state such + * a capture sits in, and it is a first-class value rather than the absence of + * a record, so a caller cannot mistake "not yet resolved" for "no such thing". + */ +export type WrcUnresolvedCaptureState = + | 'awaiting_resolution' + | 'resolution_failed' + | 'unknown_identifier' + +export interface WrcStorePersistence { + read(): Record | null + write(value: Record): void +} + +/** In-memory persistence — the default for tests. */ +export function createMemoryPersistence(seed?: Record): WrcStorePersistence { + let state: Record = seed ? { ...seed } : {} + return { + read: () => state, + write: (v) => { + state = v + }, + } +} + +/** JSON file persistence with atomic replace. */ +export function createFilePersistence(filePath: string): WrcStorePersistence { + return { + read() { + try { + if (!existsSync(filePath)) return null + return JSON.parse(readFileSync(filePath, 'utf8')) as Record + } catch { + return null + } + }, + write(value) { + try { + mkdirSync(dirname(filePath), { recursive: true }) + const tmp = `${filePath}.tmp` + writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8') + renameSync(tmp, filePath) + } catch { + /* cache write failure must never break resolution */ + } + }, + } +} + +export function defaultResolvedRecordPath(userDataDir: string): string { + return join(userDataDir, 'wrc-resolved-publishers.json') +} + +export class WrcResolvedRecordStore { + private records = new Map() + + /** + * @param persistence cache persistence (plain JSON is fine — it is cache) + * @param epochFloor the authoritative anti-rollback floor. Native-DB backed + * in production; in-memory only in tests. Never read from `persistence`. + */ + constructor( + private readonly persistence: WrcStorePersistence, + private readonly epochFloor: WrcEpochFloorStore = createMemoryEpochFloorStore(), + ) { + const raw = persistence.read() + if (!raw) return + const recs = raw.records + if (recs && typeof recs === 'object') { + for (const [k, v] of Object.entries(recs as Record)) { + this.records.set(k, v as WrcResolvedRecord) + } + } + // `epoch_floor` in a legacy cache file is deliberately ignored. Reading it + // back would reintroduce exactly the reset path this move removes. + } + + private flush(): void { + this.persistence.write({ + version: 2, + records: Object.fromEntries(this.records), + }) + } + + get(publisherPart: string): WrcResolvedRecord | null { + return this.records.get(publisherPart) ?? null + } + + /** + * The anti-rollback floor, read from the protected store. Survives eviction + * of the cached record on purpose: forgetting a publisher must not reopen a + * rollback window. + */ + lastSeenEpoch(publisherPart: string): number | null { + return this.epochFloor.get(publisherPart) + } + + /** Raise the floor. There is no lowering path here or in the floor store. */ + noteAcceptedEpoch(publisherPart: string, epoch: number): void { + this.epochFloor.raise(publisherPart, epoch) + } + + upsert(record: WrcResolvedRecord): void { + this.records.set(record.publisher_part, record) + this.epochFloor.raise(record.publisher_part, record.last_seen_epoch) + this.flush() + } + + /** §XVI.15.3 cache demotion — visible state change, never a silent delete. */ + demote(publisherPart: string, to: WrcCacheState): void { + const rec = this.records.get(publisherPart) + if (!rec) return + rec.cache_state = to + this.records.set(publisherPart, rec) + this.flush() + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/wrcContract.ts b/code/apps/electron-vite-project/electron/main/wrc/wrcContract.ts new file mode 100644 index 000000000..437dd3692 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/wrcContract.ts @@ -0,0 +1,443 @@ +/** + * WRC Registry API Contract v1.0 — wire object shapes and fail-closed decoders. + * + * Contract reference: `docs/spec/WRC-Registry-API-Contract_v1.0.md` @20794bff. + * This module is an INTERFACE REFERENCE implementation for the Phase-3 client + * only. It contains no service code and never constructs objects a publisher + * or the WRC would sign — it only reads them, and refuses whatever it cannot + * fully understand. + * + * Decoding discipline: every decoder returns `null` rather than a partially + * populated object. A field the client would later branch on must be present + * and well-typed at decode time, so no downstream code has to ask "was that + * actually in the response, or is it my default?". + */ + +// ── Primitives ──────────────────────────────────────────────────────────────── + +/** `sha256:` per contract §2. */ +export type WrcHash = string + +export const WRC_HASH_RE = /^sha256:[A-Za-z0-9_-]{43}$/ + +export function isWrcHash(v: unknown): v is WrcHash { + return typeof v === 'string' && WRC_HASH_RE.test(v) +} + +/** Base64url, unpadded — signatures and raw keys. */ +export const WRC_B64URL_RE = /^[A-Za-z0-9_-]+$/ + +function isB64Url(v: unknown): v is string { + return typeof v === 'string' && v.length > 0 && WRC_B64URL_RE.test(v) +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === 'string' && v.trim().length > 0 +} + +function isSafeNonNegativeInt(v: unknown): v is number { + return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 +} + +function asRecord(v: unknown): Record | null { + return typeof v === 'object' && v !== null && !Array.isArray(v) + ? (v as Record) + : null +} + +// ── §3.1 CatalogHead ────────────────────────────────────────────────────────── + +export interface WrcCatalogHead { + type: 'wrc/catalog-head' + publisher_part: string + domain: string + catalog_root: WrcHash + epoch: number + issued_at: number + freshness_window_s: number + kid: string + /** + * Delta v1.1 §A. REQUIRED non-null whenever `kid` is not the publisher's + * root key; null for a root-signed head. Carried in the head so that + * verification completes from the DNS-pinned root plus this record alone — + * no fetch may occur in the verification path, which also makes the chain + * immune to selective blocking of a side-fetch. + * + * Decoded as `null` when absent so v1.0 heads still parse; the requirement + * is enforced in `verifyCatalogHead`, which is the only place that knows + * which key is the root. + */ + delegation: WrcDelegationRecord | null + sig: string +} + +export function decodeCatalogHead(value: unknown): WrcCatalogHead | null { + const o = asRecord(value) + if (!o) return null + if (o.type !== 'wrc/catalog-head') return null + if (!isNonEmptyString(o.publisher_part) || !isNonEmptyString(o.domain)) return null + if (!isWrcHash(o.catalog_root)) return null + if (!isSafeNonNegativeInt(o.epoch) || !isSafeNonNegativeInt(o.issued_at)) return null + if (!isSafeNonNegativeInt(o.freshness_window_s)) return null + if (!isNonEmptyString(o.kid) || !isB64Url(o.sig)) return null + + // A present-but-malformed delegation is a decode failure, not a silent null: + // downgrading it would turn a broken chain into "root-signed head" and hand + // the verifier the wrong question. + let delegation: WrcDelegationRecord | null = null + if (o.delegation !== null && o.delegation !== undefined) { + delegation = decodeDelegationRecord(o.delegation) + if (!delegation) return null + } + + return { + type: 'wrc/catalog-head', + publisher_part: o.publisher_part, + domain: o.domain.toLowerCase(), + catalog_root: o.catalog_root, + epoch: o.epoch, + issued_at: o.issued_at, + freshness_window_s: o.freshness_window_s, + kid: o.kid, + delegation, + sig: o.sig, + } +} + +// ── §3.2 Entry ──────────────────────────────────────────────────────────────── + +/** Publisher-signed entry status. `draft` never appears on the wire (§3.2). */ +export type WrcEntryStatus = 'published' | 'suspended' | 'retired' + +export interface WrcEntryCode { + canonical: string + channels: string[] +} + +export interface WrcEntry { + type: 'wrc/entry' + entry_id: string + publisher_part: string + display: { name: string; icon: WrcHash | null; value_statement: string } + codes: WrcEntryCode[] + scopes: WrcHash[] + evp_ref: WrcHash + template_ref: WrcHash | null + status: WrcEntryStatus + epoch: number + kid: string + sig: string +} + +export function decodeEntry(value: unknown): WrcEntry | null { + const o = asRecord(value) + if (!o) return null + if (o.type !== 'wrc/entry') return null + if (!isNonEmptyString(o.entry_id) || !isNonEmptyString(o.publisher_part)) return null + + const d = asRecord(o.display) + if (!d || !isNonEmptyString(d.name) || typeof d.value_statement !== 'string') return null + const icon = d.icon === null || d.icon === undefined ? null : isWrcHash(d.icon) ? d.icon : undefined + if (icon === undefined) return null + + if (!Array.isArray(o.codes)) return null + const codes: WrcEntryCode[] = [] + for (const c of o.codes) { + const cr = asRecord(c) + if (!cr || !isNonEmptyString(cr.canonical) || !Array.isArray(cr.channels)) return null + if (!cr.channels.every((ch) => typeof ch === 'string')) return null + codes.push({ canonical: cr.canonical, channels: cr.channels as string[] }) + } + + if (!Array.isArray(o.scopes) || !o.scopes.every(isWrcHash)) return null + if (!isWrcHash(o.evp_ref)) return null + const templateRef = + o.template_ref === null || o.template_ref === undefined + ? null + : isWrcHash(o.template_ref) + ? o.template_ref + : undefined + if (templateRef === undefined) return null + + if (o.status !== 'published' && o.status !== 'suspended' && o.status !== 'retired') return null + if (!isSafeNonNegativeInt(o.epoch)) return null + if (!isNonEmptyString(o.kid) || !isB64Url(o.sig)) return null + + return { + type: 'wrc/entry', + entry_id: o.entry_id, + publisher_part: o.publisher_part, + display: { name: d.name, icon, value_statement: d.value_statement }, + codes, + scopes: o.scopes as WrcHash[], + evp_ref: o.evp_ref, + template_ref: templateRef, + status: o.status, + epoch: o.epoch, + kid: o.kid, + sig: o.sig, + } +} + +// ── §3.3 EntryValuePackage ──────────────────────────────────────────────────── + +export interface WrcScopeDirectoryItem { + scope: WrcHash + name: string + desc: string + size_hint_b: number + prefetch: 'none' | 'recommended' +} + +export interface WrcEvp { + type: 'wrc/evp' + publisher_part: string + entry_id: string + self_description: string + value_statement: string + scope_directory: WrcScopeDirectoryItem[] + preparation_view: WrcHash | null + next_steps: string[] + audit_links: boolean + epoch: number + kid: string + sig: string +} + +/** §3.3 platform-wide budget: canonical bytes ≤ 64 KiB. Never truncate. */ +export const WRC_EVP_MAX_CANONICAL_BYTES = 65_536 + +export function decodeEvp(value: unknown): WrcEvp | null { + const o = asRecord(value) + if (!o) return null + if (o.type !== 'wrc/evp') return null + if (!isNonEmptyString(o.publisher_part) || !isNonEmptyString(o.entry_id)) return null + if (typeof o.self_description !== 'string' || typeof o.value_statement !== 'string') return null + + if (!Array.isArray(o.scope_directory)) return null + const dir: WrcScopeDirectoryItem[] = [] + for (const s of o.scope_directory) { + const sr = asRecord(s) + if (!sr || !isWrcHash(sr.scope)) return null + if (typeof sr.name !== 'string' || typeof sr.desc !== 'string') return null + if (!isSafeNonNegativeInt(sr.size_hint_b)) return null + if (sr.prefetch !== 'none' && sr.prefetch !== 'recommended') return null + dir.push({ + scope: sr.scope, + name: sr.name, + desc: sr.desc, + size_hint_b: sr.size_hint_b, + prefetch: sr.prefetch, + }) + } + + const prep = + o.preparation_view === null || o.preparation_view === undefined + ? null + : isWrcHash(o.preparation_view) + ? o.preparation_view + : undefined + if (prep === undefined) return null + + if (!Array.isArray(o.next_steps) || !o.next_steps.every((s) => typeof s === 'string')) return null + if (typeof o.audit_links !== 'boolean') return null + if (!isSafeNonNegativeInt(o.epoch)) return null + if (!isNonEmptyString(o.kid) || !isB64Url(o.sig)) return null + + return { + type: 'wrc/evp', + publisher_part: o.publisher_part, + entry_id: o.entry_id, + self_description: o.self_description, + value_statement: o.value_statement, + scope_directory: dir, + preparation_view: prep, + next_steps: o.next_steps as string[], + audit_links: o.audit_links, + epoch: o.epoch, + kid: o.kid, + sig: o.sig, + } +} + +// ── §3.4 DualAssuranceEnvelope ──────────────────────────────────────────────── + +export interface WrcInclusionStep { + pos: 'left' | 'right' + hash: WrcHash +} + +export interface WrcSuspension { + since: number + reason_code: string + reversible: boolean +} + +export interface WrcEnvelope { + object: Record + hash: WrcHash + publisher_sig_valid_kid: string + ingest_countersig: { kid: string; at: number; sig: string } + epoch: number + inclusion_proof: WrcInclusionStep[] + suspension: WrcSuspension | null +} + +export function decodeEnvelope(value: unknown): WrcEnvelope | null { + const o = asRecord(value) + if (!o) return null + const obj = asRecord(o.object) + if (!obj) return null + if (!isWrcHash(o.hash)) return null + if (!isNonEmptyString(o.publisher_sig_valid_kid)) return null + + const cs = asRecord(o.ingest_countersig) + if (!cs || !isNonEmptyString(cs.kid) || !isSafeNonNegativeInt(cs.at) || !isB64Url(cs.sig)) return null + if (!isSafeNonNegativeInt(o.epoch)) return null + + if (!Array.isArray(o.inclusion_proof)) return null + const proof: WrcInclusionStep[] = [] + for (const step of o.inclusion_proof) { + const sr = asRecord(step) + if (!sr) return null + if (sr.pos !== 'left' && sr.pos !== 'right') return null + if (!isWrcHash(sr.hash)) return null + proof.push({ pos: sr.pos, hash: sr.hash }) + } + + let suspension: WrcSuspension | null = null + if (o.suspension !== null && o.suspension !== undefined) { + const s = asRecord(o.suspension) + if (!s || !isSafeNonNegativeInt(s.since) || !isNonEmptyString(s.reason_code)) return null + if (typeof s.reversible !== 'boolean') return null + suspension = { since: s.since, reason_code: s.reason_code, reversible: s.reversible } + } + + return { + object: obj, + hash: o.hash, + publisher_sig_valid_kid: o.publisher_sig_valid_kid, + ingest_countersig: { kid: cs.kid, at: cs.at, sig: cs.sig }, + epoch: o.epoch, + inclusion_proof: proof, + suspension, + } +} + +// ── §3.6 DelegationRecord ───────────────────────────────────────────────────── + +export interface WrcDelegationRecord { + type: 'wrc/catalog-delegation' + publisher_part: string + delegate_kid: string + delegate_pub: string + authority: 'catalog-signing-only' + valid_from_epoch: number + revoked_from_epoch: number | null + root_kid: string + sig: string +} + +export function decodeDelegationRecord(value: unknown): WrcDelegationRecord | null { + const o = asRecord(value) + if (!o) return null + if (o.type !== 'wrc/catalog-delegation') return null + if (!isNonEmptyString(o.publisher_part)) return null + if (!isNonEmptyString(o.delegate_kid) || !isB64Url(o.delegate_pub)) return null + if (o.authority !== 'catalog-signing-only') return null + if (!isSafeNonNegativeInt(o.valid_from_epoch)) return null + const revoked = + o.revoked_from_epoch === null || o.revoked_from_epoch === undefined + ? null + : isSafeNonNegativeInt(o.revoked_from_epoch) + ? o.revoked_from_epoch + : undefined + if (revoked === undefined) return null + if (!isNonEmptyString(o.root_kid) || !isB64Url(o.sig)) return null + return { + type: 'wrc/catalog-delegation', + publisher_part: o.publisher_part, + delegate_kid: o.delegate_kid, + delegate_pub: o.delegate_pub, + authority: 'catalog-signing-only', + valid_from_epoch: o.valid_from_epoch, + revoked_from_epoch: revoked, + root_kid: o.root_kid, + sig: o.sig, + } +} + +// ── §4.2 resolve response (the CLAIM) ───────────────────────────────────────── + +/** D4 publisher-part status enum, authoritative at the registry (§4.2). */ +export type WrcPublisherStatus = + | 'active' + | 'inactive' + | 'revoked' + | 'superseded' + | 'compromised' + +export interface WrcResolveClaim { + domain: string + status: WrcPublisherStatus + generation: number + catalog_head: WrcCatalogHead + root_fingerprint: string +} + +const PUBLISHER_STATUSES: readonly string[] = [ + 'active', + 'inactive', + 'revoked', + 'superseded', + 'compromised', +] + +export function decodeResolveClaim(value: unknown): WrcResolveClaim | null { + const o = asRecord(value) + if (!o) return null + if (!isNonEmptyString(o.domain)) return null + if (typeof o.status !== 'string' || !PUBLISHER_STATUSES.includes(o.status)) return null + if (!isSafeNonNegativeInt(o.generation)) return null + const head = decodeCatalogHead(o.catalog_head) + if (!head) return null + if (!isNonEmptyString(o.root_fingerprint)) return null + return { + domain: o.domain.toLowerCase(), + status: o.status as WrcPublisherStatus, + generation: o.generation, + catalog_head: head, + root_fingerprint: o.root_fingerprint, + } +} + +// ── Publisher manifest (`/.well-known/wr/manifest`) ─────────────────────────── + +/** + * The publisher-served side of the dual channel. Its `publisher_part` is a + * CROSS-CHECK against the resolved part (§1.1) — never a resolution source. + */ +export interface WrcPublisherManifest { + type: 'wr/manifest' + domain: string + publisher_part: string + root_kid: string + root_pub: string + sig: string +} + +export function decodePublisherManifest(value: unknown): WrcPublisherManifest | null { + const o = asRecord(value) + if (!o) return null + if (o.type !== 'wr/manifest') return null + if (!isNonEmptyString(o.domain) || !isNonEmptyString(o.publisher_part)) return null + if (!isNonEmptyString(o.root_kid) || !isB64Url(o.root_pub) || !isB64Url(o.sig)) return null + return { + type: 'wr/manifest', + domain: o.domain.toLowerCase(), + publisher_part: o.publisher_part, + root_kid: o.root_kid, + root_pub: o.root_pub, + sig: o.sig, + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/wrcCrypto.ts b/code/apps/electron-vite-project/electron/main/wrc/wrcCrypto.ts new file mode 100644 index 000000000..91f8fd55d --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/wrcCrypto.ts @@ -0,0 +1,158 @@ +/** + * WRC cryptographic conventions (contract §2), client side only. + * + * Canonical JSON is `canonicalJsonString` from `@repo/ingestion-core`: + * recursively sorted keys, no insignificant whitespace, integers only. The + * contract states its canonical form is byte-identical to the wr-connect + * `wrc_canonical_json`, and that module is the repo's implementation of the + * same rules, so it is reused rather than re-derived. + * + * One deliberate difference from the WR Handshake idiom: WRC signatures are + * over the canonical object minus its `sig` field with **no** domain-separation + * prefix (§2). `signingBytes()` prepends the `WRH1|type|vN|` tag and must NOT + * be used here — a tagged input would never verify against a publisher + * signature, and worse, silently reusing the tag would let a WRC object and a + * handshake object share a preimage space they must not share. + * + * Verification only: this module has no signing function, because the client + * is never a publisher and never the WRC ingest. + */ + +import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto' +import { canonicalJsonString, type CanonicalJsonValue } from '@repo/ingestion-core' +import type { WrcHash, WrcInclusionStep } from './wrcContract' + +// ── Hashing ─────────────────────────────────────────────────────────────────── + +function b64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fromB64Url(s: string): Buffer { + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64') +} + +/** `sha256:` over raw bytes (§2). */ +export function wrcHashBytes(bytes: Buffer | Uint8Array): WrcHash { + return `sha256:${b64url(createHash('sha256').update(Buffer.from(bytes)).digest())}` +} + +/** Canonical bytes of a JSON-shaped value. Throws only on non-canonicalizable input. */ +export function wrcCanonicalBytes(value: unknown): Buffer { + return Buffer.from(canonicalJsonString(value as CanonicalJsonValue), 'utf8') +} + +/** `sha256:` over the canonical JSON of an object (§2). */ +export function wrcHashObject(value: unknown): WrcHash { + return wrcHashBytes(wrcCanonicalBytes(value)) +} + +/** Raw 32-byte digest behind a `sha256:` hash string, or null when malformed. */ +export function wrcHashToBytes(hash: WrcHash): Buffer | null { + if (!hash.startsWith('sha256:')) return null + const raw = fromB64Url(hash.slice('sha256:'.length)) + return raw.length === 32 ? raw : null +} + +// ── Ed25519 ─────────────────────────────────────────────────────────────────── + +/** RFC 8410 SPKI DER prefix for a raw 32-byte Ed25519 public key. */ +const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +function publicKeyFromRaw(rawB64Url: string) { + const raw = fromB64Url(rawB64Url) + if (raw.length !== 32) return null + try { + return createPublicKey({ + key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), + format: 'der', + type: 'spki', + }) + } catch { + return null + } +} + +/** + * Verify a detached Ed25519 signature over `message`. + * Returns false for any malformed input rather than throwing — a malformed key + * or signature is a verification failure, not an exceptional condition. + */ +export function wrcVerifyEd25519( + message: Buffer | Uint8Array, + signatureB64Url: string, + publicKeyRawB64Url: string, +): boolean { + const key = publicKeyFromRaw(publicKeyRawB64Url) + if (!key) return false + const sig = fromB64Url(signatureB64Url) + if (sig.length !== 64) return false + try { + return cryptoVerify(null, Buffer.from(message), key, sig) + } catch { + return false + } +} + +/** + * Verify an object's own `sig` field: canonical JSON of the object MINUS `sig`, + * no domain tag (§2). The `sig` property is removed rather than blanked, since + * canonicalization treats an absent property and an empty one differently. + */ +export function wrcVerifyObjectSignature( + object: Record, + publicKeyRawB64Url: string, +): boolean { + const sig = object.sig + if (typeof sig !== 'string' || sig.length === 0) return false + const { sig: _omitted, ...unsigned } = object + let bytes: Buffer + try { + bytes = wrcCanonicalBytes(unsigned) + } catch { + return false + } + return wrcVerifyEd25519(bytes, sig, publicKeyRawB64Url) +} + +/** + * Ingest countersignature: signs `hash || epoch` (§3.4). The concatenation is + * the ASCII hash string followed by the decimal epoch, which is what the + * contract's wording denotes and what the fixture generator produces; there is + * no separate canonical object for it. + */ +export function wrcCountersignatureMessage(hash: WrcHash, epoch: number): Buffer { + return Buffer.from(`${hash}${String(epoch)}`, 'utf8') +} + +// ── Merkle inclusion (§2) ───────────────────────────────────────────────────── + +/** + * Fold an inclusion proof from a leaf hash up to a root. + * parent = sha256(left || right) over the RAW 32-byte digests, with `pos` + * naming the side the SIBLING sits on. + */ +export function wrcFoldInclusionProof( + leaf: WrcHash, + proof: readonly WrcInclusionStep[], +): WrcHash | null { + const leafBytes = wrcHashToBytes(leaf) + if (!leafBytes) return null + let acc: Buffer = leafBytes + for (const step of proof) { + const sib = wrcHashToBytes(step.hash) + if (!sib) return null + const pair: Buffer = + step.pos === 'left' ? Buffer.concat([sib, acc]) : Buffer.concat([acc, sib]) + acc = createHash('sha256').update(pair).digest() + } + return `sha256:${b64url(acc)}` +} + +/** Constant-time-ish string compare for hash equality. */ +export function wrcHashEquals(a: string, b: string): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/wrcRuntime.ts b/code/apps/electron-vite-project/electron/main/wrc/wrcRuntime.ts new file mode 100644 index 000000000..56a087f98 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/wrcRuntime.ts @@ -0,0 +1,152 @@ +/** + * Process-wide WRC client wiring. + * + * Deployment reality in Phase 3: there is no WRC service yet. An unconfigured + * deployment therefore gets {@link createUnconfiguredWrcTransport}, which + * refuses every call with `not_configured`. That is the fail-closed default on + * purpose — an unconfigured registry must be visibly unavailable, never + * indistinguishable from a registry that answered "no such publisher". + * + * Configuration is read once and can be replaced by tests. No substitute trust + * path exists: without a configured registry there is no resolution, and + * nothing downstream may present a code as validated. + */ + +import { app } from 'electron' +import { + WrcResolutionClient, + type WrcResolutionResult, + type ResolvePublisherOptions, +} from './resolutionClient' +import { + WrcResolvedRecordStore, + createFilePersistence, + defaultResolvedRecordPath, +} from './resolvedRecordStore' +import { + createDbEpochFloorStore, + createMemoryEpochFloorStore, + epochFloorTablePresent, + type EpochFloorDb, + type WrcEpochFloorStore, +} from './epochFloorStore' +import { + createUnconfiguredWrcTransport, + createWrcHttpTransport, + type WrcTransport, +} from './wrcTransport' + +export interface WrcRuntimeConfig { + /** Registry origin, e.g. `https://wrc.example.com`. Absent ⇒ unconfigured. */ + registryBaseUrl?: string | null + /** Raw base64url Ed25519 public key of the WRC ingest countersigner. */ + ingestPublicKey?: string | null +} + +let _client: WrcResolutionClient | null = null +let _configured = false + +function readConfigFromEnvironment(): WrcRuntimeConfig { + // Env only for now: the settings surface for the registry endpoint arrives + // with the Phase-4 offer work. Contract-first means no half-built UI. + return { + registryBaseUrl: process.env.WRDESK_WRC_REGISTRY_URL ?? null, + ingestPublicKey: process.env.WRDESK_WRC_INGEST_PUBKEY ?? null, + } +} + +function userDataDir(): string { + try { + return app.getPath('userData') + } catch { + return process.cwd() + } +} + +/** + * The anti-rollback floor lives in the native DB, never in the cache file. + * + * When the DB is unavailable we fall back to an in-process floor. That is + * strictly SAFER than the state this replaces: it starts empty for this + * process, but it cannot be lowered and it is never written anywhere a file + * deletion could reset. It is not a substitute for the real store — resolution + * simply has no accepted history to compare against until the DB is up. + */ +async function resolveEpochFloorStore(): Promise { + try { + const { getHandshakeDbForInternalInference } = await import('../internalInference/dbAccess') + const db = (await getHandshakeDbForInternalInference()) as EpochFloorDb | null + if (db && epochFloorTablePresent(db)) return createDbEpochFloorStore(db) + if (db) { + console.warn( + '[WRC] wrc_publisher_epoch_floor missing — using an in-process floor. ' + + 'Accepted-epoch history is unavailable until migrations run.', + ) + } + } catch (e) { + console.warn('[WRC] epoch floor store unavailable:', e instanceof Error ? e.message : e) + } + return createMemoryEpochFloorStore() +} + +/** Build (or rebuild) the process client. Tests call {@link setWrcClientForTests}. */ +export async function initWrcClient(config?: WrcRuntimeConfig): Promise { + const cfg = config ?? readConfigFromEnvironment() + const transport: WrcTransport = + cfg.registryBaseUrl && cfg.ingestPublicKey + ? createWrcHttpTransport({ registryBaseUrl: cfg.registryBaseUrl }) + : createUnconfiguredWrcTransport() + _configured = Boolean(cfg.registryBaseUrl && cfg.ingestPublicKey) + _client = new WrcResolutionClient({ + transport, + store: new WrcResolvedRecordStore( + createFilePersistence(defaultResolvedRecordPath(userDataDir())), + await resolveEpochFloorStore(), + ), + ingestPublicKey: cfg.ingestPublicKey ?? '', + }) + return _client +} + +export async function getWrcClient(): Promise { + if (!_client) return initWrcClient() + return _client +} + +export async function isWrcConfigured(): Promise { + if (!_client) await initWrcClient() + return _configured +} + +/** Test seam: inject a client built on a contract-faithful double. */ +export function setWrcClientForTests(client: WrcResolutionClient | null, configured = true): void { + _client = client + _configured = client ? configured : false +} + +/** + * Loopback-RPC entry point for the extension (`wrc.resolvePublisher`). + * Returns the client's typed result unchanged: the renderer must see the same + * distinct reason the client produced, not a flattened boolean. + */ +export async function handleWrcResolvePublisher(params: { + publisherPart?: unknown + entryId?: unknown + allowSuspended?: unknown +}): Promise<{ success: true; result: WrcResolutionResult } | { success: false; error: string }> { + const part = typeof params?.publisherPart === 'string' ? params.publisherPart.trim() : '' + if (!part) return { success: false, error: 'publisherPart is required' } + + const options: ResolvePublisherOptions = {} + if (typeof params?.entryId === 'string' && params.entryId.trim()) { + options.entryId = params.entryId.trim() + } + if (params?.allowSuspended === true) options.allowSuspended = true + + try { + const client = await getWrcClient() + return { success: true, result: await client.resolvePublisher(part, options) } + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) } + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/wrcTransport.ts b/code/apps/electron-vite-project/electron/main/wrc/wrcTransport.ts new file mode 100644 index 000000000..69e771b78 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/wrcTransport.ts @@ -0,0 +1,130 @@ +/** + * Isolated transport interface for the WRC resolution client. + * + * The WRC service is a later, separate deliverable. Per the delta v1.1 WRC + * deferral, Phase 3 is built contract-first: everything above this interface is + * real client logic, everything below it is swappable. Tests inject a + * contract-faithful double; production injects {@link createWrcHttpTransport}. + * When the live service arrives, only the factory changes — no verification, + * no policy, and no call site moves. + * + * The DNS channel lives here too, deliberately. It is a transport concern and + * it is the one channel a test cannot stub by intercepting HTTP, so leaving it + * outside the interface would make the dual-channel logic untestable and + * quietly tempt someone to skip it in tests — which is exactly the leg that + * must never be skipped. + */ + +import { resolveTxt } from 'node:dns/promises' +import { wrcHttpsGet, type WrcHttpErrorCode } from './httpsClient' + +export type WrcTransportErrorCode = WrcHttpErrorCode | 'dns_error' | 'not_configured' + +export type WrcTransportResult = + | { ok: true; value: unknown } + | { ok: false; code: WrcTransportErrorCode; message: string; status?: number } + +export type WrcTxtResult = + | { ok: true; records: string[] } + | { ok: false; code: WrcTransportErrorCode; message: string } + +export interface WrcTransport { + /** `GET /v1/resolve/{part}` — the registry CLAIM. */ + resolve(publisherPart: string): Promise + /** `GET /v1/publishers/{part}/catalog/head`. */ + catalogHead(publisherPart: string): Promise + /** + * `GET /v1/publishers/{part}/delegations` (delta v1.1 §B) — the append-only + * rotation history. AUDIT ONLY. It must never be called from a verification + * path; head verification uses the record embedded in the head. + */ + delegations(publisherPart: string): Promise + /** `GET /v1/publishers/{part}/entries/{entry_id}` → DualAssuranceEnvelope. */ + entry(publisherPart: string, entryId: string): Promise + /** `GET /v1/objects/{sha256}` → DualAssuranceEnvelope for any published object. */ + object(hash: string): Promise + /** Publisher-served `https:///.well-known/wr/manifest`. */ + publisherManifest(domain: string): Promise + /** DNS TXT for `_wr.`. */ + wrTxtRecords(domain: string): Promise +} + +/** Per-object byte caps. The EVP budget is enforced again after decode (§3.3). */ +const OBJECT_MAX_BYTES = 128 * 1024 +const HEAD_MAX_BYTES = 16 * 1024 +const MANIFEST_MAX_BYTES = 32 * 1024 + +function toResult(r: Awaited>): WrcTransportResult { + if (!r.ok) return { ok: false, code: r.code, message: r.message, status: r.status } + return { ok: true, value: r.json } +} + +export interface WrcHttpTransportConfig { + /** Registry base origin, e.g. `https://wrc.example.com`. */ + registryBaseUrl: string + timeoutMs?: number +} + +/** + * Production transport. Every call goes through the hardened client; there is + * no second HTTP path, which is what makes "used by 3B exclusively" checkable. + */ +export function createWrcHttpTransport(config: WrcHttpTransportConfig): WrcTransport { + const base = config.registryBaseUrl.replace(/\/+$/, '') + const t = config.timeoutMs + + const get = async (url: string, maxBytes: number): Promise => + toResult(await wrcHttpsGet(url, { maxBytes, timeoutMs: t, expectJson: true })) + + return { + resolve: (part) => get(`${base}/v1/resolve/${encodeURIComponent(part)}`, HEAD_MAX_BYTES), + catalogHead: (part) => + get(`${base}/v1/publishers/${encodeURIComponent(part)}/catalog/head`, HEAD_MAX_BYTES), + delegations: (part) => + get(`${base}/v1/publishers/${encodeURIComponent(part)}/delegations`, OBJECT_MAX_BYTES), + entry: (part, entryId) => + get( + `${base}/v1/publishers/${encodeURIComponent(part)}/entries/${encodeURIComponent(entryId)}`, + OBJECT_MAX_BYTES, + ), + object: (hash) => get(`${base}/v1/objects/${encodeURIComponent(hash)}`, OBJECT_MAX_BYTES), + publisherManifest: (domain) => + get(`https://${domain}/.well-known/wr/manifest`, MANIFEST_MAX_BYTES), + async wrTxtRecords(domain) { + try { + const records = await resolveTxt(`_wr.${domain}`) + return { ok: true, records: records.map((parts) => parts.join('')) } + } catch (e) { + return { ok: false, code: 'dns_error', message: e instanceof Error ? e.message : String(e) } + } + }, + } +} + +/** + * Transport that refuses everything. This is the default until an operator + * configures a registry: an unconfigured deployment must fail closed and say + * so, not silently behave as if nothing resolves. + */ +export function createUnconfiguredWrcTransport(): WrcTransport { + const refuse = async (): Promise => ({ + ok: false, + code: 'not_configured', + message: 'No WRC registry is configured for this deployment', + }) + return { + resolve: refuse, + catalogHead: refuse, + delegations: refuse, + entry: refuse, + object: refuse, + publisherManifest: refuse, + async wrTxtRecords() { + return { + ok: false, + code: 'not_configured', + message: 'No WRC registry is configured for this deployment', + } + }, + } +} diff --git a/code/apps/electron-vite-project/electron/main/wrc/wrcVerify.ts b/code/apps/electron-vite-project/electron/main/wrc/wrcVerify.ts new file mode 100644 index 000000000..9bb335a36 --- /dev/null +++ b/code/apps/electron-vite-project/electron/main/wrc/wrcVerify.ts @@ -0,0 +1,302 @@ +/** + * WRC verification — 3D (CatalogHead), 3E (DualAssuranceEnvelope), 3F (EVP). + * + * Every function here is pure over its inputs and returns a typed verdict. + * There is no "warn and continue" anywhere: a missing leg means the object does + * not exist for the runtime (contract §5.3, delta 3E), and the reason is typed + * so the Phase-4 status surface can render it without re-deriving anything. + * + * Reason codes are the vocabulary the rest of the client and the report speak. + * They are deliberately fine-grained: "it failed" is not an acceptable answer + * when the four channels (registry, DNS, manifest, declared part) can diverge + * in ways that mean very different things. + */ + +import { + WRC_EVP_MAX_CANONICAL_BYTES, + decodeEvp, + type WrcCatalogHead, + type WrcDelegationRecord, + type WrcEnvelope, + type WrcEvp, +} from './wrcContract' +import { + wrcCanonicalBytes, + wrcCountersignatureMessage, + wrcFoldInclusionProof, + wrcHashEquals, + wrcHashObject, + wrcVerifyEd25519, + wrcVerifyObjectSignature, +} from './wrcCrypto' + +export type WrcVerifyReason = + // Catalog head (3D, amended by contract delta v1.1 §A) + | 'head_signature_invalid' + | 'head_unknown_kid' + | 'head_delegation_invalid' + | 'head_delegation_revoked' + | 'head_delegation_not_yet_valid' + /** Delegated `kid` with no embedded delegation record. No fallback fetch. */ + | 'head_delegation_missing' + /** Embedded record delegates a key other than the head's `kid`. */ + | 'head_delegation_kid_mismatch' + /** `root_kid` is not the DNS-pinned root — a sub-delegation attempt. */ + | 'head_delegation_not_rooted' + | 'head_epoch_rollback' + | 'head_part_mismatch' + | 'head_domain_mismatch' + // Envelope (3E) + | 'envelope_object_hash_mismatch' + | 'envelope_epoch_mismatch' + | 'envelope_publisher_signature_invalid' + | 'envelope_countersignature_invalid' + | 'envelope_inclusion_proof_invalid' + | 'envelope_suspended' + // EVP (3F) + | 'evp_over_budget' + | 'evp_malformed' + | 'evp_part_mismatch' + | 'evp_entry_mismatch' + +export type WrcVerdict = { ok: true; value: T } | { ok: false; reason: WrcVerifyReason; detail?: string } + +/** Freshness is a separate axis from validity: a stale head is still authentic. */ +export type WrcFreshness = 'fresh' | 'stale' + +// ── Key material the client holds about a publisher ─────────────────────────── + +export interface WrcPublisherKeys { + /** Raw base64url Ed25519 root public key, anchored via DNS `_wr` + manifest. */ + rootKid: string + rootPub: string + /** + * The delegation carried BY THE HEAD (delta v1.1 §A), or null for a + * root-signed head. There is deliberately no list and no store lookup here: + * the contract requires head verification to complete from the DNS-pinned + * root plus this record alone, and a collection-shaped field would be an + * invitation to satisfy a delegated head from somewhere else. + */ + headDelegation: WrcDelegationRecord | null +} + +/** + * Resolve the signing key for a `kid` at a given epoch: the root key, or the + * head-embedded delegation when it is in force at that epoch. + * + * Every rejection is its own reason. An expired rotation, a record for a + * different key, and an attempted sub-delegation are three different events, + * and a status surface that collapses them into "bad signature" cannot tell an + * operator what actually happened. + * + * Sub-delegation is unrepresentable rather than merely refused: `authority` is + * `catalog-signing-only`, so a record whose `root_kid` is anything other than + * the DNS-pinned root is rejected before its signature is even considered. + */ +export function resolveSigningKey( + keys: WrcPublisherKeys, + kid: string, + epoch: number, +): { ok: true; pub: string } | { ok: false; reason: WrcVerifyReason } { + if (kid === keys.rootKid) return { ok: true, pub: keys.rootPub } + + const d = keys.headDelegation + // Delegated kid with nothing embedded: verification failure, no fallback fetch. + if (!d) return { ok: false, reason: 'head_delegation_missing' } + if (d.delegate_kid !== kid) return { ok: false, reason: 'head_delegation_kid_mismatch' } + if (d.authority !== 'catalog-signing-only') { + return { ok: false, reason: 'head_delegation_invalid' } + } + if (d.root_kid !== keys.rootKid) return { ok: false, reason: 'head_delegation_not_rooted' } + if (!wrcVerifyObjectSignature(d as unknown as Record, keys.rootPub)) { + return { ok: false, reason: 'head_delegation_invalid' } + } + // v1.1 §A.3: valid_from_epoch <= epoch AND (revoked null OR revoked > epoch). + if (epoch < d.valid_from_epoch) return { ok: false, reason: 'head_delegation_not_yet_valid' } + if (d.revoked_from_epoch !== null && d.revoked_from_epoch <= epoch) { + return { ok: false, reason: 'head_delegation_revoked' } + } + return { ok: true, pub: d.delegate_pub } +} + +// ── 3D — CatalogHead ────────────────────────────────────────────────────────── + +export interface WrcHeadVerification { + head: WrcCatalogHead + freshness: WrcFreshness + /** Seconds past the freshness window; 0 when fresh. */ + stale_by_s: number +} + +export interface VerifyCatalogHeadInput { + head: WrcCatalogHead + keys: WrcPublisherKeys + expectedPublisherPart: string + /** Domain established by the dual channel, not by the registry answer. */ + expectedDomain: string + /** Highest epoch ever accepted for this publisher; null when first seen. */ + lastSeenEpoch: number | null + /** Unix seconds. Injected so freshness is testable. */ + nowS: number +} + +/** + * Verify a CatalogHead: signature by root or a live delegation, binding to the + * expected publisher and domain, strict per-publisher epoch monotonicity, and + * the freshness window. + * + * Anti-rollback is `epoch < lastSeenEpoch` ⇒ reject. Equality is allowed: a + * re-fetch of the same epoch is normal and is not a rollback. + */ +export function verifyCatalogHead(input: VerifyCatalogHeadInput): WrcVerdict { + const { head, keys, expectedPublisherPart, expectedDomain, lastSeenEpoch, nowS } = input + + if (head.publisher_part !== expectedPublisherPart) { + return { ok: false, reason: 'head_part_mismatch', detail: head.publisher_part } + } + if (head.domain !== expectedDomain.toLowerCase()) { + return { ok: false, reason: 'head_domain_mismatch', detail: head.domain } + } + + const key = resolveSigningKey(keys, head.kid, head.epoch) + if (!key.ok) return { ok: false, reason: key.reason, detail: head.kid } + + if (!wrcVerifyObjectSignature(head as unknown as Record, key.pub)) { + return { ok: false, reason: 'head_signature_invalid' } + } + + if (lastSeenEpoch !== null && head.epoch < lastSeenEpoch) { + return { + ok: false, + reason: 'head_epoch_rollback', + detail: `saw epoch ${head.epoch}, already accepted ${lastSeenEpoch}`, + } + } + + const expiresAt = head.issued_at + head.freshness_window_s + const staleBy = nowS > expiresAt ? nowS - expiresAt : 0 + return { + ok: true, + value: { head, freshness: staleBy > 0 ? 'stale' : 'fresh', stale_by_s: staleBy }, + } +} + +// ── 3E — DualAssuranceEnvelope ──────────────────────────────────────────────── + +export interface WrcEnvelopeVerification { + envelope: WrcEnvelope + /** True when a suspension record is present (A5). */ + suspended: boolean +} + +export interface VerifyEnvelopeInput { + envelope: WrcEnvelope + keys: WrcPublisherKeys + /** The head this envelope must prove inclusion against. */ + verifiedHead: WrcCatalogHead + /** Raw base64url Ed25519 public key of the WRC ingest countersigner. */ + ingestPub: string + /** + * When false, a suspended object still verifies and is returned with + * `suspended: true` — the audit view needs it (§3.4). Admission paths leave + * this at its default so suspension is a typed refusal. + */ + allowSuspended?: boolean +} + +/** + * Verify all four legs: object hash binding, publisher signature, ingest + * countersignature, and Merkle inclusion against the already-verified head. + * Any missing leg ⇒ the object does not exist for the runtime. + */ +export function verifyEnvelope(input: VerifyEnvelopeInput): WrcVerdict { + const { envelope, keys, verifiedHead, ingestPub, allowSuspended = false } = input + + // 1. The envelope's hash must actually be the hash of the object it carries. + let computed: string + try { + computed = wrcHashObject(envelope.object) + } catch { + return { ok: false, reason: 'envelope_object_hash_mismatch', detail: 'not canonicalizable' } + } + if (!wrcHashEquals(computed, envelope.hash)) { + return { ok: false, reason: 'envelope_object_hash_mismatch', detail: computed } + } + + // 2. The envelope must belong to the epoch the verified head describes. + if (envelope.epoch !== verifiedHead.epoch) { + return { + ok: false, + reason: 'envelope_epoch_mismatch', + detail: `envelope ${envelope.epoch} vs head ${verifiedHead.epoch}`, + } + } + + // 3. Publisher authorization. + const key = resolveSigningKey(keys, envelope.publisher_sig_valid_kid, envelope.epoch) + if (!key.ok) return { ok: false, reason: 'envelope_publisher_signature_invalid', detail: key.reason } + if (!wrcVerifyObjectSignature(envelope.object, key.pub)) { + return { ok: false, reason: 'envelope_publisher_signature_invalid' } + } + + // 4. WRC ingest hygiene countersignature over `hash || epoch`. + const csMessage = wrcCountersignatureMessage(envelope.hash, envelope.epoch) + if (!wrcVerifyEd25519(csMessage, envelope.ingest_countersig.sig, ingestPub)) { + return { ok: false, reason: 'envelope_countersignature_invalid' } + } + + // 5. Inclusion in the verified catalog root. + const folded = wrcFoldInclusionProof(envelope.hash, envelope.inclusion_proof) + if (!folded || !wrcHashEquals(folded, verifiedHead.catalog_root)) { + return { ok: false, reason: 'envelope_inclusion_proof_invalid', detail: folded ?? 'unfoldable' } + } + + // 6. Suspension (A5): visible, typed, never a silent absence. + if (envelope.suspension && !allowSuspended) { + return { ok: false, reason: 'envelope_suspended', detail: envelope.suspension.reason_code } + } + + return { ok: true, value: { envelope, suspended: envelope.suspension !== null } } +} + +// ── 3F — EVP ────────────────────────────────────────────────────────────────── + +export interface VerifyEvpInput { + /** The object carried by an already-verified envelope. */ + object: Record + expectedPublisherPart: string + expectedEntryId: string +} + +/** + * Decode and budget-check an EVP. + * + * The 64 KiB budget is a VERIFICATION failure, never a truncation (§3.3): a + * client that trimmed an over-budget EVP would render a value statement the + * publisher never signed in that form. + */ +export function verifyEvp(input: VerifyEvpInput): WrcVerdict { + let canonicalBytes: number + try { + canonicalBytes = wrcCanonicalBytes(input.object).length + } catch { + return { ok: false, reason: 'evp_malformed', detail: 'not canonicalizable' } + } + if (canonicalBytes > WRC_EVP_MAX_CANONICAL_BYTES) { + return { + ok: false, + reason: 'evp_over_budget', + detail: `${canonicalBytes} > ${WRC_EVP_MAX_CANONICAL_BYTES}`, + } + } + + const evp = decodeEvp(input.object) + if (!evp) return { ok: false, reason: 'evp_malformed' } + if (evp.publisher_part !== input.expectedPublisherPart) { + return { ok: false, reason: 'evp_part_mismatch', detail: evp.publisher_part } + } + if (evp.entry_id !== input.expectedEntryId) { + return { ok: false, reason: 'evp_entry_mismatch', detail: evp.entry_id } + } + return { ok: true, value: evp } +} diff --git a/code/apps/electron-vite-project/electron/preload.ts b/code/apps/electron-vite-project/electron/preload.ts index 9fa2c4d1e..b0c5b79bb 100644 --- a/code/apps/electron-vite-project/electron/preload.ts +++ b/code/apps/electron-vite-project/electron/preload.ts @@ -833,7 +833,7 @@ contextBridge.exposeInMainWorld('handshakeView', { return ipcRenderer.invoke('handshake:importCapsule', jsonString) }, acceptHandshake: (id: unknown, sharingMode: unknown, fromAccountId: unknown, contextOpts?: unknown) => { - // Allowlisted fields only; X25519 / internal vs normal is enforced in main (persisted `record.handshake_type`). + // Allowlisted fields only; X25519 / internal vs normal is enforced in main (persisted `record.same_principal`). const safeOpts = buildHandshakeAcceptSafeOpts(contextOpts) return ipcRenderer.invoke('handshake:accept', assertString(id, 'id'), assertString(sharingMode, 'sharingMode'), typeof fromAccountId === 'string' ? fromAccountId : '', safeOpts) }, @@ -1083,7 +1083,7 @@ contextBridge.exposeInMainWorld('handshakeView', { ...(Array.isArray(opts.profile_ids) ? { profile_ids: opts.profile_ids } : {}), ...(Array.isArray(opts.profile_items) ? { profile_items: opts.profile_items } : {}), ...(opts.policy_selections && typeof opts.policy_selections === 'object' ? { policy_selections: opts.policy_selections } : {}), - ...(opts.handshake_type === 'internal' || opts.handshake_type === 'standard' ? { handshake_type: opts.handshake_type } : {}), + ...(typeof opts.profile_id === 'string' && opts.profile_id.trim() ? { profile_id: opts.profile_id.trim() } : {}), ...(typeof opts.device_name === 'string' && opts.device_name.trim() ? { device_name: opts.device_name.trim() } : {}), ...(opts.device_role === 'host' || opts.device_role === 'sandbox' ? { device_role: opts.device_role } : {}), ...(typeof opts.counterparty_device_id === 'string' && opts.counterparty_device_id.trim() @@ -1115,7 +1115,7 @@ contextBridge.exposeInMainWorld('handshakeView', { ...(Array.isArray(opts.profile_ids) ? { profile_ids: opts.profile_ids } : {}), ...(Array.isArray(opts.profile_items) ? { profile_items: opts.profile_items } : {}), ...(opts.policy_selections && typeof opts.policy_selections === 'object' ? { policy_selections: opts.policy_selections } : {}), - ...(opts.handshake_type === 'internal' || opts.handshake_type === 'standard' ? { handshake_type: opts.handshake_type } : {}), + ...(typeof opts.profile_id === 'string' && opts.profile_id.trim() ? { profile_id: opts.profile_id.trim() } : {}), ...(typeof opts.device_name === 'string' && opts.device_name.trim() ? { device_name: opts.device_name.trim() } : {}), ...(opts.device_role === 'host' || opts.device_role === 'sandbox' ? { device_role: opts.device_role } : {}), ...(typeof opts.counterparty_device_id === 'string' && opts.counterparty_device_id.trim() @@ -1269,7 +1269,7 @@ contextBridge.exposeInMainWorld('emailAccounts', { accountId: string, creds: { imapPassword: string; smtpPassword?: string; smtpUseSameCredentials?: boolean }, ) => ipcRenderer.invoke('email:updateImapCredentials', accountId, creds), - sendEmail: (accountId: string, payload: { to: string[]; subject: string; bodyText: string; attachments?: { filename: string; mimeType: string; contentBase64: string }[] }) => + sendEmail: (accountId: string, payload: { to: string[]; subject: string; bodyText: string; attachments?: { filename: string; mimeType: string; contentBase64: string }[]; provenance?: Record }) => ipcRenderer.invoke('email:sendEmail', accountId, payload), deleteAccount: (accountId: string) => ipcRenderer.invoke('email:deleteAccount', accountId), connectGmail: (displayName?: string, syncWindowDays?: number, gmailOAuthCredentialSource?: 'builtin_public' | 'developer_saved') => @@ -1453,8 +1453,8 @@ contextBridge.exposeInMainWorld('emailInbox', { ipcRenderer.on('inbox:aiAnalyzeMessageChunk', handler) return () => ipcRenderer.removeListener('inbox:aiAnalyzeMessageChunk', handler) }, - onAiAnalyzeDone: (cb: (data: { messageId: string }) => void) => { - const handler = (_e: Electron.IpcRendererEvent, data: { messageId: string }) => cb(data) + onAiAnalyzeDone: (cb: (data: { messageId: string; provenance?: unknown }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { messageId: string; provenance?: unknown }) => cb(data) ipcRenderer.on('inbox:aiAnalyzeMessageDone', handler) return () => ipcRenderer.removeListener('inbox:aiAnalyzeMessageDone', handler) }, @@ -2024,6 +2024,12 @@ contextBridge.exposeInMainWorld('libreoffice', { >, }) +// ── Art. 50 editorial responsibility IPC ───────────────────────────────────── +contextBridge.exposeInMainWorld('art50', { + logEditorialResponsibility: (p: unknown) => + ipcRenderer.invoke('art50:logEditorialResponsibility', p) as Promise, +}) + // === TEMPORARY DEBUG LOG BRIDGE (remove before production) === contextBridge.exposeInMainWorld('debugLogs', { onLog: (callback: (entry: { ts: string; level: string; line: string }) => void) => { diff --git a/code/apps/electron-vite-project/electron/watchdog/watchdogService.ts b/code/apps/electron-vite-project/electron/watchdog/watchdogService.ts index c36efbedc..ced9787d1 100644 --- a/code/apps/electron-vite-project/electron/watchdog/watchdogService.ts +++ b/code/apps/electron-vite-project/electron/watchdog/watchdogService.ts @@ -14,6 +14,7 @@ import path from 'node:path' import { captureScreenshot } from '../lmgtfy/capture' import type { Selection } from '../lmgtfy/overlay' import type { ChatMessage } from '../main/llm/types' +import type { AiProvenance } from '../../../../packages/shared/src/aiProvenance' import { WATCHDOG_SYSTEM_PROMPT, extractScamWatchdogScanPromptFromLegacySearchFocus, @@ -640,10 +641,10 @@ export class WatchdogService { } /** - * One-shot workspace summary: same multi-display + DOM capture as {@link runScan}, different system prompt; returns plain text. - * Excludes from running concurrently with {@link runScan} (shared extension DOM handshake). + * One-shot workspace summary: same multi-display + DOM capture as {@link runScan}, different system prompt. + * Returns `{ text, provenance }` — provenance is logged inside `localLlmManager.chat()`. */ - async runSmartSummary(): Promise { + async runSmartSummary(): Promise<{ text: string; provenance: AiProvenance | null }> { if (this.scanInFlight || this.smartSummaryInFlight) { throw new Error('Capture pipeline busy') } @@ -686,9 +687,9 @@ export class WatchdogService { this.deletePaths(capturePaths) if (!responseText) { - return 'No summary available.' + return { text: 'No summary available.', provenance: chatRes?.provenance ?? null } } - return responseText + return { text: responseText, provenance: chatRes?.provenance ?? null } } catch (e) { console.warn('[SmartSummary] failed:', e instanceof Error ? e.message : e) const paths = [...this.capturePathsBuffer] diff --git a/code/apps/electron-vite-project/src/components/AcceptHandshakeModal.tsx b/code/apps/electron-vite-project/src/components/AcceptHandshakeModal.tsx index 697dacdf5..ec6c87d55 100644 --- a/code/apps/electron-vite-project/src/components/AcceptHandshakeModal.tsx +++ b/code/apps/electron-vite-project/src/components/AcceptHandshakeModal.tsx @@ -25,7 +25,7 @@ interface HandshakeRecord { acceptor: { email: string; wrdesk_user_id: string } | null local_role: 'initiator' | 'acceptor' receiver_email?: string | null - handshake_type?: 'internal' | 'standard' | null + same_principal?: boolean | null initiator_device_role?: 'host' | 'sandbox' | null /** 6-digit pairing code from the initiate capsule's `receiver_pairing_code`. New * internal capsules carry this; legacy capsules omit it (acceptance falls back @@ -69,7 +69,7 @@ export default function AcceptHandshakeModal({ const [localPairingCodeHint, setLocalPairingCodeHint] = useState(null) const isInternal = - record.handshake_type === 'internal' || + record.same_principal === true || isSameAccountHandshakeEmails(record.initiator?.email, record.receiver_email) // Pairing-code-routed capsules carry receiver_pairing_code; legacy capsules don't. diff --git a/code/apps/electron-vite-project/src/components/BeapRedirectDialog.tsx b/code/apps/electron-vite-project/src/components/BeapRedirectDialog.tsx index ce531fe9d..84e62f7a4 100644 --- a/code/apps/electron-vite-project/src/components/BeapRedirectDialog.tsx +++ b/code/apps/electron-vite-project/src/components/BeapRedirectDialog.tsx @@ -132,7 +132,7 @@ export default function BeapRedirectDialog({ message, onClose, onSent }: BeapRed if (!hasHandshakeKeyMaterial(handshakeRecordToSelectedRecipient(h))) return false if (!(h.p2pEndpoint && String(h.p2pEndpoint).trim())) return false if (!h.localX25519PublicKey?.trim()) return false - if (h.handshake_type === 'internal' && h.internal_coordination_identity_complete === false) return false + if (h.same_principal === true && h.internal_coordination_identity_complete === false) return false return true }) }, [rows, sourceHs]) @@ -279,7 +279,7 @@ export default function BeapRedirectDialog({ message, onClose, onSent }: BeapRed return ( ) })} diff --git a/code/apps/electron-vite-project/src/components/ChannelProvenanceAlert.surfaces.test.tsx b/code/apps/electron-vite-project/src/components/ChannelProvenanceAlert.surfaces.test.tsx new file mode 100644 index 000000000..b7639b5fe --- /dev/null +++ b/code/apps/electron-vite-project/src/components/ChannelProvenanceAlert.surfaces.test.tsx @@ -0,0 +1,77 @@ +/** + * Electron surface wiring for the shared §IX.3.1 rule-8 alert. + * + * @vitest-environment node + */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import React from 'react' +import LinkWarningDialog from './LinkWarningDialog' +import { + ChannelProvenanceAlert, + channelProvenanceAlertRecordFromUnknown, +} from '@repo/shared-beap-ui' + +const here = dirname(fileURLToPath(import.meta.url)) + +const ALERTING_META = JSON.stringify({ + channel_provenance: { + dkim: { verdict: 'none', aligned: false }, + dmarc: { verdict: 'none', aligned: false }, + }, +}) + +describe('Electron CPR alert surfaces', () => { + it('EmailMessageDetail imports and mounts the shared alert from depackaged_metadata', () => { + const src = readFileSync(join(here, 'EmailMessageDetail.tsx'), 'utf8') + expect(src).toContain("from '@repo/shared-beap-ui'") + expect(src).toContain('channelProvenanceAlertRecordFromUnknown') + expect(src).toContain(' { + const src = readFileSync(join(here, 'LinkWarningDialog.tsx'), 'utf8') + expect(src).toMatch(/channelProvenanceRecord\?:/) + expect(src).toContain(' undefined, + onCancel: () => undefined, + channelProvenanceRecord: record, + }), + ) + expect(html).toContain('This sender could not be verified') + expect(html).toContain('data-surface="electron-link-warning-dialog"') + // Risk checkbox remains for link opening; it must not clear the alert. + expect(html).toContain('link-warning-risk-check') + }) + + it('EmailInboxBulkView forwards pending-link CPR into LinkWarningDialog', () => { + const src = readFileSync(join(here, 'EmailInboxBulkView.tsx'), 'utf8') + expect(src).toContain('channelProvenanceAlertRecordFromUnknown') + expect(src).toContain('pendingLink?.message.depackaged_metadata') + }) + + it('shared alert itself has no dismiss control when mounted alone', () => { + const html = renderToStaticMarkup( + React.createElement(ChannelProvenanceAlert, { + record: channelProvenanceAlertRecordFromUnknown(ALERTING_META), + surface: 'electron-email-message-detail', + }), + ) + expect(html).toContain('role="alert"') + expect(html).not.toMatch(/ ) : null} + {/* Art. 50 Layer B label controls */} + {draftProvenance && shouldApplyMachineMarking(draftProvenance) && ( +
+ + {aiLabelEnabled && ( + + )} +
+ )} @@ -1516,7 +1605,16 @@ function BulkActionCardStructured({ @@ -2337,6 +2435,8 @@ export default function EmailInboxBulkView({ } | null>(null) const [sendEmailToast, setSendEmailToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null) const [draftAttachmentsByMessage, setDraftAttachmentsByMessage] = useState>>({}) + /** Art. 50: provenance tracked per message after AI draft generation. */ + const [bulkAiProvenances, setBulkAiProvenances] = useState>({}) const composeClickRef = useRef(0) useEffect(() => { @@ -4732,7 +4832,16 @@ export default function EmailInboxBulkView({ }, } }) - if (!isError) useEmailInboxStore.getState().addBulkDraftManualCompose(messageId) + if (!isError && data?.draft) { + // Art. 50: use provenance from main process (never mint in renderer). + const prov = isAiProvenance((data as { provenance?: unknown }).provenance) + ? (data as { provenance: AiProvenance }).provenance + : null + if (prov) setBulkAiProvenances((prev) => ({ ...prev, [messageId]: prov })) + useEmailInboxStore.getState().addBulkDraftManualCompose(messageId) + } else if (!isError) { + useEmailInboxStore.getState().addBulkDraftManualCompose(messageId) + } } catch { setBulkAiOutputs((prev) => ({ ...prev, @@ -4753,6 +4862,12 @@ export default function EmailInboxBulkView({ ...prev, [messageId]: { ...prev[messageId], draftReply }, })) + // Art. 50: mark as human-edited when text changes after AI generation. + setBulkAiProvenances((prev) => { + const existing = prev[messageId] + if (!existing) return prev + return { ...prev, [messageId]: markHumanEdited(existing, draftReply) } + }) }, []) const handleFocusPair = useCallback( @@ -4915,7 +5030,7 @@ export default function EmailInboxBulkView({ /** Send draft directly (no modal). */ const handleSendDraft = useCallback( - async (msg: InboxMessage, draftBody: string, attachments?: Array<{ name: string; path: string; size: number }>) => { + async (msg: InboxMessage, draftBody: string, attachments?: Array<{ name: string; path: string; size: number }>, provenance?: AiProvenance | null) => { const replyMode = resolveInboxReplyMode(msg) const shouldSendEmail = replyMode === 'email' @@ -4925,7 +5040,7 @@ export default function EmailInboxBulkView({ phase: 'send_draft', selectedPath: 'native_beap_compose', }) - if (draftBody?.trim()) navigator.clipboard?.writeText(draftBody).catch(() => {}) + if (draftBody?.trim()) void writeAiClipboard(draftBody, draftProvenance) setComposeMode('beap') return } @@ -4986,6 +5101,8 @@ export default function EmailInboxBulkView({ subject: subject.trim() || '(No subject)', bodyText: fullBody, attachments: emailAttachments.length > 0 ? emailAttachments : undefined, + // Art. 50 Layer A: pass provenance for MIME header injection. + ...(shouldApplyMachineMarking(provenance) ? { provenance: provenance! as Record } : {}), }) if (res.ok && res.data?.success) { setSendEmailToast({ type: 'success', message: `Email sent to ${to}` }) @@ -4993,6 +5110,8 @@ export default function EmailInboxBulkView({ const { [msg.id]: _, ...rest } = prev return rest }) + // Clear provenance after successful send. + setBulkAiProvenances((prev) => { const { [msg.id]: _, ...rest } = prev; return rest }) updateDraftReply(msg.id, '') refreshMessages() setTimeout(() => setSendEmailToast(null), 3000) @@ -5003,7 +5122,7 @@ export default function EmailInboxBulkView({ setSendEmailToast({ type: 'error', message: err instanceof Error ? err.message : 'Failed to send' }) } }, - [updateDraftReply, refreshMessages, setComposeMode] + [updateDraftReply, refreshMessages, setComposeMode, setBulkAiProvenances] ) const handleAddDraftAttachment = useCallback(async (msgId: string) => { @@ -5218,6 +5337,7 @@ export default function EmailInboxBulkView({ handleArchiveOne={handleArchiveOne} handleDeleteOne={handleDeleteOne} draftAttachments={draftAttachmentsByMessage[msg.id]} + draftProvenance={bulkAiProvenances[msg.id] ?? null} onAddDraftAttachment={() => handleAddDraftAttachment(msg.id)} onRemoveDraftAttachment={(i) => handleRemoveDraftAttachment(msg.id, i)} handlePendingDeleteOne={handlePendingDeleteOne} @@ -5562,6 +5682,7 @@ export default function EmailInboxBulkView({ return (
+ {/* Toolbar — row 1: status tabs; row 2: Type filter; row 3: selection + AI / sync */}
@@ -7371,6 +7492,9 @@ export default function EmailInboxBulkView({ onSandbox={() => void handleBulkLinkWarningSandbox()} sandboxBusy={linkDialogSandboxBusy} showSandboxOrchestratorWarning={linkDialogShowSandboxOrchestratorWarning} + channelProvenanceRecord={channelProvenanceAlertRecordFromUnknown( + pendingLink?.message.depackaged_metadata, + )} /> {bulkLinkKeyingNotice ? (
void | Promise + onSendDraft?: (draft: string, message: InboxMessage, attachments?: DraftAttachment[], provenance?: AiProvenance | null) => void | Promise onArchive?: (messageIds: string[]) => void onDelete?: (messageIds: string[]) => void onCollapsedChange?: (collapsed: boolean) => void @@ -359,6 +371,14 @@ export function InboxDetailAiPanel({ messageId, message, onSendDraft, onArchive, const [draftErrorDebug, setDraftErrorDebug] = useState(null) const [editedDraft, setEditedDraft] = useState('') const [attachments, setAttachments] = useState([]) + /** Art. 50 provenance for the current AI draft. Null = no AI involvement. */ + const [draftProvenance, setDraftProvenance] = useState(null) + /** Ref to the AI-generated text, used to detect subsequent human edits. */ + const aiGeneratedDraftRef = useRef(null) + /** Layer B: visible label ON by default for ai|mixed origin; user can toggle. */ + const [aiLabelEnabled, setAiLabelEnabled] = useState(false) + /** Layer B editorial responsibility: claims exemption, turns label OFF (MIME stays). */ + const [editorialResponsible, setEditorialResponsible] = useState(false) const [actionChecked, setActionChecked] = useState>({}) const [draftSubFocused, setDraftSubFocused] = useState(false) const [visibleSections, setVisibleSections] = useState>(() => new Set(['summary', 'draft', 'analysis'])) @@ -638,7 +658,7 @@ export function InboxDetailAiPanel({ messageId, message, onSendDraft, onArchive, } }) - const unsubDone = window.emailInbox.onAiAnalyzeDone(({ messageId: mid }) => { + const unsubDone = window.emailInbox.onAiAnalyzeDone(({ messageId: mid, provenance: doneProvenance }) => { if (!acceptStreamEvent(mid)) return console.log( `[INBOX_AUDIT] renderer_stream_chunks_summary ${JSON.stringify({ @@ -696,9 +716,18 @@ export function InboxDetailAiPanel({ messageId, message, onSendDraft, onArchive, if (adjusted.draftReply && typeof adjusted.draftReply === 'string') { setDraft(adjusted.draftReply) setEditedDraft(adjusted.draftReply) + // Art. 50: use provenance from main process (never mint in renderer). + setDraftProvenance(isAiProvenance(doneProvenance) ? doneProvenance : null) + aiGeneratedDraftRef.current = adjusted.draftReply + setAiLabelEnabled(true) + setEditorialResponsible(false) } else { setDraft(null) setEditedDraft('') + setDraftProvenance(null) + aiGeneratedDraftRef.current = null + setAiLabelEnabled(false) + setEditorialResponsible(false) } } useEmailInboxStore.getState().setAnalysisCache(messageId, adjusted) @@ -1597,13 +1626,29 @@ export function InboxDetailAiPanel({ messageId, message, onSendDraft, onArchive, const handleSend = useCallback(async () => { if (!message || !onSendDraft) return - const draftToSend = isNativeBeap + let draftToSend = isNativeBeap ? [capsulePublicText, capsuleEncryptedText].map((s) => s.trim()).filter(Boolean).join('\n\n---\n\n') : (editedDraft || draft) ?? '' if (!draftToSend.trim()) return + + // Art. 50 Layer B: apply visible label when enabled and applicable. + if (!isNativeBeap && aiLabelEnabled && shouldApplyVisibleSendLabel(draftProvenance)) { + draftToSend = withVisibleAiLabel(draftToSend) + } + + // Resolve effective provenance (editorial responsibility may have been claimed). + const effectiveProvenance = editorialResponsible && draftProvenance + ? markEditorialResponsible(draftProvenance) + : draftProvenance + setSending(true) try { - const result = await onSendDraft(draftToSend, message, attachments.length > 0 ? attachments : undefined) + const result = await onSendDraft( + draftToSend, + message, + attachments.length > 0 ? attachments : undefined, + effectiveProvenance, + ) if (result) { setDraft(null) setEditedDraft('') @@ -1627,6 +1672,9 @@ export function InboxDetailAiPanel({ messageId, message, onSendDraft, onArchive, attachments, capsulePublicText, capsuleEncryptedText, + aiLabelEnabled, + editorialResponsible, + draftProvenance, ]) const handleArchive = useCallback(() => { @@ -1697,6 +1745,7 @@ export function InboxDetailAiPanel({ messageId, message, onSendDraft, onArchive, return (
+