diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index f46881c5b..cd8fb385a 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -75,6 +75,12 @@ jobs: - name: Install locked dependencies run: npm ci + - name: Install native Linux package and credential tools + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + - name: Verify encoded Windows PowerShell ACL helper success streams if: matrix.platform == 'win32' run: npm run test:windows-fixture-acl -w @propr/desktop @@ -94,11 +100,59 @@ jobs: sudo chown root:root "$sandbox" sudo chmod 4755 "$sandbox" test "$(stat -c '%U:%G:%a' "$sandbox")" = 'root:root:4755' - dbus-run-session -- xvfb-run --auto-servernum npm run smoke:connect-package -w @propr/desktop + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1" + eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum npm run smoke:connect-package -w @propr/desktop + ' bash "$keyring_root" - name: Run packaged Darwin main-to-renderer discovery if: matrix.platform == 'darwin' - run: npm run smoke:connect-package -w @propr/desktop + shell: bash + run: | + set -euo pipefail + keychain_root="$(mktemp -d)" + keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db" + keychain_password="$(openssl rand -hex 32)" + original_keychains=() + while IFS= read -r keychain; do + keychain="${keychain#"${keychain%%[![:space:]]*}"}" + keychain="${keychain#\"}" + keychain="${keychain%\"}" + if [[ -n "$keychain" ]]; then + original_keychains+=("$keychain") + fi + done < <(security list-keychains -d user) + IFS= read -r original_default < <(security default-keychain -d user) + original_default="${original_default#"${original_default%%[![:space:]]*}"}" + original_default="${original_default#\"}" + original_default="${original_default%\"}" + cleanup_keychain() { + if (( ${#original_keychains[@]} > 0 )); then + security list-keychains -d user -s "${original_keychains[@]}" || true + else + security list-keychains -d user -s || true + fi + if [[ -n "$original_default" ]]; then + security default-keychain -d user -s "$original_default" || true + fi + security delete-keychain "$keychain_path" || true + rm -rf -- "$keychain_root" + } + trap cleanup_keychain EXIT + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" + security default-keychain -d user -s "$keychain_path" + unset keychain_password + safe_storage_secret="$(openssl rand -hex 32)" + security add-generic-password -a "ProPR Desktop" -s "ProPR Desktop Safe Storage" -w "$safe_storage_secret" -A "$keychain_path" + unset safe_storage_secret + npm run smoke:connect-package -w @propr/desktop - name: Run packaged Windows main-to-renderer discovery as an ordinary user if: matrix.platform == 'win32' diff --git a/apps/desktop/scripts/packaged-connect-launch.mjs b/apps/desktop/scripts/packaged-connect-launch.mjs new file mode 100644 index 000000000..bf2f16544 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-launch.mjs @@ -0,0 +1,13 @@ +export const createPackagedConnectLaunchArguments = ({ platform, userDataPath }) => Object.freeze([ + '--disable-gpu', + `--user-data-dir=${userDataPath}`, + ...(platform === 'linux' ? ['--password-store=gnome-libsecret'] : []), +]); + +/** Keep the tested lifecycle argv identical at the real packaged-binary spawn boundary. */ +export const spawnPackagedConnectBinary = ({ + binaryPath, + launchArguments, + options, + spawn, +}) => spawn(binaryPath, launchArguments, options); diff --git a/apps/desktop/scripts/packaged-connect-launch.test.mjs b/apps/desktop/scripts/packaged-connect-launch.test.mjs new file mode 100644 index 000000000..35fec5ec0 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-launch.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, test } from 'node:test'; +import { + createPackagedConnectLaunchArguments, + spawnPackagedConnectBinary, +} from './packaged-connect-launch.mjs'; + +describe('packaged Connect launch boundary', () => { + test('passes the one effective Linux argv through the actual binary spawn', () => { + const launchArguments = createPackagedConnectLaunchArguments({ + platform: 'linux', + userDataPath: '/tmp/propr-connect-smoke', + }); + let invocation; + const child = {}; + assert.equal(spawnPackagedConnectBinary({ + binaryPath: '/package/propr-desktop', + launchArguments, + options: { shell: false }, + spawn: (file, args, options) => { + invocation = { file, args, options }; + return child; + }, + }), child); + assert.deepEqual(invocation, { + file: '/package/propr-desktop', + args: [ + '--disable-gpu', + '--user-data-dir=/tmp/propr-connect-smoke', + '--password-store=gnome-libsecret', + ], + options: { shell: false }, + }); + assert.equal(invocation.args, launchArguments); + }); + + test('does not add the Linux password-store selection on Darwin', () => { + assert.deepEqual(createPackagedConnectLaunchArguments({ + platform: 'darwin', + userDataPath: '/tmp/propr-connect-smoke', + }), [ + '--disable-gpu', + '--user-data-dir=/tmp/propr-connect-smoke', + ]); + }); + + test('the lifecycle and real binary spawn share the derived argv source', async () => { + const source = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + assert.match(source, /const launchArguments = createPackagedConnectLaunchArguments\(\{/u); + assert.match(source, /spawnPackagedConnectBinary\(\{[\s\S]*?launchArguments: args,/u); + assert.match(source, /runPackagedConnectLifecycle\(\{[\s\S]*?args: launchArguments,/u); + assert.doesNotMatch(source, /spawn\(binaryPath, \['--disable-gpu'/u); + }); +}); diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs index 93deaaffb..59b4849c9 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs @@ -5,6 +5,12 @@ import { TextDecoder } from 'node:util'; import { fileURLToPath } from 'node:url'; export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready'; +export const CONNECT_DISCOVERY_MILESTONE_EVENT = 'desktop.renderer.connect_discovery.milestone'; +export const CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.stage'; +export const CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure'; +export const CONNECT_NETWORK_PERMISSION_EVENT = 'desktop.renderer.connect_network_permission'; +export const CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation'; +export const CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership'; export const CHILD_CAPTURE_MAX_BYTES = 64 * 1024; export const CHILD_DIAGNOSTIC_MAX_RECORDS = 20; @@ -23,6 +29,12 @@ const diagnosticEvents = new Set([ 'desktop.log.write_failed', 'desktop.main_process.uncaught_exception', CONNECT_READY_EVENT, + CONNECT_DISCOVERY_MILESTONE_EVENT, + CONNECT_JOURNEY_STAGE_EVENT, + CONNECT_JOURNEY_FAILURE_EVENT, + CONNECT_NETWORK_PERMISSION_EVENT, + CONNECT_JOURNEY_OPERATION_EVENT, + CONNECT_RENDERER_OWNERSHIP_EVENT, 'desktop.renderer.connect_discovery.phase', 'desktop.renderer.connect_discovery.status', 'desktop.renderer.gone', @@ -40,6 +52,42 @@ const diagnosticCodes = new Set([ 'OPERATION_FAILED', 'UNCAUGHT_EXCEPTION', ]); +const journeyStageCodes = new Set([ + 'JOURNEY_DISCOVERY_RENDERER', + 'JOURNEY_DISCOVERY_VALIDATED', + 'JOURNEY_STORAGE_BACKEND', + 'JOURNEY_NEGATIVE_MALFORMED', + 'JOURNEY_NEGATIVE_OVERSIZED', + 'JOURNEY_NEGATIVE_EXPIRY', + 'JOURNEY_NEGATIVE_CANCEL', + 'JOURNEY_NEGATIVE_STATE', + 'JOURNEY_PAIR_MANUAL_FORM', + 'JOURNEY_PAIR_BROWSER_APPROVAL', + 'JOURNEY_PAIR_ACTIVATION_DASHBOARD', + 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED', + 'JOURNEY_PAIR_CREDENTIAL_COMMITTED', + 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY', + 'JOURNEY_PAIR_ACTIVATION_COMMITTED', + 'JOURNEY_PAIR_ACTIVATION_PUBLISHED', + 'JOURNEY_PAIR_REACT_CONNECTED', + 'JOURNEY_PAIR_TRANSPORT', + 'JOURNEY_PAIR_COMPLETE', + 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD', + 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY', + 'JOURNEY_REPROBE_ACTIVATION_COMMITTED', + 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED', + 'JOURNEY_REPROBE_REACT_CONNECTED', + 'JOURNEY_REPROBE_TRANSPORT', + 'JOURNEY_REPROBE_COMPLETE', +]); +const journeyFailurePhases = new Set(['pair', 'reprobe']); +const journeyFailureReasons = new Set([ + 'APPROVAL_REJECTED', + 'JOURNEY_FAILED', + 'RENDERER_STAGE_TIMEOUT', + 'RENDERER_STATE_TIMEOUT', + 'TRANSPORT_EVIDENCE_TIMEOUT', +]); const diagnosticPhases = new Set([ 'config-read', 'addon-integrity-type', @@ -60,26 +108,135 @@ const diagnosticCategories = new Set([ 'type-mismatch', 'unexpected', ]); +const networkPermissionCategories = new Set([ + 'local-network-access', + 'local-network', + 'loopback-network', +]); +const networkPermissionDecisions = new Set(['check', 'request']); +const networkPermissionBooleanFields = [ + 'activeBindingCurrent', + 'webContentsPresent', + 'webContentsEqualsMainWindow', + 'mainWindowPresent', + 'isMainFrame', + 'requestingUrlPresent', + 'requestingUrlTrusted', + 'rendererDocumentUrlTrusted', + 'requestingOriginAuthorityValid', + 'requestingOriginAuthorityEqual', +]; +const journeyOperations = new Set(['PROFILE_SAVE', 'PAIR', 'PROBE', 'ACTIVATE']); +const journeyOperationStatuses = new Set([ + 'COMPLETED', 'READY', 'AUTHENTICATION_REQUIRED', 'INCOMPATIBLE', 'OFFLINE', 'REJECTED', +]); +const rendererOwnershipResourceCategories = new Set(['xhr', 'webSocket', 'other']); +const rendererOwnershipBooleanFields = [ + 'mainRendererPresent', + 'mainRendererLive', + 'webContentsIdMatches', + 'webContentsAbsentOrMatches', + 'mainFrameLive', + 'rendererDocumentTrusted', + 'rendererDocumentAuthorityEqual', + 'frameOmitted', + 'framePresent', + 'frameMatchesMainFrame', + 'frameExplicitlyForeign', + 'rendererOwned', +]; + +const boundedNetworkPermissionEvidence = record => { + if (record.schemaVersion !== 1 + || !networkPermissionCategories.has(record.permissionCategory) + || !networkPermissionDecisions.has(record.decision) + || typeof record.allowed !== 'boolean' + || networkPermissionBooleanFields.some(field => typeof record[field] !== 'boolean')) return {}; + return { + schemaVersion: 1, + permissionCategory: record.permissionCategory, + decision: record.decision, + allowed: record.allowed, + ...Object.fromEntries(networkPermissionBooleanFields.map(field => [field, record[field]])), + }; +}; -export const boundedChildDiagnostics = records => records.flatMap(record => { - if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return []; - const nestedCode = record.error && typeof record.error === 'object' ? record.error.code : undefined; - const candidateCode = typeof record.code === 'string' ? record.code : nestedCode; - const phase = typeof record.phase === 'string' ? record.phase : undefined; - const substep = typeof record.substep === 'string' ? record.substep : undefined; - const category = typeof record.category === 'string' ? record.category : undefined; - return [{ - event: record.event, - ...(diagnosticPhases.has(phase) && diagnosticPhaseCodes.has(candidateCode) - ? { - phase, - code: candidateCode, - ...(candidateCode === 'FAILED' && diagnosticSubsteps.has(substep) ? { substep } : {}), - ...(candidateCode === 'FAILED' && diagnosticCategories.has(category) ? { category } : {}), - } - : diagnosticCodes.has(candidateCode) ? { code: candidateCode } : {}), - }]; -}).slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS); +const boundedJourneyOperationEvidence = record => { + if (!journeyOperations.has(record.operation) || !journeyOperationStatuses.has(record.status)) return {}; + return { operation: record.operation, status: record.status }; +}; + +const boundedRendererOwnershipEvidence = record => { + if (record.schemaVersion !== 1 + || !rendererOwnershipResourceCategories.has(record.resourceCategory) + || rendererOwnershipBooleanFields.some(field => typeof record[field] !== 'boolean')) return {}; + return { + schemaVersion: 1, + resourceCategory: record.resourceCategory, + ...Object.fromEntries(rendererOwnershipBooleanFields.map(field => [field, record[field]])), + }; +}; + +export const boundedChildDiagnostics = records => { + const diagnostics = records.flatMap(record => { + if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return []; + if (record.event === CONNECT_NETWORK_PERMISSION_EVENT) { + return [{ event: record.event, ...boundedNetworkPermissionEvidence(record) }]; + } + if (record.event === CONNECT_JOURNEY_OPERATION_EVENT) { + return [{ event: record.event, ...boundedJourneyOperationEvidence(record) }]; + } + if (record.event === CONNECT_JOURNEY_FAILURE_EVENT) { + return [{ + event: record.event, + ...(journeyFailurePhases.has(record.phase) + && (record.stage === 'JOURNEY_NOT_STARTED' || journeyStageCodes.has(record.stage)) + && journeyFailureReasons.has(record.reason) + ? { phase: record.phase, stage: record.stage, reason: record.reason } + : {}), + }]; + } + if (record.event === CONNECT_RENDERER_OWNERSHIP_EVENT) { + return [{ event: record.event, ...boundedRendererOwnershipEvidence(record) }]; + } + const nestedCode = record.error && typeof record.error === 'object' ? record.error.code : undefined; + const candidateCode = typeof record.code === 'string' ? record.code : nestedCode; + const phase = typeof record.phase === 'string' ? record.phase : undefined; + const substep = typeof record.substep === 'string' ? record.substep : undefined; + const category = typeof record.category === 'string' ? record.category : undefined; + return [{ + event: record.event, + ...(journeyStageCodes.has(candidateCode) + && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT + || record.event === CONNECT_JOURNEY_STAGE_EVENT) + ? { code: candidateCode } + : diagnosticPhases.has(phase) && diagnosticPhaseCodes.has(candidateCode) + ? { + phase, + code: candidateCode, + ...(candidateCode === 'FAILED' && diagnosticSubsteps.has(substep) ? { substep } : {}), + ...(candidateCode === 'FAILED' && diagnosticCategories.has(category) ? { category } : {}), + } + : diagnosticCodes.has(candidateCode) ? { code: candidateCode } : {}), + }]; + }); + const bounded = diagnostics.slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS); + if (diagnostics.length > CHILD_DIAGNOSTIC_MAX_RECORDS) { + const latestCriticalEvidence = [ + diagnostics.findLast(record => record.event === CONNECT_JOURNEY_OPERATION_EVENT), + diagnostics.findLast(record => record.event === CONNECT_RENDERER_OWNERSHIP_EVENT), + diagnostics.findLast(record => typeof record.code === 'string' + && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT + || record.event === CONNECT_JOURNEY_STAGE_EVENT)), + diagnostics.findLast(record => record.event === CONNECT_JOURNEY_FAILURE_EVENT), + ].filter(Boolean); + const withoutLatestCriticalEvidence = bounded.filter(record => !latestCriticalEvidence.includes(record)); + return withoutLatestCriticalEvidence + .slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS - latestCriticalEvidence.length) + .concat(latestCriticalEvidence); + } + return bounded; +}; const exactKeys = (record, expected) => { const actual = Object.keys(record).sort(); @@ -670,6 +827,24 @@ export const preservePrimaryWithCleanup = (outcome, cleanup) => cleanup.ok ? out secondary: [...new Set([...(outcome.secondary ?? []), cleanup.category])], }); +export const createIdempotentJourneyFixtureClose = ({ + closeSocketServer, + closeHttpServer, +}) => { + let closePromise; + return () => { + closePromise ??= (async () => { + await closeSocketServer(); + try { + await closeHttpServer(); + } catch (error) { + if (error?.code !== 'ERR_SERVER_NOT_RUNNING') throw error; + } + })(); + return closePromise; + }; +}; + if (isIsolatedCleanupProcess) { let input = ''; try { diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index 65c9639e0..0d1774698 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -1,13 +1,20 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { lstat, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, test } from 'node:test'; import { CHILD_CAPTURE_MAX_BYTES, + CONNECT_DISCOVERY_MILESTONE_EVENT, + CONNECT_JOURNEY_FAILURE_EVENT, + CONNECT_JOURNEY_STAGE_EVENT, + CONNECT_JOURNEY_OPERATION_EVENT, + CONNECT_NETWORK_PERMISSION_EVENT, + CONNECT_RENDERER_OWNERSHIP_EVENT, CONNECT_READY_EVENT, + createIdempotentJourneyFixtureClose, isExactReadyRecord, preservePrimaryWithCleanup, removeAuthorizedConnectFixture, @@ -90,6 +97,19 @@ const run = ({ app = new FakeChild(), onApp, onKiller, ...options } = {}) => { }; describe('packaged Connect bounded child lifecycle', () => { + test('requires exact three starts, three browser approvals, one poll, and one activation', async () => { + const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + const accounting = harness.slice( + harness.indexOf('if (discoveries.length !== 8'), + harness.indexOf('|| bootstrap.some(request => request.authorization !== null)'), + ); + assert.match(accounting, /pairingStarts\.length !== 3/u); + assert.match(accounting, /pairingBrowsers\.length !== 3/u); + assert.match(accounting, /pairingPolls\.length !== 1/u); + assert.match(accounting, /pairingActivations\.length !== 1/u); + assert.doesNotMatch(accounting, /pairingPolls\.length < 3/u); + }); + test('accepts an exact ready proof followed by a clean exit', async () => { const { result, invocations } = await run({ onApp: app => { @@ -106,6 +126,259 @@ describe('packaged Connect bounded child lifecycle', () => { assert.equal(invocations.length, 1); }); + test('does not accept an intermediate discovery milestone as terminal readiness', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_DISCOVERY_MILESTONE_EVENT, + code: 'JOURNEY_DISCOVERY_VALIDATED', + ignored: 'bounded-extra-field', + }); + app.close(0, null); + }, + }); + assert.deepEqual(result, { + ok: false, + category: 'child-exit-before-ready', + capture: 'complete', + records: [{ + event: CONNECT_DISCOVERY_MILESTONE_EVENT, + code: 'JOURNEY_DISCOVERY_VALIDATED', + }], + }); + }); + + test('returns only exact allowlisted journey stages', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_PAIR_TRANSPORT', + url: 'https://not-returned.example.test/private', + }); + app.write({ event: CONNECT_JOURNEY_STAGE_EVENT, code: 'UNBOUNDED_STAGE' }); + app.write({ + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'PROBE', + status: 'AUTHENTICATION_REQUIRED', + error: 'not-returned', + }); + app.write({ + event: CONNECT_RENDERER_OWNERSHIP_EVENT, + schemaVersion: 1, + resourceCategory: 'xhr', + mainRendererPresent: true, + mainRendererLive: true, + webContentsIdMatches: true, + webContentsAbsentOrMatches: true, + mainFrameLive: true, + rendererDocumentTrusted: true, + rendererDocumentAuthorityEqual: true, + frameOmitted: true, + framePresent: false, + frameMatchesMainFrame: false, + frameExplicitlyForeign: false, + rendererOwned: false, + url: 'not-returned', + }); + app.close(0, null); + }, + }); + assert.equal(result.category, 'child-exit-before-ready'); + assert.deepEqual(result.records, [ + { event: CONNECT_JOURNEY_STAGE_EVENT, code: 'JOURNEY_PAIR_TRANSPORT' }, + { event: CONNECT_JOURNEY_STAGE_EVENT }, + { + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'PROBE', + status: 'AUTHENTICATION_REQUIRED', + }, + { + event: CONNECT_RENDERER_OWNERSHIP_EVENT, + schemaVersion: 1, + resourceCategory: 'xhr', + mainRendererPresent: true, + mainRendererLive: true, + webContentsIdMatches: true, + webContentsAbsentOrMatches: true, + mainFrameLive: true, + rendererDocumentTrusted: true, + rendererDocumentAuthorityEqual: true, + frameOmitted: true, + framePresent: false, + frameMatchesMainFrame: false, + frameExplicitlyForeign: false, + rendererOwned: false, + }, + ]); + assert.doesNotMatch(JSON.stringify(result), /not-returned|UNBOUNDED_STAGE|url|error/u); + }); + + test('retains the latest bounded journey stage when earlier diagnostics fill the cap', async () => { + const { result } = await run({ + onApp: app => { + for (let index = 0; index < 20; index += 1) { + app.write({ event: 'desktop.app.ready', code: 'DETAIL_REDACTED' }); + } + app.write({ + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'ACTIVATE', + status: 'REJECTED', + error: 'not-returned', + }); + app.write({ event: CONNECT_JOURNEY_STAGE_EVENT, code: 'JOURNEY_PAIR_ACTIVATION_DASHBOARD' }); + app.close(0, null); + }, + }); + assert.equal(result.records.length, 20); + assert.deepEqual(result.records.at(-2), { + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'ACTIVATE', + status: 'REJECTED', + }); + assert.deepEqual(result.records.at(-1), { + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_PAIR_ACTIVATION_DASHBOARD', + }); + assert.doesNotMatch(JSON.stringify(result), /not-returned/u); + }); + + test('retains only fixed terminal journey failure evidence when diagnostics fill the cap', async () => { + const { result } = await run({ + onApp: app => { + for (let index = 0; index < 20; index += 1) { + app.write({ event: 'desktop.app.ready', code: 'DETAIL_REDACTED' }); + } + app.write({ + event: CONNECT_JOURNEY_FAILURE_EVENT, + phase: 'pair', + stage: 'JOURNEY_PAIR_REACT_CONNECTED', + reason: 'RENDERER_STATE_TIMEOUT', + error: 'secret-SENTINEL', + url: 'https://not-returned.example.test/private', + responseBody: 'not-returned', + token: 'not-returned', + path: privateWindowsPath, + environment: 'not-returned', + }); + app.write({ event: 'desktop.app.start_failed', error: 'secret-SENTINEL' }); + app.close(1, null); + }, + }); + assert.equal(result.records.length, 20); + assert.deepEqual(result.records.at(-1), { + event: CONNECT_JOURNEY_FAILURE_EVENT, + phase: 'pair', + stage: 'JOURNEY_PAIR_REACT_CONNECTED', + reason: 'RENDERER_STATE_TIMEOUT', + }); + assert.doesNotMatch( + JSON.stringify(result), + /secret-SENTINEL|not-returned|private-user|url|responseBody|token|path|environment/u, + ); + }); + + test('drops non-allowlisted terminal journey failure fields', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_FAILURE_EVENT, + phase: 'hostile-phase', + stage: 'HOSTILE_STAGE', + reason: 'hostile-reason', + error: 'secret-SENTINEL', + }); + app.close(1, null); + }, + }); + assert.deepEqual(result.records, [{ event: CONNECT_JOURNEY_FAILURE_EVENT }]); + assert.doesNotMatch(JSON.stringify(result), /hostile|secret-SENTINEL/u); + }); + + test('returns only fixed secret-free Local Network Access decision evidence', async () => { + const fixed = { + event: CONNECT_NETWORK_PERMISSION_EVENT, + schemaVersion: 1, + permissionCategory: 'loopback-network', + decision: 'request', + allowed: true, + activeBindingCurrent: true, + webContentsPresent: true, + webContentsEqualsMainWindow: true, + mainWindowPresent: true, + isMainFrame: true, + requestingUrlPresent: true, + requestingUrlTrusted: true, + rendererDocumentUrlTrusted: true, + requestingOriginAuthorityValid: true, + requestingOriginAuthorityEqual: true, + }; + const { result } = await run({ + onApp: app => { + app.write({ ...fixed, url: 'not-returned' }); + app.write({ ...fixed, permissionCategory: 'notifications', requestingUrl: 'not-returned' }); + app.close(0, null); + }, + }); + assert.deepEqual(result.records, [ + fixed, + { event: CONNECT_NETWORK_PERMISSION_EVENT }, + ]); + assert.doesNotMatch(JSON.stringify(result), /not-returned|"url":|"requestingUrl":/u); + }); + + test('fails closed when an otherwise allowlisted journey stage contains a secret', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_REPROBE_TRANSPORT', + detail: 'secret-SENTINEL', + }); + app.close(0, null); + }, + }); + assert.equal(result.category, 'output-rejected'); + assert.deepEqual(result.records, [{ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_REPROBE_TRANSPORT', + }]); + assert.doesNotMatch(JSON.stringify(result), /SENTINEL|detail/u); + }); + + test('publishes the sole terminal READY only after each real journey phase', async () => { + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.equal((main.match(/'desktop\.renderer\.connect_discovery\.ready'/gu) ?? []).length, 1); + const connectBranch = main.slice( + main.indexOf('if (connectSmoke) {'), + main.indexOf('} else if (transportSmoke)'), + ); + const discovery = connectBranch.indexOf('await runPackagedConnectDiscoverySmoke'); + const journey = connectBranch.indexOf('await runPackagedConnectJourneySmoke'); + const ready = connectBranch.indexOf('await publishPackagedConnectReady'); + assert.ok(discovery >= 0 && discovery < journey && journey < ready); + + const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + const pair = harness.indexOf("outcome = await runPhase('pair')"); + const reprobe = harness.indexOf("outcome = await runPhase('reprobe')"); + const persistedEvidence = harness.indexOf('const applicationRequests = journeyFixture.requests'); + assert.ok(pair >= 0 && pair < reprobe && reprobe < persistedEvidence); + + const manual = main.indexOf("'JOURNEY_PAIR_MANUAL_FORM'"); + const browser = main.indexOf("reportPackagedConnectJourneyStage('JOURNEY_PAIR_BROWSER_APPROVAL')"); + const credential = main.indexOf("'JOURNEY_PAIR_CREDENTIAL_COMMITTED'"); + const reprobeReady = main.indexOf("'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY'"); + const activation = main.indexOf("'JOURNEY_PAIR_ACTIVATION_COMMITTED'"); + const publication = main.indexOf("'JOURNEY_PAIR_ACTIVATION_PUBLISHED'"); + const react = main.indexOf("'JOURNEY_PAIR_REACT_CONNECTED'"); + assert.ok(manual >= 0 && browser >= 0 && credential >= 0 && reprobeReady >= 0 + && activation >= 0 && publication >= 0 && react >= 0); + assert.match(main, /await stages\.waitFor\('CREDENTIAL_COMMITTED'\)[\s\S]*?await stages\.waitFor\('AUTHENTICATED_REPROBE_READY'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_COMMITTED'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_PUBLISHED'\)[\s\S]*?await stages\.waitFor\('REACT_CONNECTED'\)/u); + assert.match(main, /if \(packagedSmokeTest && !transportSmoke && !connectJourney\)/u); + assert.match(main, /if \(packagedSmokeTest && !connectJourney\) \{/u); + assert.doesNotMatch(main, /JOURNEY_PAIR_RENDERER|JOURNEY_REPROBE_RENDERER/u); + }); + test('forces a ready app with a hung descendant through an exact bounded taskkill invocation', async () => { const { result, invocations } = await run({ onApp: app => app.write(readyRecord()), @@ -364,6 +637,33 @@ describe('packaged Connect fixture cleanup', () => { } }; + test('closes the journey fixture once and tolerates only the already-stopped server condition', async () => { + let socketCloses = 0; + let httpCloses = 0; + const close = createIdempotentJourneyFixtureClose({ + closeSocketServer: async () => { socketCloses += 1; }, + closeHttpServer: async () => { + httpCloses += 1; + throw Object.assign(new Error('server already stopped'), { code: 'ERR_SERVER_NOT_RUNNING' }); + }, + }); + const first = close(); + const second = close(); + assert.equal(first, second); + await Promise.all([first, second, close()]); + assert.equal(socketCloses, 1); + assert.equal(httpCloses, 1); + + const failure = createIdempotentJourneyFixtureClose({ + closeSocketServer: async () => undefined, + closeHttpServer: async () => { + throw Object.assign(new Error('/private/path-SENTINEL'), { code: 'EIO' }); + }, + }); + await assert.rejects(failure(), { code: 'EIO' }); + assert.equal(failure(), failure()); + }); + test('retries a transient Windows EBUSY only inside the authorized fixture', async () => { let attempts = 0; const result = await removeAuthorizedConnectFixture({ diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs new file mode 100644 index 000000000..0e8950ebe --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, test } from 'node:test'; + +const workflow = await readFile( + new URL('../../../.github/workflows/desktop-connect-discovery-guard.yml', import.meta.url), + 'utf8', +); + +describe('packaged Connect target-native credential setup', () => { + test('Linux retains one isolated unlocked libsecret session and rejects plaintext fallback', async () => { + const linux = workflow.slice( + workflow.indexOf('- name: Run packaged Linux main-to-renderer discovery'), + workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'), + ); + assert.match(linux, /keyring_root="\$\(mktemp -d\)"/u); + assert.match(linux, /export XDG_DATA_HOME="\$1"/u); + assert.match(linux, /export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="\$1"/u); + assert.match(linux, /gnome-keyring-daemon --unlock --components=secrets/u); + + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.match(main, /process\.platform === 'linux' \? 'gnome_libsecret' : 'os-protected'/u); + assert.match(main, /security\.backend !== requiredStorageBackend/u); + }); + + test('Darwin uses only a generated ephemeral default keychain and restores it on exit', () => { + const darwin = workflow.slice( + workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'), + workflow.indexOf('- name: Run packaged Windows main-to-renderer discovery'), + ); + assert.match(darwin, /keychain_root="\$\(mktemp -d\)"/u); + assert.match(darwin, /keychain_password="\$\(openssl rand -hex 32\)"/u); + assert.match(darwin, /trap cleanup_keychain EXIT/u); + assert.match(darwin, /security create-keychain -p "\$keychain_password" "\$keychain_path"/u); + assert.match(darwin, /security unlock-keychain -p "\$keychain_password" "\$keychain_path"/u); + assert.match(darwin, /security list-keychains -d user -s "\$keychain_path"/u); + assert.match(darwin, /security default-keychain -d user -s "\$keychain_path"/u); + assert.match(darwin, /safe_storage_secret="\$\(openssl rand -hex 32\)"/u); + assert.match(darwin, /security add-generic-password -a "ProPR Desktop" -s "ProPR Desktop Safe Storage" -w "\$safe_storage_secret" -A "\$keychain_path"/u); + assert.match(darwin, /unset safe_storage_secret\n\s+npm run smoke:connect-package/u); + assert.match(darwin, /security list-keychains -d user -s "\$\{original_keychains\[@\]\}"/u); + assert.match(darwin, /security delete-keychain "\$keychain_path"/u); + assert.doesNotMatch(darwin, /echo[^\n]*safe_storage_secret|printf[^\n]*safe_storage_secret/u); + assert.doesNotMatch(darwin, /CERTIFICATE|security import|codesign|notari/iu); + }); +}); diff --git a/apps/desktop/scripts/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs index 962d53ab4..c4a5e1832 100644 --- a/apps/desktop/scripts/run-native-durability.mjs +++ b/apps/desktop/scripts/run-native-durability.mjs @@ -5,6 +5,7 @@ const EXPECTED = Object.freeze({ 'credential-service': 72, 'profile-store': 37, 'pairing-shutdown': 10, + 'pairing-browser': 1, }); const expectedTotal = Object.values(EXPECTED).reduce((total, count) => total + count, 0); const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli')); @@ -15,6 +16,7 @@ const child = spawn(process.execPath, [ 'src/profile-store.test.ts', 'src/credential-service.test.ts', 'src/pairing-response-lifecycle.test.ts', + 'src/credential-service.pairing-browser.test.ts', ], { cwd: fileURLToPath(new URL('..', import.meta.url)), env: process.env, @@ -51,6 +53,7 @@ const executed = { 'credential-service': plannedForSuite('main-process desktop credential service'), 'profile-store': plannedForSuite('desktop profile store'), 'pairing-shutdown': plannedForSuite('desktop pairing service IPC native shutdown lifecycle'), + 'pairing-browser': plannedForSuite('DesktopCredentialService pairing browser sink'), }; const reportedCategory = (category) => { const match = output.match(new RegExp( diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index a8b8493be..e688f7aba 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -1,15 +1,30 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { once } from 'node:events'; import { - chmod, lstat, mkdir, mkdtemp, readFile, realpath, writeFile, + chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import { createServer } from 'node:http'; import { basename, dirname, join, relative, resolve } from 'node:path'; +import { Server as SocketIOServer } from 'socket.io'; import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + DESKTOP_TRANSPORT_SCOPE_QUERY, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { + createIdempotentJourneyFixtureClose, preservePrimaryWithCleanup, removeAuthorizedConnectFixture, runPackagedConnectLifecycle, } from './packaged-connect-lifecycle.mjs'; +import { + createPackagedConnectLaunchArguments, + spawnPackagedConnectBinary, +} from './packaged-connect-launch.mjs'; import { canonicalizeWindowsFixtureEntry, encodedWindowsFixtureAcl, @@ -67,6 +82,254 @@ let packagedConnectPhase = 'fixture-setup'; let windowsStagedContract; let windowsStagedHandoff; +const createPackagedJourneyFixture = async () => { + const pairingId = `dpr_${'P'.repeat(22)}`; + const deviceSecret = 'D'.repeat(43); + const activationTicket = 'A'.repeat(43); + const token = `propr_it_${'T'.repeat(43)}`; + const receipt = 'R'.repeat(22); + const requests = []; + let endpoint; + let approved = false; + let active = false; + let binding; + let mode = 'success'; + const cors = { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-ProPR-Desktop-Transport-Scope', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Allow-Private-Network': 'true', + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }; + const readJson = request => new Promise((resolveBody, rejectBody) => { + const chunks = []; + let bytes = 0; + request.on('data', chunk => { + bytes += chunk.length; + if (bytes > 16 * 1024) { + rejectBody(new Error('oversized request')); + request.destroy(); + } else chunks.push(chunk); + }); + request.on('end', () => { + try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch (error) { rejectBody(error); } + }); + request.on('error', rejectBody); + }); + const server = createServer(async (request, response) => { + const record = { + method: request.method, + url: request.url, + authorization: request.headers.authorization ?? null, + origin: request.headers.origin ?? null, + transportScope: request.headers[DESKTOP_TRANSPORT_SCOPE_HEADER.toLowerCase()] ?? null, + socketIo: false, + }; + requests.push(record); + if (request.method === 'OPTIONS') { + response.writeHead(204, cors); + response.end(); + return; + } + try { + if (request.method === 'POST' && request.url?.startsWith('/__packaged/control/')) { + const requestedMode = request.url.slice('/__packaged/control/'.length); + if (!['success', 'malformed', 'oversized', 'expiry', 'cancel'].includes(requestedMode)) { + throw new Error('invalid fixture mode'); + } + mode = requestedMode; + approved = false; + binding = undefined; + response.writeHead(204, cors); + response.end(); + return; + } + if (request.method === 'GET' && request.url === '/__packaged/evidence') { + const authenticatedRest = requests.filter(item => item.socketIo === false + && item.url === '/api/auth/user' + && item.authorization === `Bearer ${token}` + && item.transportScope === null); + const authenticatedSockets = requests.filter(item => item.socketIo === true + && item.authorization === `Bearer ${token}` + && item.transportScope !== null + && item.socketAuthScope === item.transportScope); + response.writeHead(200, cors); + response.end(JSON.stringify({ + authenticatedRest: authenticatedRest.length, + authenticatedSockets: authenticatedSockets.length, + })); + return; + } + if (request.method === 'GET' && request.url === '/api/desktop/discovery') { + response.writeHead(200, cors); + if (mode === 'malformed') { + response.end('{"product":"ProPR"}'); + return; + } + if (mode === 'oversized') { + response.end(`{"ignored":"${'x'.repeat(9 * 1024)}"}`); + return; + } + response.end(JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: identity, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + })); + return; + } + if (request.method === 'POST' && request.url === '/api/desktop/pairings') { + binding = await readJson(request); + response.writeHead(201, cors); + response.end(JSON.stringify({ + pairingId, + deviceSecret, + approvalUrl: `${endpoint}/api/desktop/pairings/${pairingId}/browser`, + expiresAt: new Date(Date.now() + (mode === 'expiry' ? 200 : 60_000)).toISOString(), + interval: 1, + })); + return; + } + if (request.method === 'GET' && request.url === `/api/desktop/pairings/${pairingId}/browser`) { + approved = true; + response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' }); + response.end('Desktop approved

Approved

'); + return; + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/poll`) { + const body = await readJson(request); + if (body.deviceSecret !== deviceSecret || !approved || !binding) throw new Error('pairing not approved'); + if (mode === 'cancel' || mode === 'expiry') { + response.writeHead(202, cors); + response.end('{"status":"pending","interval":1}'); + return; + } + response.writeHead(200, cors); + response.end(JSON.stringify({ + status: 'provisional', token, tokenType: 'Bearer', activationTicket, + activationExpiresAt: new Date(Date.now() + 60_000).toISOString(), + instanceId: binding.instanceId, + origin: binding.origin, + scope: binding.scope, + credentialGeneration: binding.credentialGeneration, + })); + return; + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/activate`) { + const body = await readJson(request); + if (body.deviceSecret !== deviceSecret || body.activationTicket !== activationTicket) { + throw new Error('activation binding rejected'); + } + active = true; + response.writeHead(200, cors); + response.end(JSON.stringify({ + status: 'active', receipt, activatedAt: new Date().toISOString(), expiresAt: null, + })); + return; + } + if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') { + active = false; + response.writeHead(204, cors); + response.end(); + return; + } + if (request.method === 'GET' && request.url === '/api/auth/user' + && active && record.authorization === `Bearer ${token}`) { + response.writeHead(200, cors); + response.end(JSON.stringify({ + id: 'packaged-owner', login: 'packaged-owner', username: 'packaged-owner', + displayName: 'Packaged Owner', email: null, avatarUrl: null, + role: 'admin', permissions: [], authorizationSource: 'bootstrap', + })); + return; + } + if (request.method === 'GET' && record.authorization === `Bearer ${token}`) { + response.writeHead(200, cors); + response.end('{}'); + return; + } + } catch { + response.writeHead(400, cors); + response.end('{"code":"INVALID_SMOKE_REQUEST"}'); + return; + } + response.writeHead(401, cors); + response.end('{"code":"INVALID_INSTANCE_TOKEN"}'); + }); + const io = new SocketIOServer(server, { + path: '/socket.io/', + transports: ['websocket'], + cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false }, + }); + io.of('/').use((socket, next) => { + const scopes = new URL(socket.handshake.url, 'http://fixture.invalid') + .searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); + requests.push({ + method: 'SOCKET.IO', + url: socket.handshake.url, + authorization: socket.handshake.headers.authorization ?? null, + origin: socket.handshake.headers.origin ?? null, + transportScope: scopes[0] ?? null, + socketAuthScope: socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] ?? null, + socketIo: true, + }); + if (!active || socket.handshake.headers.authorization !== `Bearer ${token}` + || scopes.length !== 1 || socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] !== scopes[0]) { + const error = new Error('INVALID_INSTANCE_TOKEN'); + error.data = { code: 'INVALID_INSTANCE_TOKEN' }; + next(error); + return; + } + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Packaged journey fixture did not bind'); + endpoint = `http://127.0.0.1:${address.port}`; + const close = createIdempotentJourneyFixtureClose({ + closeSocketServer: () => io.close(), + closeHttpServer: () => new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }), + }); + return { + endpoint, + requests, + secrets: [deviceSecret, activationTicket, token], + close, + }; +}; + +const directoryContainsPlaintext = async (root, needles) => { + const visit = async path => { + const entries = await readdir(path, { withFileTypes: true }); + for (const entry of entries) { + const child = join(path, entry.name); + if (entry.isDirectory()) { + if (await visit(child)) return true; + } else if (entry.isFile()) { + const contents = await readFile(child); + if (needles.some(needle => contents.includes(Buffer.from(needle)))) return true; + } + } + return false; + }; + return visit(root); +}; + if (process.platform === 'win32') { try { packagedConnectPhase = 'staged-contract'; @@ -219,6 +482,7 @@ const protectWindowsEntries = entries => { let canonicalTemp; let fixture; let generatedFixtureLeaf; +let journeyFixture; let outcome = { ok: false, category: 'fixture-setup', capture: 'complete', records: [] }; let failurePhase = 'fixture-setup'; try { @@ -263,11 +527,13 @@ try { || relative(canonicalTemp, configRoot) !== join(generatedFixtureLeaf, 'config')) { throw new Error('Connect smoke fixture escaped its fixed root'); } + if (process.platform !== 'win32') journeyFixture = await createPackagedJourneyFixture(); failurePhase = 'package-validation'; await assertPackageAuthority(); const treeKillerPath = await windowsTreeKiller(); const sensitiveNeedles = [ ...secrets, fixture, configRoot, stackRoot, identity, + ...(journeyFixture?.secrets ?? []), ...packagedConnectArtifactSensitiveNeedles({ platform: process.platform, artifactRoot, @@ -284,39 +550,108 @@ try { PROPR_CONNECTOR_TOKEN: secrets[1], PROPR_RELAY_TOKEN: secrets[2], GITHUB_TOKEN: secrets[3], + ...(journeyFixture ? { + PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT: journeyFixture.endpoint, + } : {}), }; delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_PARENT; delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_LEAF; + const launchArguments = createPackagedConnectLaunchArguments({ + platform: process.platform, + userDataPath, + }); const spawnLifecycleProcess = (executable, args, options) => { if (executable !== binaryPath) return spawn(executable, args, options); - const child = spawn(binaryPath, ['--disable-gpu', `--user-data-dir=${userDataPath}`], { - ...options, - env: childEnvironment, + const child = spawnPackagedConnectBinary({ + binaryPath, + launchArguments: args, + options: { + ...options, + env: options.env, + }, + spawn, }); return child; }; failurePhase = 'lifecycle-internal'; - outcome = await runPackagedConnectLifecycle({ - binaryPath, - args: ['--disable-gpu', `--user-data-dir=${userDataPath}`], - platform: process.platform, - arch: process.arch, - authorityMechanism: authorityMechanism(), - sensitiveNeedles, - treeKillerPath, - env: childEnvironment, - spawn: spawnLifecycleProcess, - }); + const runPhase = async phase => await runPackagedConnectLifecycle({ + binaryPath, + args: launchArguments, + platform: process.platform, + arch: process.arch, + authorityMechanism: authorityMechanism(), + sensitiveNeedles, + treeKillerPath, + env: { + ...childEnvironment, + ...(journeyFixture ? { PROPR_DESKTOP_CONNECT_JOURNEY_PHASE: phase } : {}), + }, + spawn: spawnLifecycleProcess, + }); + outcome = await runPhase('pair'); + if (outcome.ok && journeyFixture) { + outcome = await runPhase('reprobe'); + if (outcome.ok) { + const applicationRequests = journeyFixture.requests.filter(request => request.method !== 'OPTIONS'); + const discoveries = applicationRequests.filter(request => request.url === '/api/desktop/discovery'); + const bootstrap = applicationRequests.filter(request => + request.url === '/api/desktop/pairings' + || /^\/api\/desktop\/pairings\/[^/]+\/(?:poll|activate)$/u.test(request.url ?? '') + || /\/browser$/u.test(request.url ?? '')); + const pairingStarts = bootstrap.filter(request => request.url === '/api/desktop/pairings'); + const pairingBrowsers = bootstrap.filter(request => /\/browser$/u.test(request.url ?? '')); + const pairingPolls = bootstrap.filter(request => /\/poll$/u.test(request.url ?? '')); + const pairingActivations = bootstrap.filter(request => /\/activate$/u.test(request.url ?? '')); + const authenticatedRest = applicationRequests.filter(request => + request.socketIo === false + && request.url === '/api/auth/user' + && request.authorization === `Bearer ${journeyFixture.secrets[2]}`); + const authenticatedSockets = applicationRequests.filter(request => + request.socketIo === true + && request.authorization === `Bearer ${journeyFixture.secrets[2]}` + && request.transportScope !== null + && request.socketAuthScope === request.transportScope); + const socketScopes = new Set(authenticatedSockets.map(request => + new URL(request.url, 'http://fixture.invalid').searchParams.get(DESKTOP_TRANSPORT_SCOPE_QUERY))); + const restScopes = new Set(authenticatedRest.map(request => request.transportScope)); + const firstBearer = applicationRequests.findIndex(request => request.authorization !== null); + const firstIdentity = applicationRequests.findIndex(request => request.url === '/api/desktop/discovery'); + const plaintextPersisted = await directoryContainsPlaintext(userDataPath, journeyFixture.secrets); + if (discoveries.length !== 8 + || discoveries.some(request => request.authorization !== null) + || pairingStarts.length !== 3 + || pairingBrowsers.length !== 3 + || pairingPolls.length !== 1 + || pairingActivations.length !== 1 + || bootstrap.some(request => request.authorization !== null) + || authenticatedRest.length < 2 + || authenticatedSockets.length < 2 + || restScopes.size !== 1 + || !restScopes.has(null) + || socketScopes.has(null) + || socketScopes.size < 2 + || plaintextPersisted + || firstIdentity < 0 + || firstBearer <= firstIdentity) { + outcome = { ok: false, category: 'journey-evidence', capture: 'complete', records: [] }; + } + } + } } catch { outcome = { ok: false, category: failurePhase, capture: 'complete', records: [] }; } finally { let cleanup = { ok: true }; + if (journeyFixture) { + try { await journeyFixture.close(); } + catch { cleanup = { ok: false, category: 'fixture-cleanup-failed' }; } + } if (fixture && canonicalTemp && generatedFixtureLeaf) { - cleanup = await removeAuthorizedConnectFixture({ + const directoryCleanup = await removeAuthorizedConnectFixture({ fixture, canonicalTemporaryParent: canonicalTemp, generatedLeaf: generatedFixtureLeaf, }); + if (!directoryCleanup.ok) cleanup = directoryCleanup; } if (!cleanup.ok) { outcome = preservePrimaryWithCleanup(outcome, cleanup); diff --git a/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs index 862c7fa79..9baa63f5b 100644 --- a/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs +++ b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs @@ -1262,7 +1262,7 @@ test('the workflow stages before alternate credentials and the harness preflight ); const preflight = harness.indexOf('const staged = await validateWindowsStagedPackage'); - const spawn = harness.indexOf("const child = spawn(binaryPath, ['--disable-gpu'"); + const spawn = harness.indexOf('const child = spawnPackagedConnectBinary'); assert.ok(preflight >= 0 && preflight < spawn, 'ordinary-user package preflight must complete before spawn'); assert.equal((harness.match(/await validateWindowsStagedPackage\(/gu) ?? []).length, 1); assert.equal((harness.match(/await runPackagedConnectLifecycle\(/gu) ?? []).length, 1); diff --git a/apps/desktop/src/credential-service.pairing-browser.test.ts b/apps/desktop/src/credential-service.pairing-browser.test.ts index 1177d5915..e732d32cc 100644 --- a/apps/desktop/src/credential-service.pairing-browser.test.ts +++ b/apps/desktop/src/credential-service.pairing-browser.test.ts @@ -3,7 +3,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; -import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared'; +import { + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; import { openApprovedDesktopPairingUrl } from './pairing-browser'; import { ProfileStore, type EncryptionProvider } from './profile-store'; @@ -12,6 +16,7 @@ const pairingId = `dpr_${'A'.repeat(22)}`; const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); const origin = 'https://api.example.test'; const approvalUrl = `${origin}/api/desktop/pairings/${pairingId}/browser`; +const instanceToken = `propr_it_${'T'.repeat(43)}`; const temporaryDirectories: string[] = []; const services: DesktopCredentialService[] = []; @@ -26,8 +31,30 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string status, headers: { 'Content-Type': 'application/json' }, }); +const discovery = { + schemaVersion: 1 as const, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2 as const, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; + +interface PairingProofOptions { + beforeProvisional?(): void; + onRequest?(request: { url: string; authorization: string | null }): void; +} + const createService = async ( openPairingBrowser: (request: DesktopPairingBrowserRequest) => Promise, + proof: PairingProofOptions = {}, ): Promise => { const directory = await mkdtemp(join(tmpdir(), 'propr-pairing-sink-')); temporaryDirectories.push(directory); @@ -39,21 +66,11 @@ const createService = async ( openPairingBrowser, fetch: async (input, init) => { const url = input.toString(); - if (url === `${origin}/api/desktop/discovery`) return json({ - schemaVersion: 1, - product: 'ProPR', - version: '0.8.15', - apiCompatibility: PROPR_API_COMPATIBILITY, - uiCompatibility: PROPR_UI_COMPATIBILITY, - canonicalEndpoint: null, - publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', - desktopAuthentication: { - protocolVersion: 2, - browserPairing: true, - instanceBearerTokens: true, - socketIoBearerAuthentication: true, - }, + proof.onRequest?.({ + url, + authorization: new Headers(init?.headers).get('Authorization'), }); + if (url === `${origin}/api/desktop/discovery`) return json(discovery); if (url === `${origin}/api/desktop/pairings`) { const request = JSON.parse(String(init?.body)) as Record; binding = { @@ -67,15 +84,22 @@ const createService = async ( expiresAt: new Date(pairingNow + 10_000).toISOString(), interval: 1, }, 201); } - if (url.endsWith('/poll')) return json({ - status: 'provisional', token: `propr_it_${'T'.repeat(43)}`, tokenType: 'Bearer', - activationTicket: 'K'.repeat(43), - activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding, - }); + if (url.endsWith('/poll')) { + proof.beforeProvisional?.(); + return json({ + status: 'provisional', token: instanceToken, tokenType: 'Bearer', + activationTicket: 'K'.repeat(43), + activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding, + }); + } if (url.endsWith('/activate')) return json({ status: 'active', receipt: 'R'.repeat(22), activatedAt: '2026-01-01T00:00:01.000Z', expiresAt: null, }); + if (url === `${origin}/api/auth/user`) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${instanceToken}`); + return json({ username: 'remote-owner' }); + } throw new Error('Unexpected pairing request'); }, }); @@ -89,27 +113,62 @@ afterEach(async () => { }); describe('DesktopCredentialService pairing browser sink', () => { - it('binds the API base, pairing id, and response URL through the final shell validator', async () => { + it('pairs through the browser journey and rejects a response URL replacement', async () => { const opened: string[] = []; + const requests: Array<{ url: string; authorization: string | null }> = []; + let browserApproved = false; const service = await createService(request => openApprovedDesktopPairingUrl(request, { - openExternal: async url => { opened.push(url); }, - })); + openExternal: async url => { + opened.push(url); + // Models the explicit approval click in the independently authenticated + // system browser. The polling fixture refuses to issue a provisional + // credential until this manual browser step has completed. + browserApproved = true; + }, + }), { + beforeProvisional: () => assert.equal(browserApproved, true), + onRequest: request => requests.push(request), + }); + + const profile = { id: 'profile-a', label: 'Remote ProPR', apiBaseUrl: origin }; + const initialProbe = await service.probe(profile); + assert.equal(initialProbe.status, 'authentication-required'); + const paired = await service.pair(profile); + const probed = await service.probe(profile); + assert.equal(probed.status, 'ready'); + if (probed.status !== 'ready') return; + const activated = await service.activate(probed.activationTicket); - assert.deepEqual(await service.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), { paired: true }); + assert.deepEqual(paired, { paired: true }); assert.deepEqual(opened, [approvalUrl]); - }); + assert.deepEqual(requests.map(request => request.url), [ + `${origin}/api/desktop/discovery`, + `${origin}/api/desktop/discovery`, + `${origin}/api/desktop/pairings`, + `${origin}/api/desktop/pairings/${pairingId}/poll`, + `${origin}/api/desktop/pairings/${pairingId}/activate`, + `${origin}/api/desktop/discovery`, + `${origin}/api/auth/user`, + ]); + assert.deepEqual(requests.map(request => request.authorization), [ + null, null, null, null, null, null, `Bearer ${instanceToken}`, + ]); + assert.deepEqual(service.prepareRequest( + `${origin}/api/tasks`, + { [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope }, + ).requestHeaders, { Authorization: `Bearer ${instanceToken}` }); + assert.equal(JSON.stringify([initialProbe, paired, probed, activated, opened]).includes(instanceToken), false); - it('rejects a URL replaced after the credential service receives the API response', async () => { - const opened: string[] = []; - const service = await createService(request => openApprovedDesktopPairingUrl({ + const replacedOpened: string[] = []; + const replacedService = await createService(request => openApprovedDesktopPairingUrl({ ...request, approvalUrl: `${origin}/api/desktop/pairings/dpr_${'B'.repeat(22)}/browser`, - }, { openExternal: async url => { opened.push(url); } })); + }, { openExternal: async url => { replacedOpened.push(url); } })); await assert.rejects( - service.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), + replacedService.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), /Desktop pairing browser request was rejected/, ); - assert.deepEqual(opened, []); + assert.deepEqual(replacedOpened, []); }); }); diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts index 1d552d944..39b1b00e3 100644 --- a/apps/desktop/src/credential-service.test.ts +++ b/apps/desktop/src/credential-service.test.ts @@ -193,7 +193,7 @@ describe('main-process desktop credential service', () => { assert.equal(await store.readCredential(profile.id), null); }); - it('durably rejects malformed relaunch discovery without sending the stored bearer', async () => { + it('fails malformed identity closed and classifies legacy public-discovery 401 safely', async () => { const store = await createStore(); const profile = await store.save({ id: 'profile-malformed', label: 'A', apiBaseUrl: 'https://a.example.test' }); await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); @@ -215,6 +215,71 @@ describe('main-process desktop credential service', () => { assert.equal(result.status, 'authentication-required'); assert.equal(authorizations.some(Boolean), false); assert.equal(await store.readCredential(profile.id), null); + + const legacyStore = await createStore(); + const requests: Array<{ url: string; authorization: string | null }> = []; + const legacyService = new DesktopCredentialService({ + profiles: legacyStore, + clientName: 'Legacy remote test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json({ error: 'Unauthorized' }, 401); + }, + }); + credentialServices.push(legacyService); + + const legacyResult = await legacyService.probe({ + id: 'legacy-remote', + label: 'Legacy remote', + apiBaseUrl: 'https://legacy.example.test', + }); + + assert.deepEqual(legacyResult, { + status: 'incompatible', + message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', + }); + assert.deepEqual(requests, [{ + url: 'https://legacy.example.test/api/desktop/discovery', + authorization: null, + }]); + assert.doesNotMatch(JSON.stringify(legacyResult), /Unauthorized|AUTHENTICATION_REQUIRED/); + + const rejectedLegacyBodies = [ + '{"error":"Unauthorized","policy":"private policy detail"}', + '{"error":"Unauthorized","error":"Unauthorized"}', + '{"code":"AUTHENTICATION_REQUIRED"}', + ]; + for (const [index, body] of rejectedLegacyBodies.entries()) { + const adversarialStore = await createStore(); + let adversarialRequests = 0; + const adversarialService = new DesktopCredentialService({ + profiles: adversarialStore, + clientName: 'Adversarial legacy remote test', + openPairingBrowser: async () => undefined, + fetch: async (_input, init) => { + adversarialRequests += 1; + assert.equal(new Headers(init?.headers).get('Authorization'), null); + return new Response(body, { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + credentialServices.push(adversarialService); + + const rejected = await adversarialService.probe({ + id: `rejected-legacy-${index}`, + label: 'Rejected legacy remote', + apiBaseUrl: `https://rejected-${index}.example.test`, + }); + + assert.equal(rejected.status, 'authentication-required'); + assert.equal(adversarialRequests, 1); + assert.doesNotMatch(JSON.stringify(rejected), /private policy detail|Unauthorized|AUTHENTICATION_REQUIRED/); + } }); it('revalidates an old Socket.IO reconnect and sends zero bearer requests after identity rotation', async () => { diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts index 3065173c0..9e6aa97d2 100644 --- a/apps/desktop/src/credential-service.ts +++ b/apps/desktop/src/credential-service.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, ProprClient, ProprClientError, type PairingProtocolRequestOptions, @@ -723,6 +724,17 @@ export class DesktopCredentialService { try { discovery = await discoveryClient.discoverDesktop(8_000, operation.signal); } catch (error) { + // Only the client's typed signal for the exact credential-free public + // discovery request is actionable here. Generic HTTP 401s, malformed + // identity, redirects, and authenticated operation failures stay strict. + if (error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED) { + return { + status: 'incompatible', + message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', + }; + } if (error instanceof ProprClientError && error.kind === 'invalid_response') { try { const current = await this.#profiles.readProfileCredential(input.id); @@ -1039,10 +1051,19 @@ export class DesktopCredentialService { } } + /** Whether main still owns the complete binding required by renderer transport and LNA. */ + hasActiveRendererBinding(): boolean { + const active = this.#active; + return active !== null + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); + } + prepareRequest( url: string, originalHeaders: RequestHeaders, - details: { method?: string; resourceType?: string } = {}, + details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {}, verifiedSocketCredential?: ActiveCredential, ): DesktopRequestDecision { if (this.#closed) return { cancel: true }; @@ -1085,16 +1106,31 @@ export class DesktopCredentialService { && this.#selectionGeneration === active.selectionGeneration && active.connectClaim.isCurrent(); const isApiRequest = target?.pathname.startsWith('/api/') === true; + const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? []; + const isSocketCandidate = target?.pathname === '/socket.io/' || socketScopeValues.length > 0; const isSocketUpgrade = target?.pathname === '/socket.io/' && target.url.searchParams.get('transport') === 'websocket' && (details.resourceType === 'webSocket' || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')); + // Session security supplies this ownership bit at the actual WebContents + // boundary. A foreign renderer may load only unmarked credentialless + // resources; it can never exercise a REST/Socket scope or receive a bearer. + if (details.rendererOwned === false) { + if (markedRestRequest || isSocketCandidate) return { cancel: true }; + return { requestHeaders: headers }; + } + + // Chromium can cache Local Network Access after activation is discarded. + // The live main renderer must therefore remain pinned to the exact current + // origin even for sanitized traffic that does not carry a transport scope. + if (details.rendererOwned === true && target + && (!activeIsCurrent || target.origin !== active.origin)) return { cancel: true }; + if (isSocketUpgrade && target) { - const queryScopes = target.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); - if (queryScopes.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(queryScopes[0]) + if (socketScopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0]) || !activeIsCurrent || active !== verifiedSocketCredential || target.origin !== active.origin - || queryScopes[0] !== active.transportScope) return { cancel: true }; + || socketScopeValues[0] !== active.transportScope) return { cancel: true }; headers.Authorization = `Bearer ${active.token}`; return { requestHeaders: headers }; } @@ -1111,14 +1147,16 @@ export class DesktopCredentialService { async prepareRequestAsync( url: string, originalHeaders: RequestHeaders, - details: { method?: string; resourceType?: string } = {}, + details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {}, ): Promise { const target = requestOrigin(url); const isSocketUpgrade = target?.pathname === '/socket.io/' && target.url.searchParams.get('transport') === 'websocket' && (details.resourceType === 'webSocket' || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')); - if (!isSocketUpgrade) return this.prepareRequest(url, originalHeaders, details); + if (!isSocketUpgrade || details.rendererOwned === false) { + return this.prepareRequest(url, originalHeaders, details); + } const active = this.#active; if (!active || target.origin !== active.origin) return this.prepareRequest(url, originalHeaders, details); try { diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index d16e5a090..e2cda64ff 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -7,6 +7,16 @@ import type { LocalLifecycleController } from './lifecycle'; import type { ProfileStore } from './profile-store'; import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; import { IPC_CHANNELS } from './shared/contract'; +import type { DesktopAcceptanceJourneyStage } from './shared/contract'; + +export type DesktopAcceptanceOperation = 'PROFILE_SAVE' | 'PAIR' | 'PROBE' | 'ACTIVATE'; +export type DesktopAcceptanceOperationStatus = + | 'COMPLETED' + | 'READY' + | 'AUTHENTICATION_REQUIRED' + | 'INCOMPATIBLE' + | 'OFFLINE' + | 'REJECTED'; interface RegisterIpcOptions { app: App; @@ -22,6 +32,13 @@ interface RegisterIpcOptions { openExternal(url: string): Promise; /** @internal Deterministic admitted-work accounting for lifecycle proof. */ observeInvocation?(phase: 'entry' | 'exit', channel: string): void; + /** @internal Fixed, secret-free packaged Connect acceptance evidence. */ + reportAcceptanceJourneyStage?(stage: DesktopAcceptanceJourneyStage): void; + /** @internal Fixed, secret-free packaged Connect operation evidence. */ + reportAcceptanceOperation?( + operation: DesktopAcceptanceOperation, + status: DesktopAcceptanceOperationStatus, + ): void; } type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown; @@ -33,6 +50,32 @@ export interface RegisteredIpcHandlers { } const closingError = (): Error => new Error('DESKTOP_CLOSING'); +const acceptanceStages = new Set([ + 'AUTHENTICATION_REQUIRED', + 'CREDENTIAL_COMMITTED', + 'AUTHENTICATED_REPROBE_READY', + 'ACTIVATION_COMMITTED', + 'ACTIVATION_PUBLISHED', + 'REACT_CONNECTED', +]); +const acceptanceOperations = new Map([ + [IPC_CHANNELS.profilesSave, 'PROFILE_SAVE'], + [IPC_CHANNELS.authenticationPair, 'PAIR'], + [IPC_CHANNELS.connectionProbe, 'PROBE'], + [IPC_CHANNELS.connectionActivate, 'ACTIVATE'], +]); + +const acceptanceStatus = (result: unknown): DesktopAcceptanceOperationStatus => { + if (!result || typeof result !== 'object' || Array.isArray(result) || !('status' in result)) { + return 'COMPLETED'; + } + const status = (result as { status?: unknown }).status; + if (status === 'ready') return 'READY'; + if (status === 'authentication-required') return 'AUTHENTICATION_REQUIRED'; + if (status === 'incompatible') return 'INCOMPATIBLE'; + if (status === 'offline') return 'OFFLINE'; + return 'COMPLETED'; +}; export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcHandlers => { const channels = new Set(); @@ -54,8 +97,13 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH const invocation = Promise.resolve().then(() => handler(event, ...args)); active.add(invocation); try { - return await invocation; + const result = await invocation; + const operation = acceptanceOperations.get(channel); + if (operation) options.reportAcceptanceOperation?.(operation, acceptanceStatus(result)); + return result; } catch (error) { + const operation = acceptanceOperations.get(channel); + if (operation) options.reportAcceptanceOperation?.(operation, 'REJECTED'); options.logger.log('error', 'desktop.ipc.failed', { channel, code: 'IPC_OPERATION_FAILED' }); throw new Error('Desktop operation failed [IPC_OPERATION_FAILED]'); } finally { @@ -136,6 +184,12 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH if (args.length) throw new Error('Invalid Connect rediscovery request'); return options.connectDiscovery.rediscover(profileId); }); + if (options.reportAcceptanceJourneyStage) { + handle(IPC_CHANNELS.acceptanceJourneyStage, (_event, stage, ...args) => { + if (args.length || !acceptanceStages.has(stage)) throw new Error('Invalid acceptance journey stage'); + options.reportAcceptanceJourneyStage!(stage); + }); + } handle(IPC_CHANNELS.lifecycleStatus, () => options.lifecycle.status()); handle(IPC_CHANNELS.lifecycleStart, () => options.lifecycle.start()); handle(IPC_CHANNELS.lifecycleStop, () => options.lifecycle.stop()); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7dd2aa65d..8ce0d7a82 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,12 +17,21 @@ import { import { DesktopConnectDiscoveryService } from './connect-discovery'; import { DeepLinkDelivery } from './deep-link-delivery'; import { clearDesktopInstanceCookies } from './desktop-session'; -import { DesktopCredentialService } from './credential-service'; -import { registerIpcHandlers } from './ipc'; +import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; +import { + registerIpcHandlers, + type DesktopAcceptanceOperation, + type DesktopAcceptanceOperationStatus, +} from './ipc'; import { LocalLifecycleController } from './lifecycle'; import { createDesktopLogger, type DesktopLogger } from './logger'; import { ProfileStore, type EncryptionProvider } from './profile-store'; import { openApprovedDesktopPairingUrl } from './pairing-browser'; +import { + clearPackagedApprovalStorage, + createPackagedApprovalNavigation, + packagedApprovalPartition, +} from './packaged-approval-session'; import { createDesktopShutdownCoordinator } from './shutdown'; import { deepLinkFromArguments, @@ -33,10 +42,19 @@ import { rendererContentSecurityPolicy, validatedDevServerUrl, } from './security'; -import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; +import { + DESKTOP_PROTOCOL, + IPC_CHANNELS, + type DesktopAcceptanceJourneyStage, +} from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { + configureDesktopSessionSecurity, + type DesktopNetworkPermissionEvidence, + type DesktopRendererOwnershipEvidence, +} from './session-security'; import { createBrowserWindowOptions, MINIMUM_BROWSER_WINDOW_SIZE, @@ -50,6 +68,49 @@ const PACKAGED_RENDERER_SCHEME = 'propr-app'; const PACKAGED_RENDERER_HOST = 'renderer'; const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready'; +const PACKAGED_CONNECT_DISCOVERY_MILESTONE_EVENT = 'desktop.renderer.connect_discovery.milestone'; +const PACKAGED_CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.stage'; +const PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure'; +const PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation'; +const PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership'; +type PackagedConnectJourneyStage = + | 'JOURNEY_DISCOVERY_RENDERER' + | 'JOURNEY_DISCOVERY_VALIDATED' + | 'JOURNEY_STORAGE_BACKEND' + | 'JOURNEY_NEGATIVE_MALFORMED' + | 'JOURNEY_NEGATIVE_OVERSIZED' + | 'JOURNEY_NEGATIVE_EXPIRY' + | 'JOURNEY_NEGATIVE_CANCEL' + | 'JOURNEY_NEGATIVE_STATE' + | 'JOURNEY_PAIR_MANUAL_FORM' + | 'JOURNEY_PAIR_BROWSER_APPROVAL' + | 'JOURNEY_PAIR_ACTIVATION_DASHBOARD' + | 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED' + | 'JOURNEY_PAIR_CREDENTIAL_COMMITTED' + | 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY' + | 'JOURNEY_PAIR_ACTIVATION_COMMITTED' + | 'JOURNEY_PAIR_ACTIVATION_PUBLISHED' + | 'JOURNEY_PAIR_REACT_CONNECTED' + | 'JOURNEY_PAIR_TRANSPORT' + | 'JOURNEY_PAIR_COMPLETE' + | 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD' + | 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY' + | 'JOURNEY_REPROBE_ACTIVATION_COMMITTED' + | 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED' + | 'JOURNEY_REPROBE_REACT_CONNECTED' + | 'JOURNEY_REPROBE_TRANSPORT' + | 'JOURNEY_REPROBE_COMPLETE'; +type PackagedConnectJourneyFailureReason = + | 'APPROVAL_REJECTED' + | 'JOURNEY_FAILED' + | 'RENDERER_STAGE_TIMEOUT' + | 'RENDERER_STATE_TIMEOUT' + | 'TRANSPORT_EVIDENCE_TIMEOUT'; +interface PackagedConnectJourneyDiagnosticState { + phase: 'pair' | 'reprobe'; + stage: PackagedConnectJourneyStage | 'JOURNEY_NOT_STARTED'; +} +let packagedConnectJourneyDiagnosticState: PackagedConnectJourneyDiagnosticState | null = null; const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; let packagedSmokeUserDataDirectory: string | null = null; @@ -93,10 +154,13 @@ interface PackagedTransportSmoke { shutdownMode: 'success' | 'retry' | 'forced-timeout'; } let activePackagedTransportSmoke: PackagedTransportSmoke | null = null; +let activePackagedConnectJourney = false; interface PackagedConnectSmoke { configRoot: string; fetch: typeof globalThis.fetch; + journeyEndpoint?: string; + journeyPhase?: 'pair' | 'reprobe'; } const packagedConnectSmoke = (): PackagedConnectSmoke | null => { @@ -109,6 +173,21 @@ const packagedConnectSmoke = (): PackagedConnectSmoke | null => { if (!contained || contained.startsWith('..') || isAbsolute(contained)) { throw new Error('Packaged Connect smoke config root is outside the temporary directory'); } + const suppliedJourneyEndpoint = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT; + const suppliedJourneyPhase = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE; + let journeyEndpoint: string | undefined; + let journeyPhase: 'pair' | 'reprobe' | undefined; + if (suppliedJourneyEndpoint !== undefined || suppliedJourneyPhase !== undefined) { + const normalized = normalizeApiBaseUrl(suppliedJourneyEndpoint ?? ''); + if (!normalized) throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture'); + const parsed = new URL(normalized); + if (process.platform === 'win32' || parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' + || (suppliedJourneyPhase !== 'pair' && suppliedJourneyPhase !== 'reprobe')) { + throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture'); + } + journeyEndpoint = normalized; + journeyPhase = suppliedJourneyPhase; + } const endpoint = 'https://t-packaged123.propr.dev'; const publicInstanceIdentity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const fetch: typeof globalThis.fetch = async input => { @@ -131,7 +210,7 @@ const packagedConnectSmoke = (): PackagedConnectSmoke | null => { }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; - return { configRoot, fetch }; + return { configRoot, fetch, journeyEndpoint, journeyPhase }; }; const packagedTransportSmoke = (): PackagedTransportSmoke | null => { @@ -169,6 +248,80 @@ const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: } }; +const reportPackagedConnectJourneyStage = (code: PackagedConnectJourneyStage): void => { + if (packagedConnectJourneyDiagnosticState) packagedConnectJourneyDiagnosticState.stage = code; + log('info', PACKAGED_CONNECT_JOURNEY_STAGE_EVENT, { code }); +}; + +const packagedConnectJourneyFailureReason = (error: unknown): PackagedConnectJourneyFailureReason => { + if (!(error instanceof Error)) return 'JOURNEY_FAILED'; + if (error.message === 'Packaged pairing browser approval was rejected') return 'APPROVAL_REJECTED'; + if (error.message === 'Packaged Connect journey renderer stage timed out') { + return 'RENDERER_STAGE_TIMEOUT'; + } + if (error.message === 'Packaged Connect journey renderer state timed out') { + return 'RENDERER_STATE_TIMEOUT'; + } + if (error.message === 'Packaged Connect authenticated transport proof timed out') { + return 'TRANSPORT_EVIDENCE_TIMEOUT'; + } + return 'JOURNEY_FAILED'; +}; + +interface PackagedJourneyStageTracker { + record(stage: DesktopAcceptanceJourneyStage): void; + waitFor(stage: DesktopAcceptanceJourneyStage): Promise; +} + +const createPackagedJourneyStageTracker = ( + phase: 'pair' | 'reprobe', +): PackagedJourneyStageTracker => { + const seen = new Set(); + const waiters = new Map void>>(); + const stageCodes: Partial> = phase === 'pair' + ? { + AUTHENTICATION_REQUIRED: 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED', + CREDENTIAL_COMMITTED: 'JOURNEY_PAIR_CREDENTIAL_COMMITTED', + AUTHENTICATED_REPROBE_READY: 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY', + ACTIVATION_COMMITTED: 'JOURNEY_PAIR_ACTIVATION_COMMITTED', + ACTIVATION_PUBLISHED: 'JOURNEY_PAIR_ACTIVATION_PUBLISHED', + REACT_CONNECTED: 'JOURNEY_PAIR_REACT_CONNECTED', + } + : { + AUTHENTICATED_REPROBE_READY: 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY', + ACTIVATION_COMMITTED: 'JOURNEY_REPROBE_ACTIVATION_COMMITTED', + ACTIVATION_PUBLISHED: 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED', + REACT_CONNECTED: 'JOURNEY_REPROBE_REACT_CONNECTED', + }; + return { + record(stage) { + const code = stageCodes[stage]; + if (!code) throw new Error('Packaged Connect journey reported an invalid phase stage'); + if (seen.has(stage)) return; + seen.add(stage); + reportPackagedConnectJourneyStage(code); + for (const resolveWaiter of waiters.get(stage) ?? []) resolveWaiter(); + waiters.delete(stage); + }, + waitFor(stage) { + if (seen.has(stage)) return Promise.resolve(); + return new Promise((resolveStage, rejectStage) => { + const timer = setTimeout(() => { + waiters.get(stage)?.delete(resolve); + rejectStage(new Error('Packaged Connect journey renderer stage timed out')); + }, 15_000); + const resolve = () => { + clearTimeout(timer); + resolveStage(); + }; + const current = waiters.get(stage) ?? new Set(); + current.add(resolve); + waiters.set(stage, current); + }); + }, + }; +}; + process.on('uncaughtExceptionMonitor', () => { log('error', 'desktop.main_process.uncaught_exception', { code: 'UNCAUGHT_EXCEPTION' }); }); @@ -195,41 +348,6 @@ const deliverDeepLink = (value: string): void => { deepLinkDelivery.deliver(value); }; -const configureSessionSecurity = (credentials: DesktopCredentialService): { - close(): void; - dispose(): void; -} => { - const desktopSession = session.defaultSession; - desktopSession.setPermissionCheckHandler(() => false); - desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); - desktopSession.webRequest.onBeforeSendHeaders((details, callback) => { - void credentials.prepareRequestAsync(details.url, details.requestHeaders, { - method: details.method, - resourceType: details.resourceType, - }).then(callback, () => callback({ cancel: true })); - }); - desktopSession.webRequest.onHeadersReceived((details, callback) => { - callback({ - responseHeaders: { - ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}), - 'Content-Security-Policy': [rendererContentSecurityPolicy(!app.isPackaged)], - }, - }); - }); - return { - close() { - desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true })); - desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true })); - }, - dispose() { - desktopSession.setPermissionCheckHandler(null); - desktopSession.setPermissionRequestHandler(null); - desktopSession.webRequest.onBeforeSendHeaders(null); - desktopSession.webRequest.onHeadersReceived(null); - }, - }; -}; - const configurePackagedRendererProtocol = (): (() => void) => { protocol.handle(PACKAGED_RENDERER_SCHEME, request => { const requestUrl = new URL(request.url); @@ -381,7 +499,12 @@ const inspectPackagedReducedNativeWindow = (): Record => { } }; -const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise => { +const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise<{ + selectedPlatform: string; + selectedArch: string; + authorityMechanism: string; + rendererSchemaValid: true; +}> => { const proof = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const metadata = await bridge.app.getMetadata(); @@ -412,7 +535,15 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< : 'inherited-standard-handle', rendererSchemaValid: true, } as const; - log('info', 'desktop.renderer.connect_discovery.ready', readyFields); + log('info', PACKAGED_CONNECT_DISCOVERY_MILESTONE_EVENT, { + code: 'JOURNEY_DISCOVERY_VALIDATED', + }); + return readyFields; +}; + +const publishPackagedConnectReady = async (readyFields: Awaited< + ReturnType +>): Promise => { await new Promise((resolveReady, rejectReady) => { process.stdout.write(`${JSON.stringify({ timestamp: new Date().toISOString(), @@ -426,6 +557,223 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< }); }; +const openPackagedJourneyApproval = async (request: DesktopPairingBrowserRequest): Promise => { + reportPackagedConnectJourneyStage('JOURNEY_PAIR_BROWSER_APPROVAL'); + await openApprovedDesktopPairingUrl(request, { + openExternal: async url => { + const approvalSession = session.fromPartition( + packagedApprovalPartition(randomBytes(16).toString('hex')), + { cache: false }, + ); + let approvalWindow: BrowserWindow | null = null; + let navigation: ReturnType | null = null; + try { + approvalWindow = new BrowserWindow({ + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + session: approvalSession, + webSecurity: true, + }, + }); + navigation = createPackagedApprovalNavigation({ + approvalUrl: url, + approvalSession, + approvalWindow, + defaultSession: session.defaultSession, + }); + await navigation.navigate(); + } finally { + if (navigation) { + await navigation.cleanup(); + } else { + if (approvalWindow && !approvalWindow.isDestroyed()) approvalWindow.destroy(); + await clearPackagedApprovalStorage(approvalSession); + } + } + }, + }); +}; + +const runPackagedConnectJourneySmoke = async ( + window: BrowserWindow, + profiles: ProfileStore, + credentials: DesktopCredentialService, + endpoint: string, + phase: 'pair' | 'reprobe', + stages: PackagedJourneyStageTracker, +): Promise => { + reportPackagedConnectJourneyStage('JOURNEY_STORAGE_BACKEND'); + const security = profiles.security(); + const requiredStorageBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!security.available || security.backend !== requiredStorageBackend) { + throw new Error('Packaged Connect journey requires the production OS credential backend'); + } + if (phase === 'pair') { + const setMode = async (mode: 'success' | 'malformed' | 'oversized' | 'expiry' | 'cancel') => { + const response = await session.defaultSession.fetch(`${endpoint}/__packaged/control/${mode}`, { + method: 'POST', redirect: 'manual', + }); + if (response.status !== 204) throw new Error('Packaged Connect fixture control failed'); + }; + for (const mode of ['malformed', 'oversized'] as const) { + reportPackagedConnectJourneyStage(mode === 'malformed' + ? 'JOURNEY_NEGATIVE_MALFORMED' + : 'JOURNEY_NEGATIVE_OVERSIZED'); + await setMode(mode); + const result = await credentials.probe({ + id: `negative-${mode}`, + label: `Packaged ${mode}`, + apiBaseUrl: endpoint, + }); + if (result.status === 'ready' || result.status === 'incompatible') { + throw new Error('Strict packaged discovery accepted invalid identity'); + } + } + reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_EXPIRY'); + await setMode('expiry'); + await credentials.pair({ + id: 'negative-expiry', label: 'Packaged expiry', apiBaseUrl: endpoint, + }).then( + () => { throw new Error('Packaged pairing expiry unexpectedly succeeded'); }, + error => { + if (!(error instanceof Error) || !/expired/i.test(error.message)) { + throw new Error('Packaged pairing expiry classification failed'); + } + }, + ); + reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_CANCEL'); + await setMode('cancel'); + const cancelledPairing = credentials.pair({ + id: 'negative-cancel', label: 'Packaged cancel', apiBaseUrl: endpoint, + }); + await new Promise(resolve => setTimeout(resolve, 50)); + credentials.cancelPairing('negative-cancel'); + await cancelledPairing.then( + () => { throw new Error('Packaged pairing cancellation unexpectedly succeeded'); }, + error => { + if (!(error instanceof Error) || !/cancelled/i.test(error.message)) { + throw new Error('Packaged pairing cancellation classification failed'); + } + }, + ); + reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_STATE'); + const failedProfiles = await profiles.list(); + if (failedProfiles.profiles.some(profile => profile.id.startsWith('negative-'))) { + throw new Error('Failed packaged pairing left stale profile or credential state'); + } + await setMode('success'); + } + reportPackagedConnectJourneyStage(phase === 'pair' + ? 'JOURNEY_PAIR_MANUAL_FORM' + : 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD'); + if (phase === 'pair') { + const submitted = await window.webContents.executeJavaScript(`(async () => { + const waitFor = async predicate => { + const deadline = performance.now() + 15000; + do { + const value = predicate(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + throw new Error('Packaged Connect journey renderer state timed out'); + }; + const setInput = (input, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + const chooser = await waitFor(() => document.querySelector('.desktop-welcome-card')); + const connect = Array.from(chooser.querySelectorAll('button.desktop-choice-button')) + .find(button => button.textContent?.includes('Connect to an existing instance')); + if (!(connect instanceof HTMLButtonElement)) return false; + connect.click(); + const form = await waitFor(() => document.querySelector('form.desktop-profile-form')); + const inputs = form.querySelectorAll('input'); + if (inputs.length !== 2) return false; + setInput(inputs[0], 'Packaged remote'); + setInput(inputs[1], ${JSON.stringify(endpoint)}); + form.requestSubmit(); + await waitFor(() => Array.from(document.querySelectorAll('.desktop-connection-card button')) + .find(button => button.textContent?.includes('Sign in in browser'))); + return true; + })()`); + if (submitted !== true) throw new Error('Packaged Connect manual profile submission failed'); + await stages.waitFor('AUTHENTICATION_REQUIRED'); + const clicked = await window.webContents.executeJavaScript(`(() => { + const authenticate = Array.from(document.querySelectorAll('.desktop-connection-card button')) + .find(button => button.textContent?.includes('Sign in in browser')); + if (!(authenticate instanceof HTMLButtonElement)) return false; + authenticate.click(); + return true; + })()`); + if (clicked !== true) throw new Error('Packaged Connect authentication action was missing'); + await stages.waitFor('CREDENTIAL_COMMITTED'); + } + await stages.waitFor('AUTHENTICATED_REPROBE_READY'); + await stages.waitFor('ACTIVATION_COMMITTED'); + await stages.waitFor('ACTIVATION_PUBLISHED'); + await stages.waitFor('REACT_CONNECTED'); + const proof = await window.webContents.executeJavaScript(`(async () => { + const waitFor = async predicate => { + const deadline = performance.now() + 15000; + do { + const value = predicate(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + throw new Error('Packaged Connect journey renderer state timed out'); + }; + const dashboard = await waitFor(() => document.querySelector('.desktop-app')); + const connection = await waitFor(() => document.querySelector('.desktop-connection-pill.desktop-connection-ready')); + await waitFor(() => document.querySelector('.desktop-titlebar')); + return { + connected: dashboard instanceof HTMLElement && connection instanceof HTMLButtonElement, + rendererContractsContainSecret: JSON.stringify([window.proprDesktop, dashboard.dataset]).includes('propr_it_'), + title: connection.getAttribute('aria-label'), + }; + })()`); + if (proof?.connected !== true || proof?.rendererContractsContainSecret !== false + || !proof?.title?.startsWith('Connected: Packaged remote')) { + throw new Error('Packaged Connect dashboard did not reach its connected state'); + } + reportPackagedConnectJourneyStage(phase === 'pair' + ? 'JOURNEY_PAIR_TRANSPORT' + : 'JOURNEY_REPROBE_TRANSPORT'); + const requiredAuthenticatedRequests = phase === 'pair' ? 1 : 2; + const evidenceDeadline = Date.now() + 10_000; + let transportEvidence = { authenticatedRest: 0, authenticatedSockets: 0 }; + do { + const response = await session.defaultSession.fetch(`${endpoint}/__packaged/evidence`, { + credentials: 'omit', + redirect: 'manual', + }); + if (response.status !== 200) throw new Error('Packaged Connect transport evidence was unavailable'); + const candidate: unknown = await response.json(); + if (candidate !== null && typeof candidate === 'object') { + const record = candidate as Record; + if (Number.isInteger(record.authenticatedRest) && Number.isInteger(record.authenticatedSockets)) { + transportEvidence = { + authenticatedRest: record.authenticatedRest as number, + authenticatedSockets: record.authenticatedSockets as number, + }; + } + } + if (transportEvidence.authenticatedRest >= requiredAuthenticatedRequests + && transportEvidence.authenticatedSockets >= requiredAuthenticatedRequests) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (Date.now() < evidenceDeadline); + if (transportEvidence.authenticatedRest < requiredAuthenticatedRequests + || transportEvidence.authenticatedSockets < requiredAuthenticatedRequests) { + throw new Error('Packaged Connect authenticated transport proof timed out'); + } + reportPackagedConnectJourneyStage(phase === 'pair' + ? 'JOURNEY_PAIR_COMPLETE' + : 'JOURNEY_REPROBE_COMPLETE'); +}; + const runPackagedTransportSmoke = async ( window: BrowserWindow, profiles: ProfileStore, @@ -613,6 +961,7 @@ const runPackagedTransportSmoke = async ( const createMainWindow = async ( transportSmoke: PackagedTransportSmoke | null = activePackagedTransportSmoke, + connectJourney = activePackagedConnectJourney, ): Promise => { const workArea = selectInitialWindowWorkArea(screen); const window = new BrowserWindow( @@ -687,7 +1036,7 @@ const createMainWindow = async ( log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); } let mvpFlowProof: Record = { connectDiscovery: true }; - if (packagedSmokeTest && !transportSmoke) { + if (packagedSmokeTest && !transportSmoke && !connectJourney) { const profileFlow = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); @@ -739,7 +1088,7 @@ const createMainWindow = async ( throw new Error('Packaged desktop transport smoke did not preserve the MVP bridge boundaries'); } } - if (packagedSmokeTest) { + if (packagedSmokeTest && !connectJourney) { log('info', 'desktop.renderer.mvp_flows.ready', mvpFlowProof); log('info', PACKAGED_LAYOUT_READY_EVENT, { layout: await inspectPackagedLayout(window) }); log('info', PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT, { @@ -783,7 +1132,14 @@ if (!hasSingleInstanceLock) { const transportSmoke = packagedTransportSmoke(); activePackagedTransportSmoke = transportSmoke; const connectSmoke = packagedConnectSmoke(); + activePackagedConnectJourney = Boolean(connectSmoke?.journeyEndpoint); + packagedConnectJourneyDiagnosticState = connectSmoke?.journeyEndpoint && connectSmoke.journeyPhase + ? { phase: connectSmoke.journeyPhase, stage: 'JOURNEY_NOT_STARTED' } + : null; if (transportSmoke && connectSmoke) throw new Error('Packaged desktop smoke modes are mutually exclusive'); + const journeyStages = connectSmoke?.journeyPhase + ? createPackagedJourneyStageTracker(connectSmoke.journeyPhase) + : null; const productionEncryption: EncryptionProvider = { isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), @@ -834,7 +1190,9 @@ if (!hasSingleInstanceLock) { const credentials = new DesktopCredentialService({ profiles, fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch, - openPairingBrowser: request => openApprovedDesktopPairingUrl(request, shell), + openPairingBrowser: connectSmoke?.journeyEndpoint + ? openPackagedJourneyApproval + : request => openApprovedDesktopPairingUrl(request, shell), clientName: `ProPR Desktop (${process.platform})`, reportRevocationFailure: diagnostic => { log('warn', 'desktop.credential_revocation.retry_pending', diagnostic); @@ -842,7 +1200,22 @@ if (!hasSingleInstanceLock) { snapshotConnectIdentityClaim: (profileId, origin) => connectDiscovery.snapshotIdentityClaim(profileId, origin), }); - const sessionSecurity = configureSessionSecurity(credentials); + const sessionSecurity = configureDesktopSessionSecurity({ + contentSecurityPolicy: () => rendererContentSecurityPolicy(!app.isPackaged), + credentials, + desktopSession: session.defaultSession, + enableRendererNetworkBoundary: process.platform !== 'win32', + getMainRenderer: () => mainWindow?.webContents ?? null, + isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl), + ...(connectSmoke?.journeyEndpoint ? { + reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => { + log('info', 'desktop.renderer.connect_network_permission', { ...evidence }); + }, + reportRendererOwnershipDecision: (evidence: DesktopRendererOwnershipEvidence) => { + log('info', PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT, { ...evidence }); + }, + } : {}), + }); const credentialInitialization = await credentials.initialize(); if (credentialInitialization.status === 'degraded') { log('warn', 'desktop.credential_revocation.startup_degraded', { @@ -862,6 +1235,17 @@ if (!hasSingleInstanceLock) { devServerUrl, packagedRendererUrl, openExternal: openAllowedExternalUrl, + ...(journeyStages ? { + reportAcceptanceJourneyStage: (stage: DesktopAcceptanceJourneyStage) => { + journeyStages.record(stage); + }, + reportAcceptanceOperation: ( + operation: DesktopAcceptanceOperation, + status: DesktopAcceptanceOperationStatus, + ) => { + log('info', PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT, { operation, status }); + }, + } : {}), }); const shutdownLifecycle = transportSmoke?.shutdownMode === 'forced-timeout' ? { shutdown: () => new Promise(() => undefined) } @@ -883,7 +1267,21 @@ if (!hasSingleInstanceLock) { mainWindow = await createMainWindow(); if (connectSmoke) { - await runPackagedConnectDiscoverySmoke(mainWindow); + reportPackagedConnectJourneyStage('JOURNEY_DISCOVERY_RENDERER'); + const readyFields = await runPackagedConnectDiscoverySmoke(mainWindow); + if (connectSmoke.journeyEndpoint && connectSmoke.journeyPhase) { + if (!journeyStages) throw new Error('Packaged Connect journey stage tracker was unavailable'); + await runPackagedConnectJourneySmoke( + mainWindow, + profiles, + credentials, + connectSmoke.journeyEndpoint, + connectSmoke.journeyPhase, + journeyStages, + ); + } + await publishPackagedConnectReady(readyFields); + packagedConnectJourneyDiagnosticState = null; app.quit(); } else if (transportSmoke) { await runPackagedTransportSmoke(mainWindow, profiles, credentials, transportSmoke); @@ -930,6 +1328,13 @@ if (!hasSingleInstanceLock) { } }); }).catch(error => { + if (packagedConnectJourneyDiagnosticState) { + log('error', PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT, { + phase: packagedConnectJourneyDiagnosticState.phase, + stage: packagedConnectJourneyDiagnosticState.stage, + reason: packagedConnectJourneyFailureReason(error), + }); + } log('error', 'desktop.app.start_failed', { error }); app.exit(1); }); diff --git a/apps/desktop/src/packaged-approval-session.test.ts b/apps/desktop/src/packaged-approval-session.test.ts new file mode 100644 index 000000000..152420a51 --- /dev/null +++ b/apps/desktop/src/packaged-approval-session.test.ts @@ -0,0 +1,364 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; +import type { BrowserWindow, Session } from 'electron'; +import { + clearPackagedApprovalStorage, + createPackagedApprovalNavigation, + packagedApprovalPartition, +} from './packaged-approval-session'; + +const approvalUrl = `http://127.0.0.1:41731/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`; + +type Callback = (decision: T) => void; +type RequestHandler = (details: Record, callback: Callback>) => void; +type RedirectHandler = (details: Record) => void; +type CompletedHandler = (details: Record) => void; + +class FakeWebRequest { + beforeRequest: RequestHandler | null = null; + beforeSendHeaders: RequestHandler | null = null; + sendHeaders: RedirectHandler | null = null; + headersReceived: RequestHandler | null = null; + beforeRedirect: RedirectHandler | null = null; + completed: CompletedHandler | null = null; + + onBeforeRequest(handler: RequestHandler | null): void { this.beforeRequest = handler; } + onBeforeSendHeaders(handler: RequestHandler | null): void { this.beforeSendHeaders = handler; } + onSendHeaders(handler: RedirectHandler | null): void { this.sendHeaders = handler; } + onHeadersReceived(handler: RequestHandler | null): void { this.headersReceived = handler; } + onBeforeRedirect(handler: RedirectHandler | null): void { this.beforeRedirect = handler; } + onCompleted(handler: CompletedHandler | null): void { this.completed = handler; } +} + +class FakeSession extends EventEmitter { + readonly webRequest = new FakeWebRequest(); + permissionCheck: ((...values: unknown[]) => boolean) | null = null; + permissionRequest: ((...values: unknown[]) => void) | null = null; + clearCount = 0; + + setPermissionCheckHandler(handler: ((...values: unknown[]) => boolean) | null): void { + this.permissionCheck = handler; + } + + setPermissionRequestHandler(handler: ((...values: unknown[]) => void) | null): void { + this.permissionRequest = handler; + } + + async clearStorageData(): Promise { this.clearCount += 1; } +} + +class FakeContents extends EventEmitter { + readonly id = 91; + readonly mainFrame = { detached: false, parent: null }; + currentUrl = ''; + openHandler: (() => { action: 'deny' }) | null = null; + + constructor(readonly session: FakeSession) { super(); } + + setWindowOpenHandler(handler: () => { action: 'deny' }): void { this.openHandler = handler; } + getURL(): string { return this.currentUrl; } +} + +class FakeWindow { + readonly webContents: FakeContents; + destroyed = false; + destroyCount = 0; + load: (url: string) => Promise = async () => undefined; + + constructor(approvalSession: FakeSession) { + this.webContents = new FakeContents(approvalSession); + } + + loadURL(url: string): Promise { return this.load(url); } + isDestroyed(): boolean { return this.destroyed; } + destroy(): void { this.destroyed = true; this.destroyCount += 1; } +} + +const event = () => { + let prevented = false; + return { + preventDefault: () => { prevented = true; }, + get prevented() { return prevented; }, + }; +}; + +const decision = async ( + handler: RequestHandler | null, + details: Record, +): Promise> => { + assert.ok(handler); + return await new Promise(resolve => handler(details, resolve)); +}; + +interface Harness { + approvalSession: FakeSession; + defaultSession: FakeSession; + window: FakeWindow; + requestHeaders: Record; + responseHeaders: Record; +} + +const harness = (statusCode = 200): Harness => { + const approvalSession = new FakeSession(); + const defaultSession = new FakeSession(); + const window = new FakeWindow(approvalSession); + const requestHeaders: Record = { Accept: 'text/html' }; + const responseHeaders: Record = { + 'Content-Type': ['text/html'], + 'Set-Cookie': ['approval=secret'], + }; + window.load = async url => { + const details = { + id: 7, + url, + method: 'GET', + webContentsId: window.webContents.id, + webContents: window.webContents, + frame: window.webContents.mainFrame, + resourceType: 'mainFrame', + }; + const start = await decision(approvalSession.webRequest.beforeRequest, details); + if (start.cancel === true) throw new Error('cancelled'); + const outgoing = await decision(approvalSession.webRequest.beforeSendHeaders, { + ...details, + requestHeaders, + }); + if (outgoing.cancel === true) throw new Error('cancelled'); + Object.assign(requestHeaders, outgoing.requestHeaders); + approvalSession.webRequest.sendHeaders?.({ + ...details, + requestHeaders, + }); + const incoming = await decision(approvalSession.webRequest.headersReceived, { + ...details, + statusCode, + responseHeaders, + }); + if (incoming.cancel === true) throw new Error('cancelled'); + for (const name of Object.keys(responseHeaders)) delete responseHeaders[name]; + Object.assign(responseHeaders, incoming.responseHeaders); + window.webContents.currentUrl = url; + window.webContents.emit('did-frame-navigate', event(), url, statusCode, 'OK', true, 1, 1); + approvalSession.webRequest.completed?.({ ...details, statusCode }); + }; + return { approvalSession, defaultSession, window, requestHeaders, responseHeaders }; +}; + +const controllerFor = (value: Harness) => createPackagedApprovalNavigation({ + approvalUrl, + approvalSession: value.approvalSession as unknown as Session, + approvalWindow: value.window as unknown as BrowserWindow, + defaultSession: value.defaultSession as unknown as Session, +}); + +describe('packaged pairing approval isolated session', () => { + it('uses non-persistent unique partition names and rejects invalid entropy', () => { + const first = packagedApprovalPartition('a'.repeat(32)); + const second = packagedApprovalPartition('b'.repeat(32)); + assert.notEqual(first, second); + assert.equal(first.startsWith('persist:'), false); + assert.throws(() => packagedApprovalPartition('../shared')); + }); + + it('allows one exact credentialless main-frame GET and strips response cookies', async () => { + const value = harness(); + const controller = controllerFor(value); + assert.equal(value.approvalSession.permissionCheck?.(), false); + let permissionAllowed = true; + value.approvalSession.permissionRequest?.(null, 'notifications', (allowed: boolean) => { + permissionAllowed = allowed; + }); + assert.equal(permissionAllowed, false); + + await controller.navigate(); + + assert.equal(Object.keys(value.requestHeaders).some(name => /^(authorization|cookie)$/iu.test(name)), false); + assert.equal(Object.keys(value.responseHeaders).some(name => /^set-cookie2?$/iu.test(name)), false); + await controller.cleanup(); + }); + + it('waits for an exact completion event that arrives after loadURL resolves', async () => { + const value = harness(); + const controller = controllerFor(value); + const originalLoad = value.window.load; + let finishCompletion: (() => void) | undefined; + value.window.load = async url => { + const onCompleted = value.approvalSession.webRequest.completed; + assert.ok(onCompleted); + value.approvalSession.webRequest.completed = details => { + finishCompletion = () => onCompleted(details); + }; + await originalLoad(url); + }; + + const navigation = controller.navigate(); + let settled = false; + void navigation.finally(() => { settled = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(finishCompletion); + assert.equal(settled, false); + finishCompletion(); + await navigation; + assert.equal(settled, true); + await controller.cleanup(); + }); + + it('cancels an incidental resource without invalidating the exact main-frame approval', async () => { + const value = harness(); + const originalLoad = value.window.load; + value.window.load = async url => { + const incidental = await decision(value.approvalSession.webRequest.beforeRequest, { + id: 6, + url: 'http://127.0.0.1:41731/favicon.ico', + method: 'GET', + webContentsId: value.window.webContents.id, + webContents: value.window.webContents, + frame: value.window.webContents.mainFrame, + resourceType: 'image', + }); + assert.deepEqual(incidental, { cancel: true }); + await originalLoad(url); + }; + + const controller = controllerFor(value); + await controller.navigate(); + await controller.cleanup(); + }); + + it('rejects redirects, alternate origins and paths, methods, subframes, and credential headers', async t => { + for (const scenario of [ + 'redirect', + 'off-origin', + 'path', + 'method', + 'subframe', + 'status', + 'authorization', + 'cookie', + ] as const) { + await t.test(scenario, async () => { + const value = harness(scenario === 'status' ? 204 : 200); + const original = value.window.load; + if (scenario === 'redirect') { + value.window.load = async url => { + value.approvalSession.webRequest.beforeRedirect?.({ + id: 7, + url, + method: 'GET', + redirectURL: 'https://attacker.example.test/', + }); + const redirect = event(); + value.window.webContents.emit('will-redirect', redirect); + assert.equal(redirect.prevented, true); + throw new Error('redirect cancelled'); + }; + } else if (scenario === 'status') { + // The default loader supplies a non-exact successful response status. + } else if (scenario === 'authorization' || scenario === 'cookie') { + value.requestHeaders[scenario === 'authorization' ? 'Authorization' : 'Cookie'] = 'secret'; + } else { + value.window.load = async () => { + const changed = { + id: 7, + url: scenario === 'off-origin' + ? 'http://127.0.0.2:41731/api/desktop/pairings/other/browser' + : scenario === 'path' + ? `${approvalUrl}/extra` + : approvalUrl, + method: scenario === 'method' ? 'POST' : 'GET', + webContentsId: value.window.webContents.id, + webContents: value.window.webContents, + frame: scenario === 'subframe' ? { parent: value.window.webContents.mainFrame } : value.window.webContents.mainFrame, + resourceType: scenario === 'subframe' ? 'subFrame' : 'mainFrame', + }; + const result = await decision(value.approvalSession.webRequest.beforeRequest, changed); + assert.deepEqual(result, { cancel: true }); + throw new Error('cancelled'); + }; + } + const controller = controllerFor(value); + await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' }); + await controller.cleanup(); + value.window.load = original; + }); + } + }); + + it('rejects popups, downloads, webviews, and external renderer navigation', async t => { + for (const scenario of ['popup', 'download', 'webview', 'navigation'] as const) { + await t.test(scenario, async () => { + const value = harness(); + const original = value.window.load; + value.window.load = async url => { + await original(url); + const blocked = event(); + if (scenario === 'popup') { + assert.deepEqual(value.window.webContents.openHandler?.(), { action: 'deny' }); + } else if (scenario === 'download') { + value.approvalSession.emit('will-download', blocked); + } else if (scenario === 'webview') { + value.window.webContents.emit('will-attach-webview', blocked); + } else { + value.window.webContents.emit('will-navigate', blocked); + } + if (scenario !== 'popup') assert.equal(blocked.prevented, true); + }; + const controller = controllerFor(value); + await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' }); + await controller.cleanup(); + }); + } + }); + + it('rejects default/mismatched/reused sessions and cleans up idempotently', async () => { + const defaultValue = harness(); + assert.throws(() => createPackagedApprovalNavigation({ + approvalUrl, + approvalSession: defaultValue.defaultSession as unknown as Session, + approvalWindow: defaultValue.window as unknown as BrowserWindow, + defaultSession: defaultValue.defaultSession as unknown as Session, + }), { message: 'Packaged pairing browser approval was rejected' }); + + const value = harness(); + const controller = controllerFor(value); + assert.throws(() => controllerFor(value), { message: 'Packaged pairing browser approval was rejected' }); + await controller.navigate(); + await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' }); + const firstCleanup = controller.cleanup(); + const secondCleanup = controller.cleanup(); + assert.equal(firstCleanup, secondCleanup); + await Promise.all([firstCleanup, secondCleanup]); + + assert.equal(value.window.destroyCount, 1); + assert.equal(value.approvalSession.clearCount, 1); + assert.equal(value.approvalSession.permissionCheck, null); + assert.equal(value.approvalSession.permissionRequest, null); + assert.equal(value.approvalSession.listenerCount('will-download'), 0); + assert.equal(value.window.webContents.listenerCount('will-navigate'), 0); + assert.equal(value.approvalSession.webRequest.beforeRequest, null); + assert.equal(value.approvalSession.webRequest.beforeSendHeaders, null); + assert.equal(value.approvalSession.webRequest.sendHeaders, null); + assert.equal(value.approvalSession.webRequest.headersReceived, null); + assert.equal(value.approvalSession.webRequest.beforeRedirect, null); + assert.equal(value.approvalSession.webRequest.completed, null); + }); + + it('bounds storage cleanup, reports failures, and never makes the session reusable', async () => { + const stalled = new FakeSession(); + stalled.clearStorageData = () => new Promise(() => undefined); + await assert.rejects( + clearPackagedApprovalStorage(stalled as unknown as Session, 5), + { message: 'Packaged pairing browser approval cleanup failed' }, + ); + + const value = harness(); + value.approvalSession.clearStorageData = async () => { throw new Error('private cleanup detail'); }; + const controller = controllerFor(value); + await controller.navigate(); + const firstCleanup = controller.cleanup(); + assert.equal(firstCleanup, controller.cleanup()); + await assert.rejects(firstCleanup, { message: 'Packaged pairing browser approval cleanup failed' }); + assert.throws(() => controllerFor(value), { message: 'Packaged pairing browser approval was rejected' }); + }); +}); diff --git a/apps/desktop/src/packaged-approval-session.ts b/apps/desktop/src/packaged-approval-session.ts new file mode 100644 index 000000000..e53b377cc --- /dev/null +++ b/apps/desktop/src/packaged-approval-session.ts @@ -0,0 +1,334 @@ +import type { + BrowserWindow, + Event as ElectronEvent, + OnBeforeRedirectListenerDetails, + OnBeforeRequestListenerDetails, + OnBeforeSendHeadersListenerDetails, + OnCompletedListenerDetails, + OnHeadersReceivedListenerDetails, + OnSendHeadersListenerDetails, + Session, + WebContentsWillNavigateEventParams, + WebContentsWillRedirectEventParams, +} from 'electron'; + +const APPROVAL_REJECTED = 'Packaged pairing browser approval was rejected'; +const APPROVAL_CLEANUP_REJECTED = 'Packaged pairing browser approval cleanup failed'; +const APPROVAL_STATUS = 200; +const APPROVAL_COMPLETION_TIMEOUT_MS = 5_000; +const APPROVAL_CLEANUP_TIMEOUT_MS = 5_000; +const claimedSessions = new WeakSet(); + +export const packagedApprovalPartition = (nonce: string): string => { + if (!/^[a-f0-9]{32}$/u.test(nonce)) throw rejected(); + return `propr-packaged-approval-${nonce}`; +}; + +export interface PackagedApprovalNavigation { + navigate(): Promise; + cleanup(): Promise; +} + +interface PackagedApprovalNavigationOptions { + approvalUrl: string; + approvalSession: Session; + approvalWindow: BrowserWindow; + defaultSession: Session; +} + +function rejected(): Error { + return new Error(APPROVAL_REJECTED); +} + +function cleanupRejected(): Error { + return new Error(APPROVAL_CLEANUP_REJECTED); +} + +export const clearPackagedApprovalStorage = async ( + approvalSession: Pick, + timeoutMs = APPROVAL_CLEANUP_TIMEOUT_MS, +): Promise => { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > APPROVAL_CLEANUP_TIMEOUT_MS) { + throw cleanupRejected(); + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + approvalSession.clearStorageData(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(cleanupRejected()), timeoutMs); + }), + ]); + } catch { + throw cleanupRejected(); + } finally { + if (timeout) clearTimeout(timeout); + } +}; + +const containsCredentialHeaders = (headers: Record): boolean => + Object.keys(headers).some(name => { + const normalized = name.toLowerCase(); + return normalized === 'authorization' || normalized === 'cookie' || normalized === 'proxy-authorization'; + }); + +const withoutSetCookie = ( + headers: Record | undefined, +): Record => Object.fromEntries( + Object.entries(headers ?? {}).filter(([name]) => { + const normalized = name.toLowerCase(); + return normalized !== 'set-cookie' && normalized !== 'set-cookie2'; + }), +); + +/** + * Constrain the packaged acceptance harness to one isolated, credentialless browser + * navigation. This session is deliberately unrelated to the production renderer + * and credential transport session. + */ +export const createPackagedApprovalNavigation = ({ + approvalUrl, + approvalSession, + approvalWindow, + defaultSession, +}: PackagedApprovalNavigationOptions): PackagedApprovalNavigation => { + const contents = approvalWindow.webContents; + if (approvalSession === defaultSession + || contents.session !== approvalSession + || claimedSessions.has(approvalSession)) { + throw rejected(); + } + claimedSessions.add(approvalSession); + + let active = true; + let navigated = false; + let allowedRequestId: number | null = null; + let responseStatus: number | null = null; + let requestSent = false; + let committedStatus: number | null = null; + let committedUrl: string | null = null; + let completedStatus: number | null = null; + let boundaryRejected = false; + let completionResolve: (() => void) | null = null; + let cleanupPromise: Promise | null = null; + + const releaseCompletionWait = (): void => { + completionResolve?.(); + completionResolve = null; + }; + const rejectBoundary = (): void => { + boundaryRejected = true; + releaseCompletionWait(); + }; + const ownsMainFrame = (details: { + webContentsId?: number; + webContents?: Electron.WebContents; + frame?: Electron.WebFrameMain | null; + resourceType: string; + }): boolean => details.webContentsId === contents.id + && (details.webContents === undefined || details.webContents === contents) + && details.resourceType === 'mainFrame' + && (details.frame === undefined || details.frame === contents.mainFrame); + + const exactAllowedRequest = (details: { + id: number; + url: string; + method: string; + webContentsId?: number; + webContents?: Electron.WebContents; + frame?: Electron.WebFrameMain | null; + resourceType: string; + }): boolean => active + && details.id === allowedRequestId + && details.url === approvalUrl + && details.method === 'GET' + && ownsMainFrame(details); + + approvalSession.setPermissionCheckHandler(() => false); + approvalSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + + const onBeforeRequest = (details: OnBeforeRequestListenerDetails, callback: (decision: { + cancel?: boolean; + }) => void): void => { + if (details.resourceType !== 'mainFrame') { + if (details.resourceType === 'subFrame') rejectBoundary(); + callback({ cancel: true }); + return; + } + const allowed = active + && allowedRequestId === null + && details.url === approvalUrl + && details.method === 'GET' + && ownsMainFrame(details); + if (!allowed) { + rejectBoundary(); + callback({ cancel: true }); + return; + } + allowedRequestId = details.id; + callback({}); + }; + + const onBeforeSendHeaders = ( + details: OnBeforeSendHeadersListenerDetails, + callback: (decision: { cancel?: boolean; requestHeaders?: Record }) => void, + ): void => { + if (!exactAllowedRequest(details) || containsCredentialHeaders(details.requestHeaders)) { + rejectBoundary(); + callback({ cancel: true }); + return; + } + callback({ requestHeaders: details.requestHeaders }); + }; + + const onHeadersReceived = ( + details: OnHeadersReceivedListenerDetails, + callback: (decision: { cancel?: boolean; responseHeaders?: Record }) => void, + ): void => { + if (!exactAllowedRequest(details) || details.statusCode !== APPROVAL_STATUS) { + rejectBoundary(); + callback({ cancel: true }); + return; + } + responseStatus = details.statusCode; + callback({ responseHeaders: withoutSetCookie(details.responseHeaders) }); + }; + + const onSendHeaders = (details: OnSendHeadersListenerDetails): void => { + if (!exactAllowedRequest(details) || containsCredentialHeaders(details.requestHeaders)) { + rejectBoundary(); + return; + } + requestSent = true; + }; + + const onBeforeRedirect = (_details: OnBeforeRedirectListenerDetails): void => { + rejectBoundary(); + }; + const onCompleted = (details: OnCompletedListenerDetails): void => { + if (!exactAllowedRequest(details) || details.statusCode !== responseStatus) { + rejectBoundary(); + return; + } + completedStatus = details.statusCode; + releaseCompletionWait(); + }; + approvalSession.webRequest.onBeforeRequest(onBeforeRequest); + approvalSession.webRequest.onBeforeSendHeaders(onBeforeSendHeaders); + approvalSession.webRequest.onSendHeaders(onSendHeaders); + approvalSession.webRequest.onHeadersReceived(onHeadersReceived); + approvalSession.webRequest.onBeforeRedirect(onBeforeRedirect); + approvalSession.webRequest.onCompleted(onCompleted); + + const onWillNavigate = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + const onWillRedirect = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + const onDidFrameNavigate = ( + _event: ElectronEvent, + url: string, + status: number, + _statusText: string, + isMainFrame: boolean, + ): void => { + if (!isMainFrame || url !== approvalUrl || status !== responseStatus) { + rejectBoundary(); + return; + } + committedUrl = url; + committedStatus = status; + }; + const onDidNavigateInPage = ( + _event: ElectronEvent, + url: string, + isMainFrame: boolean, + ): void => { + // An exact no-op history replacement is the only same-document behavior allowed. + if (!isMainFrame || url !== approvalUrl) rejectBoundary(); + }; + const onWillAttachWebview = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + const onWillDownload = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + + contents.setWindowOpenHandler(() => { + rejectBoundary(); + return { action: 'deny' }; + }); + contents.on('will-navigate', onWillNavigate); + contents.on('will-redirect', onWillRedirect); + contents.on('did-frame-navigate', onDidFrameNavigate); + contents.on('did-navigate-in-page', onDidNavigateInPage); + contents.on('will-attach-webview', onWillAttachWebview); + approvalSession.on('will-download', onWillDownload); + + return { + async navigate() { + if (!active || navigated) throw rejected(); + navigated = true; + const completion = completedStatus !== null || boundaryRejected + ? Promise.resolve() + : new Promise(resolve => { completionResolve = resolve; }); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + (async () => { + await approvalWindow.loadURL(approvalUrl); + await completion; + })(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(rejected()), APPROVAL_COMPLETION_TIMEOUT_MS); + }), + ]); + } catch { + throw rejected(); + } finally { + if (timeout) clearTimeout(timeout); + completionResolve = null; + } + if (!active + || boundaryRejected + || allowedRequestId === null + || !requestSent + || responseStatus === null + || responseStatus !== committedStatus + || responseStatus !== completedStatus + || committedUrl !== approvalUrl + || contents.getURL() !== approvalUrl) { + throw rejected(); + } + }, + cleanup() { + if (cleanupPromise) return cleanupPromise; + cleanupPromise = (async () => { + active = false; + releaseCompletionWait(); + if (!approvalWindow.isDestroyed()) approvalWindow.destroy(); + contents.off('will-navigate', onWillNavigate); + contents.off('will-redirect', onWillRedirect); + contents.off('did-frame-navigate', onDidFrameNavigate); + contents.off('did-navigate-in-page', onDidNavigateInPage); + contents.off('will-attach-webview', onWillAttachWebview); + approvalSession.off('will-download', onWillDownload); + approvalSession.setPermissionCheckHandler(null); + approvalSession.setPermissionRequestHandler(null); + approvalSession.webRequest.onBeforeRequest(null); + approvalSession.webRequest.onBeforeSendHeaders(null); + approvalSession.webRequest.onSendHeaders(null); + approvalSession.webRequest.onHeadersReceived(null); + approvalSession.webRequest.onBeforeRedirect(null); + approvalSession.webRequest.onCompleted(null); + await clearPackagedApprovalStorage(approvalSession); + })(); + return cleanupPromise; + }, + }; +}; diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts index 8b38ac70b..1da9cfa34 100644 --- a/apps/desktop/src/preload-bridge.test.ts +++ b/apps/desktop/src/preload-bridge.test.ts @@ -70,6 +70,17 @@ describe('desktop preload bridge', () => { assert.deepEqual(Object.keys(bridge.discovery).sort(), ['discover', 'rediscover', 'supported']); }); + it('exposes only a fixed stage reporter when packaged Connect acceptance is authorized', async () => { + const ipc = new FakeIpc(); + const bridge = createDesktopBridge(ipc, true, true); + assert.deepEqual(Object.keys(bridge.acceptance ?? {}), ['reportJourneyStage']); + await bridge.acceptance?.reportJourneyStage('CREDENTIAL_COMMITTED'); + assert.deepEqual(ipc.invocations, [{ + channel: IPC_CHANNELS.acceptanceJourneyStage, + args: ['CREDENTIAL_COMMITTED'], + }]); + }); + it('does not expose Electron event objects to deep-link listeners', () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index 10c07bef1..3a6e6e3d3 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -15,6 +15,7 @@ export const createDesktopBridge = ( connectDiscoverySupported = process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32', + connectJourneyAcceptance = false, ): DesktopBridge => { const deepLinkListeners = new Set<(url: string) => void>(); const pendingDeepLinks: string[] = []; @@ -71,6 +72,11 @@ export const createDesktopBridge = ( stop: () => invoke(ipc, IPC_CHANNELS.lifecycleStop), restart: () => invoke(ipc, IPC_CHANNELS.lifecycleRestart), }, + ...(connectJourneyAcceptance ? { + acceptance: { + reportJourneyStage: (stage) => invoke(ipc, IPC_CHANNELS.acceptanceJourneyStage, stage), + }, + } : {}), }; Object.values(bridge).forEach(Object.freeze); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ba4f4d45b..165e7f187 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,11 @@ import { contextBridge, ipcRenderer } from 'electron'; import { createDesktopBridge } from './preload-bridge'; -contextBridge.exposeInMainWorld('proprDesktop', createDesktopBridge(ipcRenderer)); +const connectJourneyAcceptance = process.env.PROPR_DESKTOP_CONNECT_SMOKE_TEST === '1' + && (process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE === 'pair' + || process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE === 'reprobe'); + +contextBridge.exposeInMainWorld( + 'proprDesktop', + createDesktopBridge(ipcRenderer, undefined, connectJourneyAcceptance), +); diff --git a/apps/desktop/src/session-security.test.ts b/apps/desktop/src/session-security.test.ts new file mode 100644 index 000000000..efbdc9d74 --- /dev/null +++ b/apps/desktop/src/session-security.test.ts @@ -0,0 +1,349 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import type { Session, WebContents, WebFrameMain } from 'electron'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { DesktopCredentialService } from './credential-service'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; +import { + configureDesktopSessionSecurity, + desktopNetworkPermissionAllowed, + type DesktopNetworkPermissionEvidence, + type DesktopRendererOwnershipEvidence, +} from './session-security'; + +const RENDERER_URL = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; +const ACTIVE_ORIGIN = 'http://127.0.0.2:41731'; +const TOKEN = `propr_it_${'T'.repeat(43)}`; +const IDENTITY = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(value, 'utf8'), + decrypt: value => value.toString('utf8'), +}; + +const discovery = { + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: IDENTITY, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; + +describe('production desktop session security', () => { + it('denies every permission except active trusted-main-frame local network access', () => { + const accepted = { + activeBindingCurrent: true, + decision: 'check' as const, + isMainFrame: true, + mainWindowPresent: true, + permission: 'loopback-network', + rendererDocumentUrlTrusted: true, + requestingOriginAuthorityEqual: true, + requestingOriginAuthorityValid: true, + requestingUrlAuthorityEqual: true, + requestingUrlPresent: false, + requestingUrlTrusted: false, + webContentsEqualsMainWindow: false, + webContentsPresent: false, + }; + assert.equal(desktopNetworkPermissionAllowed(accepted), true); + for (const rejected of [ + { activeBindingCurrent: false }, + { isMainFrame: false }, + { mainWindowPresent: false }, + { permission: 'notifications' }, + { rendererDocumentUrlTrusted: false }, + { requestingOriginAuthorityEqual: false }, + { requestingOriginAuthorityValid: false }, + { webContentsPresent: true }, + ]) { + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, ...rejected }), false); + } + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network' }), true); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network-access' }), true); + assert.equal(desktopNetworkPermissionAllowed({ + ...accepted, + decision: 'request', + requestingUrlPresent: true, + requestingUrlTrusted: true, + webContentsEqualsMainWindow: true, + webContentsPresent: true, + }), true); + }); + + it('pins permission and concrete credential transport to the live main renderer and current origin', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-session-security-')); + const store = new ProfileStore(directory, encryption); + let connectClaimCurrent = true; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Session security test', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? new Response(JSON.stringify(discovery), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify({ username: 'octocat' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + snapshotConnectIdentityClaim: () => ({ + status: 'unclaimed', + isCurrent: () => connectClaimCurrent, + beginCommit: () => () => undefined, + }), + }); + try { + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: ACTIVE_ORIGIN }); + await store.writeCredential({ + version: 2, + profileId: profile.id, + origin: ACTIVE_ORIGIN, + publicInstanceIdentity: IDENTITY, + token: TOKEN, + }); + const ready = await service.probe(profile); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + + type PermissionCheck = ( + webContents: WebContents | null, + permission: string, + requestingOrigin: string, + details: { requestingUrl?: string; isMainFrame: boolean }, + ) => boolean; + type PermissionRequest = ( + webContents: WebContents, + permission: string, + callback: (allowed: boolean) => void, + details: { requestingUrl?: string; isMainFrame: boolean }, + ) => void; + type BeforeSendHeaders = ( + details: { + url: string; + method: string; + resourceType: string; + requestHeaders: Record; + webContentsId: number; + webContents?: WebContents; + frame?: WebFrameMain | null; + }, + callback: (decision: Record) => void, + ) => void; + let permissionCheck: PermissionCheck = () => false; + let permissionRequest: PermissionRequest = () => undefined; + let beforeSendHeaders: BeforeSendHeaders = () => undefined; + const evidence: DesktopNetworkPermissionEvidence[] = []; + const ownershipEvidence: DesktopRendererOwnershipEvidence[] = []; + const desktopSession = { + setPermissionCheckHandler: (handler: PermissionCheck | null) => { + if (handler) permissionCheck = handler; + }, + setPermissionRequestHandler: (handler: PermissionRequest | null) => { + if (handler) permissionRequest = handler; + }, + webRequest: { + onBeforeSendHeaders: (handler: BeforeSendHeaders | null) => { + if (handler) beforeSendHeaders = handler; + }, + onHeadersReceived: () => undefined, + }, + } as unknown as Session; + let destroyed = false; + let rendererUrl = RENDERER_URL; + const mainFrame = { + detached: false, + parent: null, + url: RENDERER_URL, + } as unknown as WebFrameMain; + const mainRenderer = { + id: 41, + getURL: () => rendererUrl, + isDestroyed: () => destroyed, + mainFrame, + } as unknown as WebContents; + const foreignRenderer = { + id: 42, + getURL: () => RENDERER_URL, + isDestroyed: () => false, + } as unknown as WebContents; + configureDesktopSessionSecurity({ + contentSecurityPolicy: () => "default-src 'self'", + credentials: service, + desktopSession, + getMainRenderer: () => mainRenderer, + isTrustedRendererUrl: value => value === RENDERER_URL, + reportNetworkPermissionDecision: record => evidence.push(record), + reportRendererOwnershipDecision: record => ownershipEvidence.push(record), + }); + + const check = ( + webContents: WebContents | null = null, + origin = DESKTOP_RENDERER_ORIGIN, + details: { requestingUrl?: string; isMainFrame: boolean } = { isMainFrame: true }, + ) => permissionCheck(webContents, 'loopback-network', origin, details); + assert.equal(check(), false); + const activated = await service.activate(ready.activationTicket); + assert.equal(check(), true); + assert.equal(check(foreignRenderer, DESKTOP_RENDERER_ORIGIN, { + requestingUrl: RENDERER_URL, + isMainFrame: true, + }), false); + assert.equal(check(null, 'https://attacker.example.test'), false); + assert.equal(check(null, DESKTOP_RENDERER_ORIGIN, { isMainFrame: false }), false); + rendererUrl = 'https://attacker.example.test/renderer.html'; + assert.equal(check(), false); + rendererUrl = RENDERER_URL; + destroyed = true; + assert.equal(check(), false); + destroyed = false; + + let requested = false; + permissionRequest(mainRenderer, 'local-network-access', value => { requested = value; }, { + requestingUrl: RENDERER_URL, + isMainFrame: true, + }); + assert.equal(requested, true); + permissionRequest(foreignRenderer, 'local-network-access', value => { requested = value; }, { + requestingUrl: RENDERER_URL, + isMainFrame: true, + }); + assert.equal(requested, false); + + const intercepted = async ( + url: string, + headers: Record, + webContentsId = mainRenderer.id, + resourceType = 'xhr', + frame: WebFrameMain | null = mainFrame, + omitFrame = false, + ) => await new Promise>(resolve => beforeSendHeaders({ + url, + method: 'GET', + resourceType, + requestHeaders: headers, + webContentsId, + ...(!omitFrame ? { frame } : {}), + }, resolve)); + const scopeHeaders = { + Origin: DESKTOP_RENDERER_ORIGIN, + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=must-not-cross', + [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope, + }; + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { + requestHeaders: { + Origin: DESKTOP_RENDERER_ORIGIN, + Authorization: `Bearer ${TOKEN}`, + }, + }); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', mainFrame, true, + ), { cancel: true }); + assert.equal(ownershipEvidence.at(-1)?.frameOmitted, true); + assert.equal(ownershipEvidence.at(-1)?.rendererOwned, false); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'other', mainFrame, true, + ), { cancel: true }); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', null, + ), { cancel: true }); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`, + scopeHeaders, + mainRenderer.id, + 'mainFrame', + ), { cancel: true }); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, foreignRenderer.id), { + cancel: true, + }); + const childFrame = { + detached: false, + parent: mainFrame, + url: RENDERER_URL, + } as unknown as WebFrameMain; + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', childFrame, + ), { cancel: true }); + const foreignDocument = { + detached: false, + parent: null, + url: 'https://attacker.example.test/renderer.html', + } as unknown as WebFrameMain; + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', foreignDocument, + ), { cancel: true }); + for (const target of [ + 'http://127.0.0.1:41731/api/side-effect', + 'http://127.0.0.3:41731/api/side-effect', + 'https://192.168.1.10/api/side-effect', + ]) { + assert.deepEqual(await intercepted(target, { + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=must-not-cross', + }), { cancel: true }, target); + } + connectClaimCurrent = false; + assert.equal(check(), false); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { cancel: true }); + connectClaimCurrent = true; + assert.equal(check(), true); + destroyed = true; + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { cancel: true }); + destroyed = false; + + assert.deepEqual(await service.discardActivation(activated), { discarded: true }); + assert.equal(check(), false); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/side-effect`, { + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=must-not-cross', + }), { cancel: true }); + + const revokedReady = await service.probe(profile); + assert.equal(revokedReady.status, 'ready'); + if (revokedReady.status !== 'ready') return; + const revoked = await service.activate(revokedReady.activationTicket); + assert.deepEqual(await service.invalidate({ + profileId: profile.id, + transportScope: revoked.transportScope, + code: 'INSTANCE_TOKEN_REVOKED', + }), { invalidated: true }); + assert.equal(check(), false); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/side-effect`, {}), { cancel: true }); + + assert.ok(evidence.length >= 10); + assert.doesNotMatch( + JSON.stringify(evidence), + /attacker|renderer\.html|127\.0\.0\.2|192\.168|propr_it_/u, + ); + assert.ok(evidence.every(record => Object.keys(record).sort().join(',') === [ + 'activeBindingCurrent', 'allowed', 'decision', 'isMainFrame', 'mainWindowPresent', + 'permissionCategory', 'rendererDocumentUrlTrusted', 'requestingOriginAuthorityEqual', + 'requestingOriginAuthorityValid', 'requestingUrlPresent', 'requestingUrlTrusted', + 'schemaVersion', 'webContentsEqualsMainWindow', 'webContentsPresent', + ].sort().join(','))); + } finally { + await service.dispose(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/session-security.ts b/apps/desktop/src/session-security.ts new file mode 100644 index 000000000..a13e6a0e6 --- /dev/null +++ b/apps/desktop/src/session-security.ts @@ -0,0 +1,292 @@ +import type { Session, WebContents } from 'electron'; +import type { DesktopCredentialService } from './credential-service'; + +const DESKTOP_NETWORK_PERMISSIONS = new Set([ + // Chromium split the original permission into address-space-specific + // permissions. Keep the original spelling for older supported runtimes. + 'local-network-access', + 'local-network', + 'loopback-network', +]); + +export type DesktopNetworkPermissionCategory = + | 'local-network-access' + | 'local-network' + | 'loopback-network'; + +export interface DesktopNetworkPermissionEvidence { + schemaVersion: 1; + permissionCategory: DesktopNetworkPermissionCategory; + decision: 'check' | 'request'; + allowed: boolean; + activeBindingCurrent: boolean; + webContentsPresent: boolean; + webContentsEqualsMainWindow: boolean; + mainWindowPresent: boolean; + isMainFrame: boolean; + requestingUrlPresent: boolean; + requestingUrlTrusted: boolean; + rendererDocumentUrlTrusted: boolean; + requestingOriginAuthorityValid: boolean; + requestingOriginAuthorityEqual: boolean; +} + +export interface DesktopRendererOwnershipEvidence { + schemaVersion: 1; + resourceCategory: 'xhr' | 'webSocket' | 'other'; + mainRendererPresent: boolean; + mainRendererLive: boolean; + webContentsIdMatches: boolean; + webContentsAbsentOrMatches: boolean; + mainFrameLive: boolean; + rendererDocumentTrusted: boolean; + rendererDocumentAuthorityEqual: boolean; + frameOmitted: boolean; + framePresent: boolean; + frameMatchesMainFrame: boolean; + frameExplicitlyForeign: boolean; + rendererOwned: boolean; +} + +const rendererAuthority = (value: string): string | null => { + try { + const url = new URL(value); + if (!url.protocol || !url.hostname || url.username || url.password) return null; + return `${url.protocol}//${url.host}`; + } catch { + return null; + } +}; + +export interface DesktopNetworkPermissionContext extends Omit { + permission: string; + requestingUrlAuthorityEqual: boolean; +} + +/** Local Network Access is available only to the live trusted main frame with a current binding. */ +export const desktopNetworkPermissionAllowed = ({ + activeBindingCurrent, + decision, + isMainFrame, + mainWindowPresent, + permission, + rendererDocumentUrlTrusted, + requestingOriginAuthorityEqual, + requestingOriginAuthorityValid, + requestingUrlAuthorityEqual, + requestingUrlPresent, + requestingUrlTrusted, + webContentsEqualsMainWindow, + webContentsPresent, +}: DesktopNetworkPermissionContext): boolean => DESKTOP_NETWORK_PERMISSIONS.has(permission) + && activeBindingCurrent + && mainWindowPresent + && isMainFrame + && rendererDocumentUrlTrusted + && (!requestingUrlPresent || (requestingUrlTrusted && requestingUrlAuthorityEqual)) + && requestingOriginAuthorityValid + && requestingOriginAuthorityEqual + && (decision === 'check' + ? !webContentsPresent || webContentsEqualsMainWindow + : webContentsPresent && webContentsEqualsMainWindow && requestingUrlPresent); + +interface ConfigureDesktopSessionSecurityOptions { + contentSecurityPolicy(): string; + credentials: DesktopCredentialService; + desktopSession: Session; + enableRendererNetworkBoundary?: boolean; + getMainRenderer(): WebContents | null; + isTrustedRendererUrl(value: string): boolean; + reportNetworkPermissionDecision?(evidence: DesktopNetworkPermissionEvidence): void; + reportRendererOwnershipDecision?(evidence: DesktopRendererOwnershipEvidence): void; +} + +/** Install the production permission, concrete-request, and response boundary on one session. */ +export const configureDesktopSessionSecurity = ({ + contentSecurityPolicy, + credentials, + desktopSession, + enableRendererNetworkBoundary = true, + getMainRenderer, + isTrustedRendererUrl, + reportNetworkPermissionDecision = () => undefined, + reportRendererOwnershipDecision = () => undefined, +}: ConfigureDesktopSessionSecurityOptions): { + close(): void; + dispose(): void; +} => { + const allowNetworkPermission = ( + decision: 'check' | 'request', + webContents: WebContents | null, + permission: string, + requestingOrigin: string, + isMainFrame: boolean, + requestingUrl?: string, + ): boolean => { + const candidate = getMainRenderer(); + const mainRenderer = candidate !== null && !candidate.isDestroyed() ? candidate : null; + const rendererDocumentUrl = mainRenderer?.getURL() ?? ''; + const rendererDocumentAuthority = rendererAuthority(rendererDocumentUrl); + const requestingUrlPresent = typeof requestingUrl === 'string' && requestingUrl.length > 0; + const requestingUrlAuthority = requestingUrlPresent ? rendererAuthority(requestingUrl) : null; + const requestingOriginAuthority = rendererAuthority(requestingOrigin); + const context: DesktopNetworkPermissionContext = { + activeBindingCurrent: credentials.hasActiveRendererBinding(), + decision, + isMainFrame: isMainFrame === true, + mainWindowPresent: mainRenderer !== null, + permission, + rendererDocumentUrlTrusted: mainRenderer !== null && isTrustedRendererUrl(rendererDocumentUrl), + requestingOriginAuthorityEqual: rendererDocumentAuthority !== null + && requestingOriginAuthority === rendererDocumentAuthority, + requestingOriginAuthorityValid: requestingOriginAuthority !== null + && requestingOrigin === requestingOriginAuthority, + requestingUrlAuthorityEqual: !requestingUrlPresent || (rendererDocumentAuthority !== null + && requestingUrlAuthority === rendererDocumentAuthority), + requestingUrlPresent, + requestingUrlTrusted: requestingUrlPresent && isTrustedRendererUrl(requestingUrl), + webContentsEqualsMainWindow: webContents !== null && webContents === mainRenderer, + webContentsPresent: webContents !== null, + }; + const allowed = desktopNetworkPermissionAllowed(context); + if (DESKTOP_NETWORK_PERMISSIONS.has(permission)) { + try { + reportNetworkPermissionDecision({ + schemaVersion: 1, + permissionCategory: permission as DesktopNetworkPermissionCategory, + decision, + allowed, + activeBindingCurrent: context.activeBindingCurrent, + webContentsPresent: context.webContentsPresent, + webContentsEqualsMainWindow: context.webContentsEqualsMainWindow, + mainWindowPresent: context.mainWindowPresent, + isMainFrame: context.isMainFrame, + requestingUrlPresent: context.requestingUrlPresent, + requestingUrlTrusted: context.requestingUrlTrusted, + rendererDocumentUrlTrusted: context.rendererDocumentUrlTrusted, + requestingOriginAuthorityValid: context.requestingOriginAuthorityValid, + requestingOriginAuthorityEqual: context.requestingOriginAuthorityEqual, + }); + } catch { + // Fixed diagnostics cannot alter the permission decision. + } + } + return allowed; + }; + + if (enableRendererNetworkBoundary) { + desktopSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => + allowNetworkPermission( + 'check', + webContents, + String(permission), + requestingOrigin, + details.isMainFrame, + details.requestingUrl, + )); + desktopSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + const requestingUrl = 'requestingUrl' in details && typeof details.requestingUrl === 'string' + ? details.requestingUrl + : undefined; + callback(allowNetworkPermission( + 'request', + webContents, + String(permission), + requestingUrl ? rendererAuthority(requestingUrl) ?? '' : '', + details.isMainFrame, + requestingUrl, + )); + }); + } else { + desktopSession.setPermissionCheckHandler(() => false); + desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + } + desktopSession.webRequest.onBeforeSendHeaders((details, callback) => { + const mainRenderer = getMainRenderer(); + const requestingFrame = details.frame; + const mainFrame = mainRenderer?.mainFrame; + const mainRendererLive = mainRenderer !== null && !mainRenderer.isDestroyed(); + const mainFrameLive = mainRendererLive + && mainFrame !== undefined + && mainFrame !== null + && !mainFrame.detached + && mainFrame.parent === null; + const rendererDocumentUrl = mainRendererLive ? mainRenderer.getURL() : ''; + const mainFrameUrl = mainFrameLive ? mainFrame.url : ''; + const rendererDocumentTrusted = mainFrameLive + && isTrustedRendererUrl(rendererDocumentUrl) + && isTrustedRendererUrl(mainFrameUrl); + const rendererDocumentAuthorityEqual = rendererDocumentTrusted + && rendererAuthority(rendererDocumentUrl) !== null + && rendererAuthority(rendererDocumentUrl) === rendererAuthority(mainFrameUrl) + && rendererDocumentUrl === mainFrameUrl; + const webContentsIdMatches = mainRendererLive && details.webContentsId === mainRenderer.id; + const webContentsAbsentOrMatches = mainRendererLive + && (details.webContents === undefined || details.webContents === mainRenderer); + const frameOmitted = requestingFrame === undefined; + const framePresent = requestingFrame !== undefined && requestingFrame !== null; + const frameMatchesMainFrame = framePresent + && mainFrame !== undefined + && mainFrame !== null + && requestingFrame === mainFrame + && !requestingFrame.detached + && isTrustedRendererUrl(requestingFrame.url); + const resourceCategory = details.resourceType === 'xhr' + ? 'xhr' + : details.resourceType === 'webSocket' + ? 'webSocket' + : 'other'; + const rendererOwned = mainRendererLive + && webContentsIdMatches + && webContentsAbsentOrMatches + && frameMatchesMainFrame; + if (details.webContentsId !== undefined) { + try { + reportRendererOwnershipDecision({ + schemaVersion: 1, + resourceCategory, + mainRendererPresent: mainRenderer !== null, + mainRendererLive, + webContentsIdMatches, + webContentsAbsentOrMatches, + mainFrameLive, + rendererDocumentTrusted, + rendererDocumentAuthorityEqual, + frameOmitted, + framePresent, + frameMatchesMainFrame, + frameExplicitlyForeign: framePresent && !frameMatchesMainFrame, + rendererOwned, + }); + } catch { + // Fixed diagnostics cannot alter the renderer ownership decision. + } + } + void credentials.prepareRequestAsync(details.url, details.requestHeaders, { + method: details.method, + ...(enableRendererNetworkBoundary ? { rendererOwned } : {}), + resourceType: details.resourceType, + }).then(callback, () => callback({ cancel: true })); + }); + desktopSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}), + 'Content-Security-Policy': [contentSecurityPolicy()], + }, + }); + }); + return { + close() { + desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true })); + desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true })); + }, + dispose() { + desktopSession.setPermissionCheckHandler(null); + desktopSession.setPermissionRequestHandler(null); + desktopSession.webRequest.onBeforeSendHeaders(null); + desktopSession.webRequest.onHeadersReceived(null); + }, + }; +}; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 4a36dcc2e..2fe16b87e 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -22,8 +22,17 @@ export const IPC_CHANNELS = Object.freeze({ lifecycleStop: 'desktop:lifecycle-stop', lifecycleRestart: 'desktop:lifecycle-restart', deepLink: 'desktop:deep-link', + acceptanceJourneyStage: 'desktop:acceptance-journey-stage', } as const); +export type DesktopAcceptanceJourneyStage = + | 'AUTHENTICATION_REQUIRED' + | 'CREDENTIAL_COMMITTED' + | 'AUTHENTICATED_REPROBE_READY' + | 'ACTIVATION_COMMITTED' + | 'ACTIVATION_PUBLISHED' + | 'REACT_CONNECTED'; + export type DesktopPlatform = 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd'; @@ -142,4 +151,8 @@ export interface DesktopBridge { stop(): Promise; restart(): Promise; }; + /** @internal Present only in an authorized packaged Connect acceptance process. */ + acceptance?: { + reportJourneyStage(stage: DesktopAcceptanceJourneyStage): Promise; + }; } diff --git a/package.json b/package.json index 13b25ecc8..8e6a6e4b0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:notifications:ui": "npm --workspace propr-ui test -- src/api/notificationApi.test.ts src/serviceWorker.test.ts src/serviceWorkerRegistration.test.ts src/hooks/useBrowserPush.test.tsx src/pages/SettingsPage/NotificationSettingsSection.test.tsx src/pages/InboxPage.test.tsx src/pages/inboxUtils.test.ts src/components/Inbox/NotificationActions.test.tsx src/components/MobileBottomNavigation.test.tsx src/contexts/NotificationCenterContext.test.tsx src/utils/notificationIntents.test.ts src/pages/PlanStudioPage.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.notificationIntent.test.tsx src/components/TaskPlanner/PlanIssuesManager.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.responsive.test.tsx", "test:notifications": "npm run build -w @propr/shared && npm run build -w @propr/core && npm run test:notifications:server && npm run test:notifications:ui", "pretest:unit": "npm run build -w @propr/shared && npm run build -w @propr/local-setup", - "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", + "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/desktopApiBoundary.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", "test:e2e": "npx tsx --test test/e2e.test.ts", "test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts", "test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test", diff --git a/packages/api/desktopApiBoundary.ts b/packages/api/desktopApiBoundary.ts new file mode 100644 index 000000000..71a0cf69f --- /dev/null +++ b/packages/api/desktopApiBoundary.ts @@ -0,0 +1,39 @@ +import type { Express, RequestHandler } from 'express'; +import { ensureAuthenticated } from './auth.js'; +import { resolveAuthorization } from './authorization.js'; +import { + createDiscoveryRequestRateLimiter, + createPairingPollRateLimiter, + createPairingStartRateLimiter, +} from './requestRateLimits.js'; + +export interface DesktopApiBoundaryRoutes { + discovery: RequestHandler; + startPairing: RequestHandler; + pollPairing: RequestHandler; + activatePairing: RequestHandler; + cancelPairing: RequestHandler; + openPairingApproval: RequestHandler; + revokeCurrentToken: RequestHandler; +} + +/** + * Register the complete public desktop bootstrap boundary and then close it + * with the generic API authentication/authorization guard. Operational routes + * must be registered only after this function returns. + */ +export function registerDesktopApiBoundary( + app: Express, + routes: DesktopApiBoundaryRoutes, +): void { + app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), routes.discovery); + app.post('/api/desktop/pairings', createPairingStartRateLimiter(), routes.startPairing); + app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), routes.pollPairing); + app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), routes.activatePairing); + app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), routes.cancelPairing); + app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), routes.openPairingApproval); + // Token possession authorizes only this exact self-revocation route. It must + // precede generic auth so inactive tokens receive a stable terminal contract. + app.delete('/api/desktop/tokens/current', routes.revokeCurrentToken); + app.use('/api', ensureAuthenticated, resolveAuthorization); +} diff --git a/packages/api/server.ts b/packages/api/server.ts index 3e014729c..33561d9e5 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -6,7 +6,7 @@ import { createClient, RedisClientType } from 'redis'; import { Queue } from 'bullmq'; import 'dotenv/config'; import { Redis, RedisOptions } from 'ioredis'; -import { authenticateSocketRequest, setupAuth, ensureAuthenticated } from './auth.js'; +import { authenticateSocketRequest, setupAuth } from './auth.js'; import { configureDemoMode, createDemoRedisClient, demoModeReadOnlyMiddleware } from './demoMode.js'; import { resolveGithubAuthMode, resolveGithubEventIntakeMode, validateIntakeModePrerequisites } from '@propr/shared'; import { initSocketService, closeSocketService } from './services/socketService.js'; @@ -62,14 +62,12 @@ import { stopTaskExecution } from './routes/dockerRoutes.js'; import { initializePushSubscriptionMaintenance } from './services/pushSubscriptionMaintenance.js'; import { NotificationProjectionService } from './services/notificationProjectionService.js'; import { WebPushDispatcher } from './services/webPushDispatcher.js'; -import { assertInstanceAdministratorConfigured, resolveAuthorization } from './authorization.js'; +import { assertInstanceAdministratorConfigured } from './authorization.js'; import { resolveApiListenHost } from './listenAddress.js'; import { configureApiProxyTrust, createApiRequestRateLimiter, createDiscoveryRequestRateLimiter, - createPairingPollRateLimiter, - createPairingStartRateLimiter, createWebhookRequestRateLimiter, } from './requestRateLimits.js'; import { desktopAuthService } from './desktopAuthService.js'; @@ -83,6 +81,7 @@ import { type RouteEntry } from './routeRegistry.js'; import { createTaskDeleteRouteEntries } from './taskDeleteRouteRegistry.js'; +import { registerDesktopApiBoundary } from './desktopApiBoundary.js'; import { startVisualPreviewOAuthRefreshScheduler, type VisualPreviewOAuthRefreshScheduler, @@ -262,16 +261,15 @@ function setupRoutes(): void { // They return only compatibility/capability metadata or pairing state gated by // a high-entropy secret; all operational routes below remain authenticated. app.get('/api/compatibility', createDiscoveryRequestRateLimiter(), statusRoutes.getCompatibility); - app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), statusRoutes.getDesktopDiscovery); - app.post('/api/desktop/pairings', createPairingStartRateLimiter(), desktopAuthRoutes.startPairing); - app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), desktopAuthRoutes.pollPairing); - app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), desktopAuthRoutes.activatePairing); - app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), desktopAuthRoutes.cancelPairing); - app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), desktopAuthRoutes.openPairingApproval); - // Token possession authorizes only this exact self-revocation route. It must - // precede generic auth so inactive tokens receive a stable terminal contract. - app.delete('/api/desktop/tokens/current', desktopAuthRoutes.revokeCurrentToken); - app.use('/api', ensureAuthenticated, resolveAuthorization); + registerDesktopApiBoundary(app, { + discovery: statusRoutes.getDesktopDiscovery, + startPairing: desktopAuthRoutes.startPairing, + pollPairing: desktopAuthRoutes.pollPairing, + activatePairing: desktopAuthRoutes.activatePairing, + cancelPairing: desktopAuthRoutes.cancelPairing, + openPairingApproval: desktopAuthRoutes.openPairingApproval, + revokeCurrentToken: desktopAuthRoutes.revokeCurrentToken, + }); app.get('/api/desktop/pairings/:pairingId/approval', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.getPairingApproval); app.post('/api/desktop/pairings/:pairingId/approve', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.approvalOriginGuard, desktopAuthRoutes.approvePairing); app.get('/api/desktop/tokens', desktopAuthRoutes.listTokens); diff --git a/packages/api/test/desktopApiBoundary.test.ts b/packages/api/test/desktopApiBoundary.test.ts new file mode 100644 index 000000000..fe1dd2811 --- /dev/null +++ b/packages/api/test/desktopApiBoundary.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { after, describe, test } from 'node:test'; +import express, { type RequestHandler } from 'express'; +import { closeConnection } from '@propr/core'; +import { registerDesktopApiBoundary, type DesktopApiBoundaryRoutes } from '../desktopApiBoundary.js'; + +after(async () => closeConnection()); + +const reached = (name: string): RequestHandler => (_req, res) => { + res.status(204).set('X-ProPR-Route', name).end(); +}; + +const publicRoutes: DesktopApiBoundaryRoutes = { + discovery: reached('discovery'), + startPairing: reached('start'), + pollPairing: reached('poll'), + activatePairing: reached('activate'), + cancelPairing: reached('cancel'), + openPairingApproval: reached('browser'), + revokeCurrentToken: reached('revoke'), +}; + +const fetchFromApp = async ( + app: express.Express, + path: string, + init?: RequestInit, +): Promise => { + const server = app.listen(0, '127.0.0.1'); + try { + await new Promise(resolve => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return await fetch(`http://127.0.0.1:${port}${path}`, init); + } finally { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } +}; + +describe('assembled desktop API authentication boundary', () => { + test('keeps discovery and bounded pairing bootstrap ahead of the operational API guard', async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.isAuthenticated = () => false; + next(); + }); + registerDesktopApiBoundary(app, publicRoutes); + app.get('/api/status', (_req, res) => res.json({ operational: true })); + + for (const [method, path, expected] of [ + ['GET', '/api/desktop/discovery', 'discovery'], + ['POST', '/api/desktop/pairings', 'start'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/poll', 'poll'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/activate', 'activate'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/cancel', 'cancel'], + ['GET', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/browser', 'browser'], + ] as const) { + const response = await fetchFromApp(app, path, { method }); + assert.equal(response.status, 204, `${method} ${path}`); + assert.equal(response.headers.get('x-propr-route'), expected, `${method} ${path}`); + } + + const protectedResponse = await fetchFromApp(app, '/api/status'); + assert.equal(protectedResponse.status, 401); + assert.deepEqual(await protectedResponse.json(), { error: 'Unauthorized' }); + }); +}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 44a95516a..2eb48f10d 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -11,7 +11,10 @@ import { type NormalizeApiBaseUrlOptions, type ProprApiBaseUrl, } from './baseUrl.js'; -import { ProprClientError } from './errors.js'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClientError, +} from './errors.js'; import { buildSocketConnection, connectProprSocket, @@ -80,6 +83,45 @@ const isCompatibilityMetadata = (value: unknown): value is Partial { + let offset = 0; + const whitespace = (): void => { + while (offset < contents.length && /[\x20\t\r\n]/.test(contents[offset])) offset += 1; + }; + const stringToken = (): string | null => { + if (contents[offset] !== '"') return null; + const start = offset; + offset += 1; + while (offset < contents.length) { + const character = contents[offset++]; + if (character === '"') { + try { return JSON.parse(contents.slice(start, offset)) as string; } catch { return null; } + } + if (character === '\\') { + const escape = contents[offset++]; + if (escape === 'u') { + if (!/^[0-9a-fA-F]{4}$/.test(contents.slice(offset, offset + 4))) return null; + offset += 4; + } else if (!escape || !'"\\/bfnrt'.includes(escape)) return null; + } else if (character.charCodeAt(0) < 0x20) return null; + } + return null; + }; + + whitespace(); + if (contents[offset++] !== '{') return false; + whitespace(); + if (stringToken() !== 'error') return false; + whitespace(); + if (contents[offset++] !== ':') return false; + whitespace(); + if (stringToken() !== 'Unauthorized') return false; + whitespace(); + if (contents[offset++] !== '}') return false; + whitespace(); + return offset === contents.length; +}; + const assertTimeout = (timeoutMs: number): void => { if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { throw new ProprClientError('Request timeouts must be finite, non-negative numbers.', { @@ -341,11 +383,18 @@ export class ProprClient { throw new ProprClientError('The ProPR API could not be reached.', { kind: 'network', cause }); } try { - if (!response.ok || response.redirected - || response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + const discoveryContentType = response.headers.get('content-type') + ?.split(';', 1)[0]?.trim().toLowerCase(); + const legacyAuthenticationCandidate = response.status === 401 + && !response.redirected + && discoveryContentType === 'application/json'; + if ((!response.ok && !legacyAuthenticationCandidate) + || response.redirected + || discoveryContentType !== 'application/json') { try { void response.body?.cancel().catch(() => undefined); } catch { /* best-effort response disposal */ } throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { - kind: 'invalid_response', status: response.status, + kind: 'invalid_response', + status: response.status, }); } const declaredLength = response.headers.get('content-length'); @@ -378,7 +427,8 @@ export class ProprClient { throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); } throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { - kind: 'invalid_response', status: response.status, cause, + kind: 'invalid_response', status: response.status, + ...(legacyAuthenticationCandidate ? {} : { cause }), }); } finally { try { reader?.releaseLock(); } catch { /* hostile streams may retain a pending read */ } } const contentEncoding = response.headers.get('content-encoding')?.trim().toLowerCase(); @@ -395,7 +445,17 @@ export class ProprClient { try { contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (cause) { throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { - kind: 'invalid_response', status: response.status, cause, + kind: 'invalid_response', status: response.status, + ...(legacyAuthenticationCandidate ? {} : { cause }), + }); + } + if (legacyAuthenticationCandidate) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', + status: response.status, + ...(isExactLegacyDiscoveryAuthenticationBody(contents) + ? { code: DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED } + : {}), }); } const metadata = parseProprDesktopDiscoveryJson(contents); diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 6a5af7ae1..75ed8fa8b 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -8,6 +8,10 @@ export type ProprClientErrorKind = | 'invalid_response' | 'compatibility'; +/** The exact credential-free public discovery request was authentication-gated. */ +export const DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED = + 'DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED' as const; + export interface ProprClientErrorOptions { kind: ProprClientErrorKind; status?: number; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7ff0e4a39..84f5a37ab 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,6 +15,7 @@ export { type ProprRequestOptions, } from './client.js'; export { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, isProprClientError, ProprClientError, type ProprClientErrorKind, diff --git a/packages/client/src/socket.ts b/packages/client/src/socket.ts index b59d6f342..b6bdefc50 100644 --- a/packages/client/src/socket.ts +++ b/packages/client/src/socket.ts @@ -20,17 +20,43 @@ export interface ProprSocketConnection { options: ProprSocketOptions; } -const bearerSocketAuth = (getAccessToken: AccessTokenProvider): SocketOptions['auth'] => - (callback: (data: Record) => void): void => { +type SocketAuthPayload = Record; +type SocketAuthCallback = (data: SocketAuthPayload) => void; + +const metadataWithoutToken = (value: unknown): SocketAuthPayload => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const { token: _untrustedToken, ...metadata } = value as SocketAuthPayload; + return metadata; +}; + +const bearerSocketAuth = ( + getAccessToken: AccessTokenProvider, + configuredAuth: SocketOptions['auth'], +): SocketOptions['auth'] => (callback: SocketAuthCallback): void => { + const resolveBearer = (metadataValue: unknown): void => { + const metadata = metadataWithoutToken(metadataValue); Promise.resolve(getAccessToken()).then( token => { const normalized = token?.trim(); - callback(normalized && !/\r|\n/.test(normalized) ? { token: normalized } : {}); + callback(normalized && !/\r|\n/.test(normalized) + ? { ...metadata, token: normalized } + : metadata); }, - () => callback({}) + () => callback(metadata), ); }; + if (typeof configuredAuth === 'function') { + try { + configuredAuth(resolveBearer); + } catch { + resolveBearer({}); + } + return; + } + resolveBearer(configuredAuth); +}; + /** Build the complete, explicit reconnect policy used by every ProPR surface. */ export const buildSocketConnection = ( baseUrl: ProprApiBaseUrl, @@ -38,7 +64,7 @@ export const buildSocketConnection = ( overrides: ProprSocketOptions = {} ): ProprSocketConnection => { const auth = authentication.type === 'bearer' - ? bearerSocketAuth(authentication.getAccessToken) + ? bearerSocketAuth(authentication.getAccessToken, overrides.auth) : undefined; return { @@ -55,7 +81,7 @@ export const buildSocketConnection = ( randomizationFactor: 0.5, timeout: 20_000, ...overrides, - ...(auth && overrides.auth === undefined ? { auth } : {}), + ...(auth ? { auth } : {}), }, }; }; diff --git a/packages/client/test/desktopPairing.test.ts b/packages/client/test/desktopPairing.test.ts index e803774ba..cdd59a678 100644 --- a/packages/client/test/desktopPairing.test.ts +++ b/packages/client/test/desktopPairing.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared'; -import { ProprClient, ProprClientError } from '../src/index.js'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClient, + ProprClientError, +} from '../src/index.js'; const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, @@ -74,6 +78,100 @@ class PairingClock { } describe('desktop instance protocol', () => { + it('strictly classifies only the credential-free public discovery 401', async () => { + const legacy = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async (_input, init) => { + assert.equal(init?.credentials, 'omit'); + assert.equal(init?.redirect, 'manual'); + return new Response('{ "error": "Unauthorized" }', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + await assert.rejects(legacy.discoverDesktop(), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.status === 401 + && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED); + + const oversized = `{"error":"Unauthorized","padding":"${'x'.repeat(8 * 1024)}"}`; + const invalidResponses = [ + new Response(null, { status: 401, headers: { 'Content-Type': 'application/json' } }), + new Response('

Policy login required

', { + status: 401, headers: { 'Content-Type': 'text/html' }, + }), + new Response('{"error":', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized","error":"Unauthorized"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized","code":"PROXY_POLICY"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"code":"AUTHENTICATION_REQUIRED"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"private proxy policy detail"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized"}', { + status: 401, + headers: { 'Content-Type': 'application/json', 'Content-Length': '8193' }, + }), + new Response(oversized, { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response(new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d]), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized"}', { + status: 401, + headers: { 'Content-Type': 'application/json', 'Content-Length': '1' }, + }), + new Response('{"error":"Unauthorized"}', { + status: 401, headers: { 'Content-Type': 'application/problem+json' }, + }), + ]; + const redirected = new Response('{"error":"Unauthorized"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + Object.defineProperty(redirected, 'redirected', { value: true }); + invalidResponses.push(redirected); + + for (const response of invalidResponses) { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => response, + }); + await assert.rejects(client.discoverDesktop(), (error: unknown) => { + assert.ok(error instanceof ProprClientError); + assert.equal(error.kind, 'invalid_response'); + assert.equal(error.status, 401); + assert.equal(error.code, undefined); + assert.equal(error.body, undefined); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /private proxy policy detail|Unauthorized/u); + return true; + }); + } + + const operational = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => json({ code: 'AUTHENTICATION_REQUIRED' }, 401), + }); + await assert.rejects(operational.request('/api/tasks'), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'http' + && error.status === 401 + && error.code === 'AUTHENTICATION_REQUIRED'); + + }); + it('uses the shared strict wire parser for missing, extra, malformed, duplicate, and oversized discovery', async () => { const valid = JSON.stringify(discovery); const invalidBodies = [ @@ -126,10 +224,10 @@ describe('desktop instance protocol', () => { authentication: { type: 'none' }, fetch: async () => new Response(new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode('{"schemaVersion":1')); + controller.enqueue(new TextEncoder().encode('{"error":"Unauthor')); }, cancel() { bodyCancelled += 1; }, - }), { headers: { 'Content-Type': 'application/json' } }), + }), { status: 401, headers: { 'Content-Type': 'application/json' } }), }); await assert.rejects(bounded(stalledBody.discoverDesktop(20), 500), (error: unknown) => error instanceof ProprClientError && error.kind === 'timeout'); diff --git a/packages/client/test/socket.test.ts b/packages/client/test/socket.test.ts index 6347db87d..c970bc096 100644 --- a/packages/client/test/socket.test.ts +++ b/packages/client/test/socket.test.ts @@ -38,6 +38,28 @@ describe('Socket.IO connection configuration', () => { assert.deepEqual(await resolveAuth(), { token: 'refreshed-token' }); }); + it('preserves handshake metadata while only the fresh provider can supply the bearer token', async () => { + let token = 'first-token'; + const connection = buildSocketConnection( + normalizeApiBaseUrl('https://propr.example.com'), + { type: 'bearer', getAccessToken: () => token }, + { auth: { proprDesktopTransportScope: 'scope-a', token: 'metadata-token' } } + ); + + const resolveAuth = (): Promise => new Promise(resolve => { + (connection.options.auth as (callback: (data: unknown) => void) => void)(resolve); + }); + assert.deepEqual(await resolveAuth(), { + proprDesktopTransportScope: 'scope-a', + token: 'first-token', + }); + token = 'refreshed-token'; + assert.deepEqual(await resolveAuth(), { + proprDesktopTransportScope: 'scope-a', + token: 'refreshed-token', + }); + }); + it('routes Connect Socket.IO to the same origin and fixed proxy path', () => { const connection = buildSocketConnection( normalizeApiBaseUrl('https://t-instance123.propr.dev'), diff --git a/propr-ui/src/contexts/SocketProvider.test.tsx b/propr-ui/src/contexts/SocketProvider.test.tsx index 4dfb5091f..ee96e4ab6 100644 --- a/propr-ui/src/contexts/SocketProvider.test.tsx +++ b/propr-ui/src/contexts/SocketProvider.test.tsx @@ -88,6 +88,7 @@ describe('SocketProvider', () => { expect(connectSocketMock).toHaveBeenCalledOnce(); expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({ forceNew: true, + auth: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' }, query: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' }, })); }); @@ -159,6 +160,7 @@ describe('SocketProvider', () => { expect(connectSocketMock).toHaveBeenCalledOnce(); expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({ forceNew: true })); + expect(connectSocketMock).toHaveBeenCalledWith(expect.not.objectContaining({ auth: expect.anything() })); expect(connectSocketMock).toHaveBeenCalledWith(expect.not.objectContaining({ query: expect.anything() })); }); diff --git a/propr-ui/src/contexts/SocketProvider.tsx b/propr-ui/src/contexts/SocketProvider.tsx index 9bf0d9956..0c6ef8459 100644 --- a/propr-ui/src/contexts/SocketProvider.tsx +++ b/propr-ui/src/contexts/SocketProvider.tsx @@ -49,7 +49,10 @@ export const SocketProvider: React.FC = ({ children, disabl autoConnect: true, path: '/socket.io/', forceNew: true, - ...(desktopScope ? { query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope } } : {}), + ...(desktopScope ? { + auth: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope }, + query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope }, + } : {}), }); let disposed = false; const isCurrentScope = (): boolean => { diff --git a/propr-ui/src/desktop/DesktopConnectedExperience.tsx b/propr-ui/src/desktop/DesktopConnectedExperience.tsx new file mode 100644 index 000000000..8d81ca948 --- /dev/null +++ b/propr-ui/src/desktop/DesktopConnectedExperience.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; +import type { RefObject } from 'react'; +import { Plus, X } from 'lucide-react'; +import { DesktopContext } from './DesktopContext'; +import { ProfileEditor, ProfileList } from './DesktopExperiencePanels'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +interface DesktopConnectedExperienceProps { + adapters: DesktopAdapters; + profile: DesktopProfile; + result: Extract; + profiles: DesktopProfile[]; + managerOpen: boolean; + managerRef: RefObject; + editing: DesktopProfile | 'new' | null; + operationError: string | null; + deepLinkError: string | null; + editorNotice: string | null; + hasPendingConnectCandidate: boolean; + children: React.ReactNode; + openManager(): void; + closeManager(): void; + closeEditor(): void; + openEditor(profile: DesktopProfile | 'new'): void; + connect(profile: DesktopProfile): Promise; + removeProfile(profile: DesktopProfile): Promise; + saveProfile(profile: DesktopProfile, shouldConnect?: boolean): Promise; + retry(): void; + setManagerOpen(open: boolean): void; +} + +export const DesktopConnectedExperience: React.FC = ({ + adapters, profile, result, profiles, managerOpen, managerRef, editing, + operationError, deepLinkError, editorNotice, hasPendingConnectCandidate, + children, openManager, closeManager, closeEditor, openEditor, connect, + removeProfile, saveProfile, retry, setManagerOpen, +}) => { + const [networkOffline, setNetworkOffline] = useState(!navigator.onLine); + + useEffect(() => { + const online = () => setNetworkOffline(false); + const offline = () => setNetworkOffline(true); + window.addEventListener('online', online); + window.addEventListener('offline', offline); + return () => { + window.removeEventListener('online', online); + window.removeEventListener('offline', offline); + }; + }, []); + + const displayedConnection: DesktopConnectionResult = networkOffline + ? { status: 'offline', message: 'This computer is offline.' } + : result; + const contextValue = { + isDesktop: true as const, + platform: adapters.platform, + profile, + connection: displayedConnection, + openProfileManager: openManager, + authenticate: () => adapters.authentication.authenticate(profile), + openConnectionHelp: () => adapters.externalBrowser.open('https://propr.dev'), + retry, + }; + + return ( + + {deepLinkError &&
{deepLinkError}
} +
{children}
+ {managerOpen && ( +
{ if (event.target === event.currentTarget) closeManager(); }}> +
+
Desktop

Manage instances

+ {editing ? ( + void saveProfile(editedProfile, hasPendingConnectCandidate || editing === 'new' || profile.id === editedProfile.id)} /> + ) : ( + <> + {operationError &&
{operationError}
} + { setManagerOpen(false); void connect(nextProfile); }} onEdit={openEditor} onRemove={nextProfile => void removeProfile(nextProfile)} /> + + + )} +
+
+ )} +
+ ); +}; diff --git a/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx b/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx new file mode 100644 index 000000000..fae4e5f35 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import { adaptersFor, remoteProfile } from './DesktopExperience.testSupport'; + +const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); +const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); + +vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); + +describe('DesktopExperience authentication', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reconnects after authentication completes and advances to the connected app', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'authentication-required', message: 'Please sign in.' }) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor([remoteProfile], remoteProfile.id, probe); + const stages: string[] = []; + adapters.acceptance = { + reportJourneyStage: vi.fn(async stage => { stages.push(stage); }), + }; + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.authentication.authenticate).toHaveBeenCalledWith(remoteProfile); + expect(probe).toHaveBeenCalledTimes(2); + await waitFor(() => expect(stages).toEqual([ + 'AUTHENTICATION_REQUIRED', + 'CREDENTIAL_COMMITTED', + 'AUTHENTICATED_REPROBE_READY', + 'ACTIVATION_COMMITTED', + 'ACTIVATION_PUBLISHED', + 'REACT_CONNECTED', + ])); + }); + + it('reports rejected authentication and connection-help operations in the blocked panel', async () => { + const adapters = adaptersFor( + [remoteProfile], + remoteProfile.id, + async () => ({ status: 'authentication-required', message: 'Please sign in.' }) + ); + vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce(new Error('Browser launch failed.')); + vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce(new Error('No browser is configured.')); + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); + expect(await screen.findByText(/could not open sign in.*try again/i)).toBeInTheDocument(); + expect(screen.queryByText(/browser launch failed/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in in browser/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Open connection help/i })); + expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument(); + expect(screen.queryByText(/no browser is configured/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Open connection help/i })).toBeInTheDocument(); + }); +}); diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index ea7a818d9..f5545dcb8 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -2,8 +2,8 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DesktopDeepLinkInbox } from '../desktop-deep-link'; import { DesktopExperience } from './DesktopExperience'; -import { DesktopTitleBar } from './DesktopTitleBar'; -import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; +import { adaptersFor, deferred, localProfile, remoteProfile, renderConnectedExperience } from './DesktopExperience.testSupport'; +import type { DesktopConnectionResult } from './types'; const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); @@ -11,54 +11,6 @@ const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); -const localProfile: DesktopProfile = { - id: 'local', - name: 'This computer', - baseUrl: 'http://127.0.0.1:3000', - kind: 'local', -}; - -const remoteProfile: DesktopProfile = { - id: 'remote', - name: 'Team server', - baseUrl: 'https://propr.example.com', - kind: 'remote', -}; - -const adaptersFor = ( - profiles: DesktopProfile[] = [], - activeId: string | null = null, - probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' }) -): DesktopAdapters => ({ - platform: 'linux', - app: { onDeepLink: () => () => undefined }, - profiles: { - list: vi.fn(async () => profiles), - save: vi.fn(async () => undefined), - remove: vi.fn(async () => undefined), - getActiveId: vi.fn(async () => activeId), - setActiveId: vi.fn(async () => undefined), - }, - discovery: { supported: true, discover: vi.fn(async () => []) }, - authentication: { authenticate: vi.fn(async () => undefined) }, - externalBrowser: { open: vi.fn(async () => undefined) }, - localSetup: { supported: true, setup: vi.fn(async () => localProfile) }, - connection: { probe: vi.fn(probe) }, -}); - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise(complete => { resolve = complete; }); - return { promise, resolve }; -} - -const renderConnectedExperience = (adapters: DesktopAdapters, content?: string) => render( - - - {content &&
{content}
} -
-); - describe('DesktopExperience', () => { beforeEach(() => { vi.clearAllMocks(); @@ -419,41 +371,6 @@ describe('DesktopExperience', () => { expect(adapters.profiles.remove).toHaveBeenCalledWith(remoteProfile.id); }); - it('reconnects after authentication completes and advances to the connected app', async () => { - const probe = vi.fn() - .mockResolvedValueOnce({ status: 'authentication-required', message: 'Please sign in.' }) - .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); - const adapters = adaptersFor([remoteProfile], remoteProfile.id, probe); - render(
Connected app
); - - fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); - - expect(await screen.findByText('Connected app')).toBeInTheDocument(); - expect(adapters.authentication.authenticate).toHaveBeenCalledWith(remoteProfile); - expect(probe).toHaveBeenCalledTimes(2); - }); - - it('reports rejected authentication and connection-help operations in the blocked panel', async () => { - const adapters = adaptersFor( - [remoteProfile], - remoteProfile.id, - async () => ({ status: 'authentication-required', message: 'Please sign in.' }) - ); - vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce(new Error('Browser launch failed.')); - vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce(new Error('No browser is configured.')); - render(
Connected app
); - - fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); - expect(await screen.findByText(/could not open sign in.*try again/i)).toBeInTheDocument(); - expect(screen.queryByText(/browser launch failed/i)).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Sign in in browser/i })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: /Open connection help/i })); - expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument(); - expect(screen.queryByText(/no browser is configured/i)).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Open connection help/i })).toBeInTheDocument(); - }); - it.each(['macos', 'windows'] as const)('offers remote connection guidance instead of local setup on %s', async platform => { const adapters = adaptersFor(); adapters.platform = platform; diff --git a/propr-ui/src/desktop/DesktopExperience.testSupport.tsx b/propr-ui/src/desktop/DesktopExperience.testSupport.tsx new file mode 100644 index 000000000..193019a20 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.testSupport.tsx @@ -0,0 +1,53 @@ +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import { DesktopTitleBar } from './DesktopTitleBar'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +export const localProfile: DesktopProfile = { + id: 'local', + name: 'This computer', + baseUrl: 'http://127.0.0.1:3000', + kind: 'local', +}; + +export const remoteProfile: DesktopProfile = { + id: 'remote', + name: 'Team server', + baseUrl: 'https://propr.example.com', + kind: 'remote', +}; + +export const adaptersFor = ( + profiles: DesktopProfile[] = [], + activeId: string | null = null, + probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' }) +): DesktopAdapters => ({ + platform: 'linux', + app: { onDeepLink: () => () => undefined }, + profiles: { + list: vi.fn(async () => profiles), + save: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + getActiveId: vi.fn(async () => activeId), + setActiveId: vi.fn(async () => undefined), + }, + discovery: { supported: true, discover: vi.fn(async () => []) }, + authentication: { authenticate: vi.fn(async () => undefined) }, + externalBrowser: { open: vi.fn(async () => undefined) }, + localSetup: { supported: true, setup: vi.fn(async () => localProfile) }, + connection: { probe: vi.fn(probe) }, +}); + +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(complete => { resolve = complete; }); + return { promise, resolve }; +} + +export const renderConnectedExperience = (adapters: DesktopAdapters, content?: string) => render( + + + {content &&
{content}
} +
+); diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx index df1c64989..92c0e9462 100644 --- a/propr-ui/src/desktop/DesktopExperience.tsx +++ b/propr-ui/src/desktop/DesktopExperience.tsx @@ -1,48 +1,24 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { parseProprConnectEndpoint } from '@propr/shared'; -import { LoaderCircle, Plus, X } from 'lucide-react'; +import { LoaderCircle } from 'lucide-react'; import { setApiBaseUrl } from '../api/apiClient'; import * as runtimeConfig from '../config/runtimeConfig'; import type { DesktopDeepLinkInbox } from '../desktop-deep-link'; -import { DesktopContext } from './DesktopContext'; +import { DesktopConnectedExperience } from './DesktopConnectedExperience'; import { useAttemptFence, useDesktopModal, useSerializedMutationQueue } from './desktopExperienceHooks'; -import { ConnectionPanel, DesktopBrand, InstanceChooser, ManagedRecoveryReview, ProfileEditor, ProfileList } from './DesktopExperiencePanels'; +import { ConnectionPanel, DesktopBrand, InstanceChooser, ManagedRecoveryReview, ProfileEditor } from './DesktopExperiencePanels'; import { managedRecoveryMessage, managedRediscoveryUnavailableMessage, safeConnectionMessage } from './desktopExperienceMessages'; +import { mergeProfiles, recoverableError, settleAuthenticationCancellation, type ExperienceState } from './desktopExperienceState'; import { DESKTOP_ACCESS_INVALID_EVENT, type DesktopAccessInvalidEventDetail, type DesktopAdapters, type DesktopConnectionResult, type DesktopProfile } from './types'; import { useDesktopDeepLinks } from './useDesktopDeepLinks'; import './desktop.css'; -type ExperienceState = - | { phase: 'loading' } - | { phase: 'choose' } - | { phase: 'connecting'; profile: DesktopProfile } - | { phase: 'blocked'; profile: DesktopProfile; result: Exclude } - | { phase: 'recovery-review'; profile: DesktopProfile; candidate: DesktopProfile } - | { phase: 'connected'; profile: DesktopProfile; result: Extract }; - interface DesktopExperienceProps { adapters: DesktopAdapters; deepLinks?: DesktopDeepLinkInbox; children: React.ReactNode; } -const mergeProfiles = (current: DesktopProfile[], incoming: DesktopProfile[]): DesktopProfile[] => { - const profiles = new Map(current.map(profile => [profile.id, profile])); - incoming.forEach(profile => profiles.set(profile.id, profile)); - return [...profiles.values()].sort((a, b) => - (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || '')); -}; - -const recoverableError = (message: string): string => `${message} Try again.`; - -const settleAuthenticationCancellation = (adapters: DesktopAdapters, profileId: string): void => { - // Back/navigation must remain synchronous. Cancellation is best effort and - // its rejection is deliberately consumed so shutdown cannot create an - // unhandled promise containing host-specific IPC details. - void Promise.resolve() - .then(() => adapters.authentication.cancel?.(profileId)) - .catch(() => undefined); -}; export const DesktopExperience: React.FC = ({ adapters, deepLinks, children }) => { const [profiles, setProfiles] = useState([]); const [state, setState] = useState({ phase: 'loading' }); @@ -50,7 +26,6 @@ export const DesktopExperience: React.FC = ({ adapters, const [managerOpen, setManagerOpen] = useState(false); const [operationError, setOperationError] = useState(null); const [busy, setBusy] = useState(false); - const [networkOffline, setNetworkOffline] = useState(!navigator.onLine); const connectionAttempt = useRef(0); const activeProfileId = useRef(null); const stateRef = useRef(state); @@ -90,6 +65,15 @@ export const DesktopExperience: React.FC = ({ adapters, setEditing(null); }, [cancelDiscovery, clearConnectCandidate]); const { dialogRef: managerRef, openModal: openManager } = useDesktopModal(managerOpen, setManagerOpen, closeManager); + const reportAcceptanceStage = useCallback(async ( + stage: Parameters['reportJourneyStage']>[0], + ): Promise => { + try { + await adapters.acceptance?.reportJourneyStage(stage); + } catch { + // Acceptance diagnostics must never alter the renderer lifecycle they observe. + } + }, [adapters]); const connect = useCallback(async (profile: DesktopProfile) => { cancelDiscovery(); @@ -102,6 +86,9 @@ export const DesktopExperience: React.FC = ({ adapters, const probeResult = await adapters.connection.probe(profile); if (!isCurrentAttempt()) return; if (probeResult.status !== 'ready') { + if (probeResult.status === 'authentication-required') { + await reportAcceptanceStage('AUTHENTICATION_REQUIRED'); + } setState({ phase: 'blocked', profile, @@ -109,6 +96,7 @@ export const DesktopExperience: React.FC = ({ adapters, }); return; } + await reportAcceptanceStage('AUTHENTICATED_REPROBE_READY'); operation = 'persist'; const connectedProfile = { ...profile, lastConnectedAt: new Date().toISOString() }; @@ -121,7 +109,10 @@ export const DesktopExperience: React.FC = ({ adapters, result = await adapters.connection.activate(connectedProfile, probeResult, isCurrentAttempt); } else if (activeProfileId.current !== profile.id) await adapters.profiles.setActiveId(profile.id); - if (result.status === 'ready') activeProfileId.current = profile.id; + if (result.status === 'ready') { + activeProfileId.current = profile.id; + await reportAcceptanceStage('ACTIVATION_COMMITTED'); + } }); if (!isCurrentAttempt()) return; setProfiles(current => mergeProfiles(current, [connectedProfile])); @@ -132,6 +123,7 @@ export const DesktopExperience: React.FC = ({ adapters, runtimeConfig.setDesktopApiBaseUrl(connectedProfile.baseUrl); if (adapters.connection.publishActivation) adapters.connection.publishActivation(connectedProfile, result); else setApiBaseUrl(connectedProfile.baseUrl); + await reportAcceptanceStage('ACTIVATION_PUBLISHED'); setState({ phase: 'connected', profile: connectedProfile, result }); } catch { if (!isCurrentAttempt()) return; @@ -140,7 +132,11 @@ export const DesktopExperience: React.FC = ({ adapters, : 'ProPR Desktop could not check this instance. Try again.'; setState({ phase: 'blocked', profile, result: { status: 'offline', message } }); } - }, [adapters, cancelDiscovery, enqueueProfileMutation]); + }, [adapters, cancelDiscovery, enqueueProfileMutation, reportAcceptanceStage]); + + useEffect(() => { + if (state.phase === 'connected') void reportAcceptanceStage('REACT_CONNECTED'); + }, [reportAcceptanceStage, state.phase]); useEffect(() => { let cancelled = false; @@ -189,14 +185,6 @@ export const DesktopExperience: React.FC = ({ adapters, return () => window.removeEventListener(DESKTOP_ACCESS_INVALID_EVENT, accessInvalid); }, [adapters]); - useEffect(() => { - const online = () => setNetworkOffline(false); const offline = () => setNetworkOffline(true); - window.addEventListener('online', online); window.addEventListener('offline', offline); - return () => { - window.removeEventListener('online', online); window.removeEventListener('offline', offline); - }; - }, []); - useEffect(() => { const handleKeyboard = (event: KeyboardEvent) => { const current = stateRef.current; @@ -387,43 +375,25 @@ export const DesktopExperience: React.FC = ({ adapters, if (state.phase === 'loading') return
Opening ProPR…
; if (state.phase === 'connecting') return undefined} onHelp={() => undefined} onReenter={() => undefined} onRediscover={() => undefined} />; if (state.phase === 'recovery-review') return { cancelDiscovery(); setState({ phase: 'blocked', profile: state.profile, result: { status: 'offline', message: managedRecoveryMessage } }); }} onConfirm={() => void connect(state.candidate)} />; - if (state.phase === 'blocked') return void runBlockedAction(state.profile, () => adapters.authentication.authenticate(state.profile), 'ProPR Desktop could not open sign in.', 'ProPR Connect pairing could not be completed.', () => connect(state.profile))} onHelp={() => void runBlockedAction(state.profile, () => adapters.externalBrowser.open('https://propr.dev'), 'ProPR Desktop could not open connection help.')} onReenter={() => reenterManagedEndpoint(state.profile)} onRediscover={() => void rediscoverManagedEndpoint(state.profile)} />; + if (state.phase === 'blocked') return void runBlockedAction(state.profile, async () => { + await adapters.authentication.authenticate(state.profile); + await reportAcceptanceStage('CREDENTIAL_COMMITTED'); + }, 'ProPR Desktop could not open sign in.', 'ProPR Connect pairing could not be completed.', () => connect(state.profile))} onHelp={() => void runBlockedAction(state.profile, () => adapters.externalBrowser.open('https://propr.dev'), 'ProPR Desktop could not open connection help.')} onReenter={() => reenterManagedEndpoint(state.profile)} onRediscover={() => void rediscoverManagedEndpoint(state.profile)} />; if (editing) return
void saveProfile(profile)} />
; return void setupLocal()} onConnectNew={() => openEditor('new')} onDiscover={() => void discover()} onConnect={profile => void connect(profile)} onEdit={openEditor} onRemove={profile => void removeProfile(profile)} />; }; if (state.phase !== 'connected') return
{deepLinkError &&
{deepLinkError}
}{content()}
; - const displayedConnection: DesktopConnectionResult = networkOffline ? { status: 'offline', message: 'This computer is offline.' } : state.result; - const contextValue = { - isDesktop: true as const, platform: adapters.platform, profile: state.profile, - connection: displayedConnection, - openProfileManager: openManager, - authenticate: () => adapters.authentication.authenticate(state.profile), - openConnectionHelp: () => adapters.externalBrowser.open('https://propr.dev'), - retry, - }; - return ( - - {deepLinkError &&
{deepLinkError}
} -
{children}
- {managerOpen && ( -
{ if (event.target === event.currentTarget) closeManager(); }}> -
-
Desktop

Manage instances

- {editing ? ( - void saveProfile(profile, hasPendingConnectCandidate() || editing === 'new' || state.profile.id === profile.id)} /> - ) : ( - <> - {operationError &&
{operationError}
} - { setManagerOpen(false); void connect(profile); }} onEdit={openEditor} onRemove={profile => void removeProfile(profile)} /> - - - )} -
-
- )} -
+ {children} ); }; diff --git a/propr-ui/src/desktop/desktopExperienceState.ts b/propr-ui/src/desktop/desktopExperienceState.ts new file mode 100644 index 000000000..cc13dd001 --- /dev/null +++ b/propr-ui/src/desktop/desktopExperienceState.ts @@ -0,0 +1,27 @@ +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +export type ExperienceState = + | { phase: 'loading' } + | { phase: 'choose' } + | { phase: 'connecting'; profile: DesktopProfile } + | { phase: 'blocked'; profile: DesktopProfile; result: Exclude } + | { phase: 'recovery-review'; profile: DesktopProfile; candidate: DesktopProfile } + | { phase: 'connected'; profile: DesktopProfile; result: Extract }; + +export const mergeProfiles = (current: DesktopProfile[], incoming: DesktopProfile[]): DesktopProfile[] => { + const profiles = new Map(current.map(profile => [profile.id, profile])); + incoming.forEach(profile => profiles.set(profile.id, profile)); + return [...profiles.values()].sort((a, b) => + (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || '')); +}; + +export const recoverableError = (message: string): string => `${message} Try again.`; + +export const settleAuthenticationCancellation = (adapters: DesktopAdapters, profileId: string): void => { + // Back/navigation must remain synchronous. Cancellation is best effort and + // its rejection is deliberately consumed so shutdown cannot create an + // unhandled promise containing host-specific IPC details. + void Promise.resolve() + .then(() => adapters.authentication.cancel?.(profileId)) + .catch(() => undefined); +}; diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts index 9a618facf..f98cc53ea 100644 --- a/propr-ui/src/desktop/electronAdapters.ts +++ b/propr-ui/src/desktop/electronAdapters.ts @@ -122,6 +122,11 @@ export const createElectronDesktopAdapters = (bridge: DesktopBridge): DesktopAda return fromDiscoveryCandidate(candidate); }, }, + ...(bridge.acceptance ? { + acceptance: { + reportJourneyStage: stage => bridge.acceptance!.reportJourneyStage(stage), + }, + } : {}), authentication: { async authenticate(profile) { const security = await bridge.storage.security(); diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts index f58e9cd3d..c6b3f3cbe 100644 --- a/propr-ui/src/desktop/types.ts +++ b/propr-ui/src/desktop/types.ts @@ -71,6 +71,14 @@ export interface DesktopConnectionAdapter { deactivate?(): void; } +export type DesktopAcceptanceJourneyStage = + | 'AUTHENTICATION_REQUIRED' + | 'CREDENTIAL_COMMITTED' + | 'AUTHENTICATED_REPROBE_READY' + | 'ACTIVATION_COMMITTED' + | 'ACTIVATION_PUBLISHED' + | 'REACT_CONNECTED'; + export interface DesktopManagedTunnelRecoveryAdapter { /** * Request a secret-free Connect endpoint refresh for an existing profile. @@ -92,6 +100,10 @@ export interface DesktopAdapters { localSetup: DesktopLocalSetupAdapter; connection: DesktopConnectionAdapter; managedTunnelRecovery?: DesktopManagedTunnelRecoveryAdapter; + /** @internal Authorized packaged-journey evidence; absent in production use. */ + acceptance?: { + reportJourneyStage(stage: DesktopAcceptanceJourneyStage): Promise; + }; } /** diff --git a/test/config-followup.test.ts b/test/config-followup.test.ts index a4d008e94..fe48af4be 100644 --- a/test/config-followup.test.ts +++ b/test/config-followup.test.ts @@ -64,7 +64,7 @@ test('saveSettingsWithRollback returns a specific failure without partial-commit database: testDb, settings: { planner_context_model: 'gpt-5', - pr_review_model: 'claude-sonnet-4-6' + pr_review_model: '' }, publishConfigUpdate: async () => { published += 1;