From ace4568f819509e7b79643e7fa7e91ac5c7c0799 Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Sat, 22 Aug 2026 16:20:12 +0000 Subject: [PATCH] feat: release passwordless hierarchical APT v1.2.5 --- CLAUDE.md | 2 +- README.md | 5 +- chrome-extension/manifest.json | 2 +- chrome-extension/options.css | 6 -- chrome-extension/options.html | 4 +- chrome-extension/popup.css | 8 --- chrome-extension/popup.html | 1 - chrome-extension/popup.js | 8 +-- .../2026-08-19-apt-security-foundation.md | 2 +- docs/security/2026-08-security-baseline.md | 4 ++ index.html | 10 ++-- main.js | 2 +- package-lock.json | 4 +- package.json | 2 +- preload.js | 2 +- renderer-controller.js | 11 +++- renderer.js | 51 +++++++++++++--- src/device-projection.js | 11 +++- src/electron-runtime.js | 9 ++- src/proxy-launch.js | 31 +++++----- test/build-contract.test.js | 4 +- test/device-projection.test.js | 58 +++++++++++++++++-- test/extension-contract.test.js | 12 ++-- test/proxy-launch.test.js | 44 +++++++------- test/renderer-controller.test.js | 10 +++- test/renderer-hierarchy.test.js | 1 + test/runtime-contract.test.js | 50 +++++----------- 27 files changed, 209 insertions(+), 145 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0444294..c1abb0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ npm run build - IPC methods are narrow and sender-validated against the main local file frame. - Bridge is exactly `127.0.0.1:18247`, exact committed extension origin, and `X-APT-Pairing` authenticated. - Persist only the pairing hash envelope under Electron `userData`; plaintext is shown once after first run/rotation. -- Proxy helper launches directly with `shell: false`; stop only app-owned child processes. +- Proxy helper launches directly with `shell: false`; passwordless launch uses the main-owned paired session only through the helper's required `-k` argument. Never expose that cookie to renderer IPC, UI, logs, files, shell commands, or tracked process metadata; stop only app-owned child processes. - Update behavior is check-only against the exact GitPeji API. The only follow-up action opens the fixed GitPeji releases page externally. - Never add download/install/replace logic, arbitrary URLs, redirect following, shell scripts, broad process killing, hardcoded bridge secrets, or credential-bearing renderer state. - Synthetic tests only: never use a real tenant or Alta session. diff --git a/README.md b/README.md index 48a4dc6..805924c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ APT is a Windows Electron desktop app that imports an existing Alta Video sessio - The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value. - The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin. - The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it. -- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. Its exact arguments are deployment host, non-secret Alta username/email, and selected device UUID; no session bearer, password, or 2FA value reaches the command line. Stop actions can target only children owned by this app. +- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. APT passes the main-process-only paired Alta session through the helper's supported `-k` cookie mode so users are not prompted for Alta credentials again. The cookie never crosses renderer IPC, enters a shell, or reaches logs, files, tracked process metadata, or UI state; it is necessarily present in the helper process arguments because the external helper exposes no pipe/stdin credential interface. Stop actions can target only children owned by this app. - Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update. ## Requirements @@ -30,8 +30,7 @@ npm start 2. Start APT. Under **Bridge Pairing**, copy the one-time secret. 3. Open the extension pairing settings, paste the secret, and save it. 4. Visit your Alta deployment in Chrome and use **Send to APT**. -5. Select a local camera, enter your Alta username/email, and choose **Start Proxy**. -6. Complete the password and 2FA prompts in the helper's visible Windows console. APT does not collect or pass those secrets. +5. Select a local camera and choose **Start Proxy**. APT reuses the paired Chrome session; no Alta username, password, or 2FA prompt is required. Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication. diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 522b3e1..b1f7f52 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Alta Proxy Tool Bridge", - "version": "1.2.0", + "version": "1.2.5", "description": "Send Alta session cookies to a paired Alta Proxy Tool desktop app.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4EZdkSgOsyiy5DRe0JkX+BpK94FpMjBU59NVIqDPO8QBDwvqNDWT/UjqHK/0aqSxzed5KibX6MdAvc495+u1sCybFdjDdXyBewEvg+PDqGiJketlZKC9dcR1RXHuPgAoM3NaNbMb3TqYcS9J4iGq0UwadxubkQrEcPiuyR6oriOkop8q9/5DWGb15wOGmiCuVmlXfUjNJIvNBm9P/ZHtgFBYDI2PuSSI5GI4j04VFpEyfNlFCrpi8GQ7bYZzezigZWXRjhhNwkx39bNHlkAWYa8XGZseCpKKvi0EaeCoBPjoYSAt161SM1dqX+/UC61/sLOU/SpDB1SYGTm5DC+7wIDAQAB", "permissions": ["cookies", "activeTab", "clipboardWrite", "storage"], diff --git a/chrome-extension/options.css b/chrome-extension/options.css index 51251b2..d659ef4 100644 --- a/chrome-extension/options.css +++ b/chrome-extension/options.css @@ -75,9 +75,3 @@ button:disabled { cursor: not-allowed; opacity: 0.45; } padding-top: 14px; border-top: 1px solid #3c3c3c; } - -.privacy-note { - margin-top: 24px; - color: #aaa; - font-size: 12px; -} diff --git a/chrome-extension/options.html b/chrome-extension/options.html index 0879681..c3a5c29 100644 --- a/chrome-extension/options.html +++ b/chrome-extension/options.html @@ -10,7 +10,7 @@

Pair with Alta Proxy Tool

-

In the desktop app, create a one-time pairing secret. Paste it below on this computer. Treat it like a password.

+

Create a one-time pairing secret in APT, then paste it below.

@@ -22,7 +22,7 @@

Checking...

-

The secret is stored only in Chrome extension local storage. It is never displayed again or copied automatically.

+
diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css index 6f4387b..3bc2b57 100644 --- a/chrome-extension/popup.css +++ b/chrome-extension/popup.css @@ -88,14 +88,6 @@ h1 { opacity: 0.45; } -.copy-warning { - margin: 10px 0 0; - padding: 8px 10px; - border-left: 3px solid #f0a33a; - background: rgba(240, 163, 58, 0.08); - color: #d7c29f; - font-size: 12px; -} .status-msg { display: none; diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index a859867..25214ac 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -17,7 +17,6 @@ -

Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.

diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index 9af6a92..fb91d9b 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -45,7 +45,6 @@ function createPopupController({ const pairingInfo = documentApi.getElementById('pairingInfo'); const sendBtn = documentApi.getElementById('sendBtn'); const copyBtn = documentApi.getElementById('copyBtn'); - const copyWarning = documentApi.getElementById('copyWarning'); const statusMsg = documentApi.getElementById('statusMsg'); const openOptionsBtn = documentApi.getElementById('openOptionsBtn'); @@ -159,9 +158,7 @@ function createPopupController({ async function copyToken() { if (!detectedOrigin || !pairingSecret || busy) return; - const confirmed = confirmCopy( - 'Copy the full VA bearer token? Clipboard history or sync may retain it. Continue only if you will paste it into a trusted destination.' - ); + const confirmed = confirmCopy('Copy VA token?'); if (!confirmed) { showStatus('Copy cancelled. The VA token was not read.', 'info'); return; @@ -172,7 +169,7 @@ function createPopupController({ try { cookieValue = await getVaCookieValue(); await navigatorApi.clipboard.writeText(cookieValue); - showStatus('VA token copied. Clear your clipboard after use.', 'success'); + showStatus('VA token copied.', 'success'); } catch (error) { showCookieError(error, true); } finally { @@ -182,7 +179,6 @@ function createPopupController({ } async function init() { - copyWarning.textContent = 'Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.'; sendBtn.addEventListener('click', sendToApt); copyBtn.addEventListener('click', copyToken); openOptionsBtn.addEventListener('click', () => chromeApi.runtime.openOptionsPage()); diff --git a/docs/plans/2026-08-19-apt-security-foundation.md b/docs/plans/2026-08-19-apt-security-foundation.md index dba08e9..4b5f61b 100644 --- a/docs/plans/2026-08-19-apt-security-foundation.md +++ b/docs/plans/2026-08-19-apt-security-foundation.md @@ -40,7 +40,7 @@ Feature discovery begins only after Phase A passes independent security review a - Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer. - Preserve current Tool Hub downloads and production visibility until Zac approves replacement. - Do not use real Alta cookies in automated tests; use conspicuous synthetic sentinels. -- Do not write cookies to logs, disk, command files, shell strings, crash reports, analytics, or test artifacts. +- Do not write cookies to logs, disk, command files, shell strings, crash reports, analytics, or test artifacts. The sole accepted exception is the fixed helper's required `-k` argv value for Zac's explicitly required paired-extension passwordless proxy flow; keep it main-process-only, direct-spawned with `shell: false`, redacted from errors, and absent from renderer IPC and tracked metadata. - No production release until the exact candidate passes controller verification plus independent security and release reviews. - Mac distribution remains approval-gated under Kanban task `t_42bad4d3`. diff --git a/docs/security/2026-08-security-baseline.md b/docs/security/2026-08-security-baseline.md index df20ddb..5a5defb 100644 --- a/docs/security/2026-08-security-baseline.md +++ b/docs/security/2026-08-security-baseline.md @@ -16,3 +16,7 @@ - Synthetic sentinel credentials only in tests. - Work remains on the isolated hardening branch until review gates pass. - No feature additions until Critical/Important security closure. + +## Accepted passwordless proxy exception + +Zac explicitly requires the paired-extension **Connect → select camera → Start Proxy** flow without re-entering Alta credentials. The external `aware-cam-proxy.exe` helper supports cookie authentication only through `-k`; it exposes no stdin, named-pipe, environment-variable, or credential-file interface. APT therefore passes the validated main-owned session directly to the fixed helper as one argv value. This exception remains bounded by direct `shell: false` spawn, trusted-device allowlisting, no renderer/IPC credential parameter, no logs/files/shells, redacted launch errors, safe tracked metadata, and synthetic regression tests. diff --git a/index.html b/index.html index e8b21cb..465aadb 100644 --- a/index.html +++ b/index.html @@ -51,11 +51,12 @@
+
-
+ @@ -80,11 +82,7 @@ -
- - -
-

The proxy helper opens a Windows console and prompts for your password and 2FA. APT never sends those secrets on its command line.

+

APT uses the paired Chrome extension session. Select a camera, then start the proxy—no Alta credentials need to be entered again.

diff --git a/main.js b/main.js index c12e1f8..15203f7 100644 --- a/main.js +++ b/main.js @@ -81,7 +81,7 @@ function registerIpcHandlers() { registerIpc('get-device-groups', () => runtime.getDeviceGroups()); registerIpc('get-device-hierarchy', () => runtime.getDeviceHierarchy()); registerIpc('get-auth-info', () => runtime.getAuthInfo()); - registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username)); + registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId)); registerIpc('stop-proxy', async (key) => { const result = await runtime.stopProxy(key); sendConnectionState(); diff --git a/package-lock.json b/package-lock.json index 6d12c56..715defb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "alta-api-client", - "version": "1.2.0", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "alta-api-client", - "version": "1.2.0", + "version": "1.2.5", "license": "MIT", "dependencies": { "axios": "1.19.0" diff --git a/package.json b/package.json index 88b45bc..cec0eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alta-api-client", - "version": "1.2.0", + "version": "1.2.5", "description": "Secure Windows Electron client for the Alta Camera Proxy", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index bd60372..1d1b77c 100644 --- a/preload.js +++ b/preload.js @@ -15,7 +15,7 @@ contextBridge.exposeInMainWorld('electronAPI', Object.freeze({ getDeviceGroups: () => ipcRenderer.invoke('get-device-groups'), getDeviceHierarchy: () => ipcRenderer.invoke('get-device-hierarchy'), getAuthInfo: () => ipcRenderer.invoke('get-auth-info'), - launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username), + launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId), stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key), disconnect: () => ipcRenderer.invoke('disconnect'), getConnectionState: () => ipcRenderer.invoke('get-connection-state'), diff --git a/renderer-controller.js b/renderer-controller.js index 84fded4..942c602 100644 --- a/renderer-controller.js +++ b/renderer-controller.js @@ -1,6 +1,15 @@ 'use strict'; (function exposeRendererController(globalScope) { + function shouldShowPairingOnboarding({ + paired = false, + secretVisible = false, + connected = false, + manuallyOpen = false, + } = {}) { + return manuallyOpen || !paired || (secretVisible && !connected); + } + function createRendererController({ disconnect, renderConnectionState, @@ -35,7 +44,7 @@ }); } - const api = Object.freeze({ createRendererController }); + const api = Object.freeze({ createRendererController, shouldShowPairingOnboarding }); if (typeof module !== 'undefined' && module.exports) module.exports = api; if (globalScope) globalScope.AptRendererController = api; }(typeof window !== 'undefined' ? window : undefined)); diff --git a/renderer.js b/renderer.js index 70bd3f8..e0ea6b2 100644 --- a/renderer.js +++ b/renderer.js @@ -10,7 +10,6 @@ const statusIndicator = document.getElementById('statusIndicator'); const deviceSearch = document.getElementById('deviceSearch'); const disconnectBtn = document.getElementById('disconnectBtn'); const selectedDeviceId = document.getElementById('selectedDeviceId'); -const altaUsername = document.getElementById('altaUsername'); const startProxyBtn = document.getElementById('startProxyBtn'); const stopProxyBtn = document.getElementById('stopProxyBtn'); const checkUpdateBtn = document.getElementById('checkUpdateBtn'); @@ -18,11 +17,14 @@ const updateNotice = document.getElementById('updateNotice'); const updateMessage = document.getElementById('updateMessage'); const openReleasesBtn = document.getElementById('openReleasesBtn'); const dismissUpdateBtn = document.getElementById('dismissUpdateBtn'); +const pairingSection = document.getElementById('pairingSection'); +const managePairingBtn = document.getElementById('managePairingBtn'); const pairingState = document.getElementById('pairingState'); const pairingSecret = document.getElementById('pairingSecret'); const pairingSecretRow = document.getElementById('pairingSecretRow'); const rotatePairingBtn = document.getElementById('rotatePairingBtn'); const revokePairingBtn = document.getElementById('revokePairingBtn'); +const hidePairingBtn = document.getElementById('hidePairingBtn'); const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT; let connection = { connected: false, origin: null, activeProxies: [] }; @@ -32,6 +34,17 @@ let sidebarController = null; let loadGeneration = 0; let searchTimer = null; let scrollFrame = null; +let pairingView = { paired: false, secretVisible: false, manuallyOpen: false }; + +function renderPairingVisibility() { + const visible = window.AptRendererController.shouldShowPairingOnboarding({ + ...pairingView, + connected: connection.connected, + }); + pairingSection.hidden = !visible; + managePairingBtn.hidden = visible || !pairingView.paired; + hidePairingBtn.hidden = !pairingView.paired; +} function showStatus(element, message, type) { element.textContent = message; @@ -51,8 +64,7 @@ function deviceStatusFor(device) { function updateProxyButtons() { const id = selectedDevice && selectedDevice.id; const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; - const hasUsername = altaUsername.value.trim().length > 0; - startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active; + startProxyBtn.disabled = !connection.connected || !id || active; stopProxyBtn.disabled = !id || !active; } @@ -67,6 +79,13 @@ function renderConnectionState(state) { dot.className = `status-dot ${connection.connected ? 'online' : 'offline'}`; text.textContent = connection.connected ? 'Connected' : 'Disconnected'; disconnectBtn.disabled = !connection.connected; + if (connection.connected) { + pairingView.secretVisible = false; + pairingView.manuallyOpen = false; + pairingSecret.value = ''; + pairingSecretRow.style.display = 'none'; + } + renderPairingVisibility(); updateProxyButtons(); if (sidebarController) sidebarController.refresh(); } @@ -145,10 +164,9 @@ function installHierarchy(hierarchy) { const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys ? snapshot.state.searchCameraKeys.size : hierarchyModel.cameraCount; - const hidden = snapshot.rows.hiddenSelected ? ' Selected camera is hidden by the current view.' : ''; deviceResults.textContent = snapshot.state.searchQuery - ? `${count} exact search result${count === 1 ? '' : 's'}.${hidden}` - : `${count} camera${count === 1 ? '' : 's'}.${hidden}`; + ? `${count} exact search result${count === 1 ? '' : 's'}.` + : `${count} camera${count === 1 ? '' : 's'}.`; }, }); sidebarController = window.AptSidebarController.createSidebarController({ @@ -201,7 +219,6 @@ const rendererController = window.AptRendererController.createRendererController disconnect: () => window.electronAPI.disconnect(), renderConnectionState, clearDisconnectedState: () => { - altaUsername.value = ''; clearHierarchy(); }, showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type), @@ -216,7 +233,7 @@ async function handleStartProxy() { if (!id) return; startProxyBtn.disabled = true; showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info'); - const result = await window.electronAPI.launchProxy(id, altaUsername.value); + const result = await window.electronAPI.launchProxy(id); if (result.success) { renderConnectionState(await window.electronAPI.getConnectionState()); showStatus(connectionStatus, 'Camera proxy started.', 'success'); @@ -259,6 +276,11 @@ async function checkForUpdates(showCurrent = true) { } function renderPairingStatus(result) { + pairingView = { + paired: result.paired === true, + secretVisible: Boolean(result.secret), + manuallyOpen: Boolean(result.secret) || result.paired !== true, + }; pairingState.textContent = result.paired ? 'Paired' : 'Revoked / not paired'; pairingState.className = result.paired ? 'paired' : 'unpaired'; revokePairingBtn.disabled = !result.paired; @@ -270,6 +292,7 @@ function renderPairingStatus(result) { pairingSecret.value = ''; pairingSecretRow.style.display = 'none'; } + renderPairingVisibility(); } async function runSearch(query) { @@ -340,10 +363,20 @@ deviceSearch.addEventListener('keydown', (event) => { disconnectBtn.addEventListener('click', handleDisconnect); startProxyBtn.addEventListener('click', handleStartProxy); stopProxyBtn.addEventListener('click', handleStopProxy); -altaUsername.addEventListener('input', updateProxyButtons); checkUpdateBtn.addEventListener('click', () => checkForUpdates(true)); openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage()); dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; }); rotatePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.rotatePairing())); revokePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.revokePairing())); +managePairingBtn.addEventListener('click', () => { + pairingView.manuallyOpen = true; + renderPairingVisibility(); +}); +hidePairingBtn.addEventListener('click', () => { + pairingView.manuallyOpen = false; + pairingView.secretVisible = false; + pairingSecret.value = ''; + pairingSecretRow.style.display = 'none'; + renderPairingVisibility(); +}); document.addEventListener('DOMContentLoaded', () => { initialize().catch(() => showStatus(connectionStatus, 'Application initialization failed.', 'error')); }); diff --git a/src/device-projection.js b/src/device-projection.js index 88285d5..e29e14e 100644 --- a/src/device-projection.js +++ b/src/device-projection.js @@ -37,7 +37,9 @@ function projectDevice(raw) { type: nullableString(raw.type), model: nullableString(raw.model), address: nullableString(raw.address), - siteId: nullableId(raw.server_group_id), + // server_group_id identifies the Alta server/cloud connector, not a device site. + // The tree infers the site through device_group_id -> deviceGroups.parent_id. + siteId: null, deviceGroupId: nullableId(raw.device_group_id), displayStatus: nullableString(raw.live && raw.live.display_status), localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage), @@ -89,17 +91,20 @@ function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadat throw new TypeError('Hierarchy projection requires arrays'); } const deviceProjection = projectArray(devices, projectDevice); + const eligibleDevices = deviceProjection.projected.filter((device) => device.localStorage !== true); + const cloudNativeExcluded = deviceProjection.projected.length - eligibleDevices.length; const siteProjection = projectArray(sites, projectSite); const groupProjection = projectArray(groups, projectGroup); const payload = { - devices: deviceProjection.projected, + devices: eligibleDevices, sites: siteProjection.projected, groups: groupProjection.projected, diagnostics: { devices: { received: devices.length, - eligible: deviceProjection.projected.length, + eligible: eligibleDevices.length, invalid: deviceProjection.invalid, + cloudNativeExcluded, }, sites: { received: sites.length, diff --git a/src/electron-runtime.js b/src/electron-runtime.js index 832af90..d0cdc74 100644 --- a/src/electron-runtime.js +++ b/src/electron-runtime.js @@ -11,7 +11,7 @@ const { } = require('./bridge-auth'); const { RELEASES_PAGE_URL } = require('./update-policy'); const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection'); -const { validateDeviceId, validateUsername } = require('./proxy-launch'); +const { validateCookie, validateDeviceId } = require('./proxy-launch'); const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000; @@ -23,7 +23,7 @@ function safeErrorMessage(error, fallback) { 'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE', 'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_TOO_LARGE', 'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND', - 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED', + 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_SESSION_COOKIE', 'SPAWN_FAILED', ]); return error && allowed.has(error.code) && typeof error.message === 'string' ? error.message @@ -363,10 +363,9 @@ class AppRuntime { } } - async launchProxy(deviceId, username) { + async launchProxy(deviceId) { try { const validatedId = validateDeviceId(deviceId); - const validatedUsername = validateUsername(username); this._reconcileProxies(); if (!this.allowedDeviceDiscovery || !this._isCurrentDiscovery(this.allowedDeviceDiscovery) || !this.allowedDeviceIds.has(validatedId)) { @@ -378,7 +377,7 @@ class AppRuntime { const session = this.sessionStore.requireSession(); const result = this.proxyManager.launchProxy({ deploymentHost: new URL(session.origin).hostname, - username: validatedUsername, + cookie: validateCookie(session.cookie), deviceId: validatedId, }); this.proxyByDevice.set(validatedId, result.processId); diff --git a/src/proxy-launch.js b/src/proxy-launch.js index 58dffb2..f5da56d 100644 --- a/src/proxy-launch.js +++ b/src/proxy-launch.js @@ -5,7 +5,7 @@ const nodePath = require('node:path'); const { spawn: nodeSpawn } = require('node:child_process'); const HELPER_FILENAME = 'aware-cam-proxy.exe'; -const MAX_USERNAME_LENGTH = 254; +const MAX_COOKIE_LENGTH = 4096; const MAX_HOST_LENGTH = 253; const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; @@ -42,19 +42,20 @@ function validateDeviceId(value) { return value.toLowerCase(); } -function validateUsername(value) { - if (typeof value !== 'string' || value.length === 0 || value.length > MAX_USERNAME_LENGTH || value.trim().length === 0) { - throw new ProxyLaunchError('Alta username is invalid.', 'INVALID_USERNAME'); +function validateCookie(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) { + throw new ProxyLaunchError('Alta session cookie is invalid.', 'INVALID_SESSION_COOKIE'); } - if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) { - throw new ProxyLaunchError('Alta username must not contain control characters.', 'INVALID_USERNAME'); + if (/[;\u0000-\u001f\u007f-\u009f]/.test(value)) { + throw new ProxyLaunchError('Alta session cookie must not contain separators or control characters.', 'INVALID_SESSION_COOKIE'); } return value; } -function safeProcessError(error) { +function safeProcessError(error, secret) { const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error'; - return source.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512); + const redacted = secret ? source.split(secret).join('[REDACTED]') : source; + return redacted.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512); } function safeMetadata(entry, status = entry.status) { @@ -129,7 +130,7 @@ function createProxyManager({ const deploymentHost = validateDeploymentHost(request.deploymentHost); const deviceId = validateDeviceId(request.deviceId); - const username = validateUsername(request.username); + const cookie = validateCookie(request.cookie); if (!fs.existsSync(helperPath)) { throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND'); @@ -139,17 +140,17 @@ function createProxyManager({ try { child = spawn( helperPath, - ['-a', deploymentHost, '-u', username, '-d', deviceId], + ['-a', deploymentHost, '-d', deviceId, '-k', cookie], { shell: false, detached: true, - stdio: 'inherit', + stdio: 'ignore', windowsHide: false } ); } catch (error) { throw new ProxyLaunchError( - `Failed to launch proxy helper: ${safeProcessError(error)}`, + `Failed to launch proxy helper: ${safeProcessError(error, cookie)}`, 'SPAWN_FAILED' ); } @@ -268,10 +269,10 @@ function createProxyManager({ module.exports = { DEFAULT_STOP_TIMEOUT_MS, HELPER_FILENAME, - MAX_USERNAME_LENGTH, + MAX_COOKIE_LENGTH, ProxyLaunchError, createProxyManager, + validateCookie, validateDeploymentHost, - validateDeviceId, - validateUsername + validateDeviceId }; diff --git a/test/build-contract.test.js b/test/build-contract.test.js index 0b25f65..46a7a47 100644 --- a/test/build-contract.test.js +++ b/test/build-contract.test.js @@ -11,7 +11,7 @@ const ROOT = path.join(__dirname, '..'); const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8'); const readJson = (name) => JSON.parse(read(name)); -function writeKit(root, { version = '1.2.0', legacy = false } = {}) { +function writeKit(root, { version = '1.2.5', legacy = false } = {}) { const extension = path.join(root, 'chrome-extension'); fs.mkdirSync(extension, { recursive: true }); fs.writeFileSync(path.join(root, 'AltaCameraProxy.exe'), 'synthetic executable'); @@ -31,7 +31,7 @@ test('application and extension versions and supported dependencies stay coordin const lock = readJson('package-lock.json'); const manifest = readJson('chrome-extension/manifest.json'); - assert.equal(pkg.version, '1.2.0'); + assert.equal(pkg.version, '1.2.5'); assert.equal(manifest.version, pkg.version); assert.equal(lock.version, pkg.version); assert.equal(lock.packages[''].version, pkg.version); diff --git a/test/device-projection.test.js b/test/device-projection.test.js index 3fa29c5..64abe1a 100644 --- a/test/device-projection.test.js +++ b/test/device-projection.test.js @@ -6,6 +6,7 @@ const { MAX_PROJECTED_PAYLOAD_BYTES, projectDeviceHierarchy, } = require('../src/device-projection'); +const { buildDeviceTree } = require('../device-tree'); function uuid(index) { return `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`; @@ -18,7 +19,7 @@ function rawDevice(index, overrides = {}) { type: 'camera', model: 'Synthetic', address: `10.0.${Math.floor(index / 255)}.${index % 255}`, - server_group_id: 'site-1', + server_group_id: 'server-group-1', device_group_id: 'group-1', capabilities: { localStorage: index % 2 === 0, secretCapability: 'drop-me' }, live: { display_status: 'online', private: 'drop-me' }, @@ -29,25 +30,68 @@ function rawDevice(index, overrides = {}) { test('projects only allowlisted fields and honestly diagnoses ineligible devices', () => { const payload = projectDeviceHierarchy({ - devices: [rawDevice(1), rawDevice(2, { guid: 'not-a-uuid' })], + devices: [ + rawDevice(1), + rawDevice(2, { guid: 'not-a-uuid' }), + rawDevice(4), + ], sites: [{ id: 'site-1', name: 'HQ', pending_deletion_start: null, secret: 'drop-me' }], groups: [{ id: 'group-1', name: 'Lobby', parent_id: null, pending_deletion_start: null, secret: 'drop-me' }], }); assert.deepEqual(payload.devices, [{ id: uuid(1), name: 'Camera 1', type: 'camera', model: 'Synthetic', address: '10.0.0.1', - siteId: 'site-1', deviceGroupId: 'group-1', displayStatus: 'online', localStorage: false, + siteId: null, deviceGroupId: 'group-1', displayStatus: 'online', localStorage: false, }]); assert.deepEqual(payload.sites, [{ id: 'site-1', name: 'HQ', pendingDeletionStart: null }]); assert.deepEqual(payload.groups, [{ id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null }]); - assert.deepEqual(payload.diagnostics.devices, { received: 2, eligible: 1, invalid: 1 }); + assert.deepEqual(payload.diagnostics.devices, { + received: 3, eligible: 1, invalid: 1, cloudNativeExcluded: 1, + }); assert.doesNotMatch(JSON.stringify(payload), /drop-me|cookie|secretCapability|private/); }); +test('infers camera sites from device-group parents instead of server groups', () => { + const payload = projectDeviceHierarchy({ + devices: [rawDevice(3, { + name: 'Front Door', + server_group_id: 'cloud-connector-server-group', + device_group_id: 'entrances', + })], + sites: [{ id: 'test-site', name: 'Test Site' }], + groups: [{ id: 'entrances', name: 'Entrances', parent_id: 'test-site' }], + }); + const model = buildDeviceTree(payload); + const camera = [...model.cameraByKey.values()][0]; + + assert.equal(payload.devices[0].siteId, null); + assert.equal(camera.site.canonicalId, 'test-site'); + assert.equal(camera.group.canonicalId, 'entrances'); + assert.equal(camera.hierarchyStatus, 'inferred-site'); + assert.deepEqual(model.diagnostics, []); +}); + +test('excludes cloud-native cameras while retaining Cloud Connector and unknown-storage devices', () => { + const payload = projectDeviceHierarchy({ + devices: [ + rawDevice(10, { name: 'Cloud Native', capabilities: { localStorage: true } }), + rawDevice(11, { name: 'Cloud Connector', capabilities: { localStorage: false } }), + rawDevice(12, { name: 'Unknown Storage', capabilities: {} }), + ], + }); + + assert.deepEqual(payload.devices.map((device) => device.name), ['Cloud Connector', 'Unknown Storage']); + assert.deepEqual(payload.diagnostics.devices, { + received: 3, eligible: 2, invalid: 0, cloudNativeExcluded: 1, + }); +}); + test('projects synthetic 1,500 and 10,000 camera deployments below the 8 MiB IPC cap', () => { for (const count of [1_500, 10_000]) { const payload = projectDeviceHierarchy({ - devices: Array.from({ length: count }, (_, index) => rawDevice(index)), + devices: Array.from({ length: count }, (_, index) => rawDevice(index, { + capabilities: { localStorage: false }, + })), sites: [], groups: [], }); @@ -80,6 +124,7 @@ test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding ca address: 'Hall \ud800\udc00 / \udc00', server_group_id: 'site-\ud800', device_group_id: 'group-\udc00', + capabilities: { localStorage: false }, })], sites: [{ id: 'site-\ud800', name: 'Valid \ud83d\udcf7 \udc00', pending_deletion_start: '\ud800' }], groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parent_id: 'site-\ud800' }], @@ -90,7 +135,7 @@ test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding ca assert.equal(payload.devices[0].name, 'Front \ufffd camera'); assert.equal(payload.devices[0].model, 'Model \ufffd'); assert.equal(payload.devices[0].address, 'Hall \ud800\udc00 / \ufffd'); - assert.equal(payload.devices[0].siteId, 'site-\ufffd'); + assert.equal(payload.devices[0].siteId, null); assert.equal(payload.devices[0].deviceGroupId, 'group-\ufffd'); assert.deepEqual(payload.sites, [{ id: 'site-\ufffd', name: 'Valid \ud83d\udcf7 \ufffd', pendingDeletionStart: '\ufffd' }]); assert.deepEqual(payload.groups, [{ id: 'group-\ufffd', name: 'Group \ud83d\udcf7 \ufffd', parentId: 'site-\ufffd', pendingDeletionStart: null }]); @@ -101,6 +146,7 @@ test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding ca test('rejects a projected hierarchy payload above 8 MiB', () => { const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, { name: `Camera ${index} ${'x'.repeat(900)}`, + capabilities: { localStorage: false }, })); assert.throws( () => projectDeviceHierarchy({ devices, sites: [], groups: [] }), diff --git a/test/extension-contract.test.js b/test/extension-contract.test.js index 6c47b53..b42e7e7 100644 --- a/test/extension-contract.test.js +++ b/test/extension-contract.test.js @@ -39,10 +39,11 @@ function makeElement() { function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null, validServerProof = true } = {}) { const elements = Object.fromEntries( - ['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn'] + ['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'openOptionsBtn'] .map((id) => [id, makeElement()]) ); const clipboardWrites = []; + const confirmationMessages = []; const fetchCalls = []; let cookieReads = 0; const chromeApi = { @@ -65,7 +66,7 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject chromeApi, documentApi, navigatorApi, - confirmCopy: () => confirmCopy, + confirmCopy: (message) => { confirmationMessages.push(message); return confirmCopy; }, cryptoApi: crypto.webcrypto, fetchImpl: async (...args) => { fetchCalls.push(args); @@ -81,7 +82,7 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject return { ok: true, json: async () => ({ success: true }) }; } }); - return { controller, elements, clipboardWrites, fetchCalls, get cookieReads() { return cookieReads; } }; + return { controller, elements, clipboardWrites, confirmationMessages, fetchCalls, get cookieReads() { return cookieReads; } }; } test('manifest commits only a public key, stable ID, local storage, and exact loopback host access', () => { @@ -174,11 +175,11 @@ test('copy cancellation occurs before cookie access and never writes the token', assert.match(harness.elements.statusMsg.textContent, /cancelled/i); }); -test('confirmed copy warns about clipboard history and reports success without exposing token', async () => { +test('confirmed copy uses one concise prompt and reports success without exposing token', async () => { const harness = makePopupHarness({ paired: true, confirmCopy: true }); await harness.controller.init(); - assert.match(harness.elements.copyWarning.textContent, /clipboard (?:history|sync)/i); await harness.controller.copyToken(); + assert.deepEqual(harness.confirmationMessages, ['Copy VA token?']); assert.deepEqual(harness.clipboardWrites, ['sensitive-va-token']); assert.match(harness.elements.statusMsg.textContent, /copied/i); assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false); @@ -201,6 +202,7 @@ test('options UI stores a validated pairing secret locally and can forget pairin const js = read('options.js'); assert.match(html, /pairingSecret/); assert.match(html, /type="password"/); + assert.doesNotMatch(html, /treat it like a password|privacy-note/i); assert.match(js, /chrome\.storage\.local\.set/); assert.match(js, /chrome\.storage\.local\.remove/); assert.match(js, /aptPairingSecret/); diff --git a/test/proxy-launch.test.js b/test/proxy-launch.test.js index 10c4e64..73d603e 100644 --- a/test/proxy-launch.test.js +++ b/test/proxy-launch.test.js @@ -11,7 +11,7 @@ const APPROVED_DIRECTORY = 'C:\\Program Files\\Alta Proxy Tool'; const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe'; const VALID_HOST = 'tenant.avasecurity.com'; const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000'; -const VALID_USERNAME = 'proxy.operator+apt@example.com'; +const SYNTHETIC_COOKIE = 'HERMES_SENTINEL_SESSION_COOKIE'; function loadModule() { return require(MODULE_PATH); @@ -67,7 +67,7 @@ function validRequest(overrides = {}) { return { deploymentHost: VALID_HOST, deviceId: VALID_DEVICE_ID, - username: VALID_USERNAME, + cookie: SYNTHETIC_COOKIE, ...overrides }; } @@ -76,18 +76,18 @@ test('exports the proxy manager module', () => { assert.doesNotThrow(() => loadModule()); }); -test('launches the approved helper directly in a visible interactive console', () => { +test('launches the approved helper directly with the paired Alta session', () => { const { manager, calls } = createHarness(); const result = manager.launchProxy(validRequest()); assert.deepEqual(calls, [[ APPROVED_HELPER, - ['-a', VALID_HOST, '-u', VALID_USERNAME, '-d', VALID_DEVICE_ID], + ['-a', VALID_HOST, '-d', VALID_DEVICE_ID, '-k', SYNTHETIC_COOKIE], { shell: false, detached: true, - stdio: 'inherit', + stdio: 'ignore', windowsHide: false } ]]); @@ -100,16 +100,15 @@ test('launches the approved helper directly in a visible interactive console', ( }); }); -test('passes username punctuation literally in argv without invoking a shell', () => { - const username = 'proxy+apt&literal|name@example.com'; +test('passes the paired cookie literally without invoking a shell or console prompt', () => { + const cookie = 'abc_DEF-123.456=='; const { manager, calls } = createHarness(); - manager.launchProxy(validRequest({ username })); + manager.launchProxy(validRequest({ cookie })); - assert.equal(calls[0][1][3], username); + assert.equal(calls[0][1][5], cookie); assert.equal(calls[0][2].shell, false); - assert.equal(calls[0][2].stdio, 'inherit'); - assert.notEqual(calls[0][2].stdio, 'ignore'); + assert.equal(calls[0][2].stdio, 'ignore'); }); test('rejects the CRLF calc.exe reproducer in every structured input', () => { @@ -117,8 +116,8 @@ test('rejects the CRLF calc.exe reproducer in every structured input', () => { const attacks = [ { deploymentHost: `${VALID_HOST}\r\ncalc.exe` }, { deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` }, - { username: `${VALID_USERNAME}\r\ncalc.exe` }, - { username: `${VALID_USERNAME}\0calc.exe` } + { cookie: `${SYNTHETIC_COOKIE}\r\ncalc.exe` }, + { cookie: `${SYNTHETIC_COOKIE}\0calc.exe` } ]; for (const attack of attacks) { @@ -191,10 +190,10 @@ test('normalizes a valid Alta hostname to lowercase', () => { assert.equal(calls[0][1][1], 'tenant.avigilon.com'); }); -test('rejects empty, oversized, non-string, and control-character usernames', () => { - for (const username of ['', 'x'.repeat(255), 42, 'user\nname', 'user\rname', 'user\0name', 'user\u007fname']) { +test('rejects empty, oversized, non-string, and control-character cookies', () => { + for (const cookie of ['', 'x'.repeat(4097), 42, 'abc\nxyz', 'abc\rxyz', 'abc\0xyz', 'abc;xyz']) { const { manager } = createHarness(); - assert.throws(() => manager.launchProxy(validRequest({ username })), /username/i); + assert.throws(() => manager.launchProxy(validRequest({ cookie })), /cookie/i); } }); @@ -218,25 +217,27 @@ test('requires an absolute approved application directory and Windows platform', ); }); -test('reports a bounded spawn failure without credential redaction machinery', () => { +test('redacts the cookie if spawn throws an error containing it', () => { const { createProxyManager } = loadModule(); const manager = createProxyManager({ appDirectory: APPROVED_DIRECTORY, fs: { existsSync: () => true }, platform: 'win32', - spawn: () => { throw new Error('spawn failed'); } + spawn: () => { throw new Error(`spawn failed for ${SYNTHETIC_COOKIE}`); } }); assert.throws( () => manager.launchProxy(validRequest()), (error) => { assert.match(error.message, /spawn failed/); + assert.match(error.message, /\[REDACTED\]/); + assert.doesNotMatch(error.message, /HERMES_SENTINEL_SESSION_COOKIE/); return true; } ); }); -test('tracks only safe process metadata and never exposes usernames', () => { +test('tracks only safe process metadata and never exposes cookies', () => { const { manager } = createHarness(); manager.launchProxy(validRequest()); @@ -247,7 +248,7 @@ test('tracks only safe process metadata and never exposes usernames', () => { startedAt: 1_777_777_777_777, status: 'running' }]); - assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/); + assert.doesNotMatch(JSON.stringify(tracked), /HERMES_SENTINEL_SESSION_COOKIE/); }); test('stopping one tracked process waits for confirmed exit and leaves the other alive', async () => { @@ -381,9 +382,8 @@ test('exit events remove only the matching owned child', () => { assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]); }); -test('source contains no bearer argv, shell launchers, broad process killers, or persistence APIs', () => { +test('source contains no shell launchers, broad process killers, or credential persistence APIs', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8'); - assert.doesNotMatch(source, /cookie|bearer|token|-k/i); assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i); assert.doesNotMatch(source, /\.(?:bat|command)\b/i); assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/); diff --git a/test/renderer-controller.test.js b/test/renderer-controller.test.js index 841097e..b335241 100644 --- a/test/renderer-controller.test.js +++ b/test/renderer-controller.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { createRendererController } = require('../renderer-controller'); +const { createRendererController, shouldShowPairingOnboarding } = require('../renderer-controller'); function harness(result) { const calls = []; @@ -39,3 +39,11 @@ test('successful disconnect renders disconnected state and clears device state', ['status', 'Disconnected from Alta.', 'info'], ]); }); + +test('pairing onboarding is temporary but remains recoverable', () => { + assert.equal(shouldShowPairingOnboarding({ paired: false }), true); + assert.equal(shouldShowPairingOnboarding({ paired: true, secretVisible: true }), true); + assert.equal(shouldShowPairingOnboarding({ paired: true }), false); + assert.equal(shouldShowPairingOnboarding({ paired: true, secretVisible: true, connected: true }), false); + assert.equal(shouldShowPairingOnboarding({ paired: true, connected: true, manuallyOpen: true }), true); +}); diff --git a/test/renderer-hierarchy.test.js b/test/renderer-hierarchy.test.js index 0eebc29..8bf9998 100644 --- a/test/renderer-hierarchy.test.js +++ b/test/renderer-hierarchy.test.js @@ -40,6 +40,7 @@ test('renderer uses one hierarchy request and no parallel flat discovery', () => assert.match(renderer, /electronAPI\.getDeviceHierarchy\(\)/); assert.doesNotMatch(renderer, /electronAPI\.getDevices\(|electronAPI\.getDeviceSites\(|Promise\.all\(\s*\[\s*window\.electronAPI\.get/); assert.doesNotMatch(renderer, /\ballDevices\b|\ballSites\b|\bcollapsedSites\b|groupDevicesBySite/); + assert.doesNotMatch(renderer, /Selected camera is hidden|hidden by the current view/i); assert.match(renderer, /loadGeneration/); assert.match(renderer, /generation\s*!==\s*loadGeneration/); }); diff --git a/test/runtime-contract.test.js b/test/runtime-contract.test.js index 58664c6..7491144 100644 --- a/test/runtime-contract.test.js +++ b/test/runtime-contract.test.js @@ -92,14 +92,11 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- localStorage: null, }], }); - const launched = await runtime.launchProxy( - '550e8400-e29b-41d4-a716-446655440000', - 'proxy.operator@example.com' - ); + const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000'); assert.equal(launched.success, true); assert.deepEqual(calls[0], { deploymentHost: 'customer.avasecurity.com', - username: 'proxy.operator@example.com', + cookie: 'top-secret-cookie', deviceId: '550e8400-e29b-41d4-a716-446655440000', }); assert.deepEqual(runtime.getConnectionState(), { @@ -189,7 +186,7 @@ test('newest hierarchy discovery owns the launch allowlist when completions arri const freshB = runtime.getDeviceHierarchy(); pending[1]([{ guid: deviceB }]); assert.equal((await freshB).success, true); - assert.equal((await runtime.launchProxy(deviceB, 'operator@example.com')).success, true); + assert.equal((await runtime.launchProxy(deviceB)).success, true); pending[0]([{ guid: deviceA }]); assert.deepEqual(await staleA, { success: false, @@ -197,7 +194,7 @@ test('newest hierarchy discovery owns the launch allowlist when completions arri hierarchy: { devices: [], sites: [], groups: [] }, message: 'Alta device discovery result is stale', }); - assert.equal((await runtime.launchProxy(deviceA, 'operator@example.com')).success, false); + assert.equal((await runtime.launchProxy(deviceA)).success, false); assert.deepEqual(launches, [deviceB]); }); @@ -248,7 +245,7 @@ test('launch allowlist remains bound to the session origin that produced it', as }); assert.equal((await runtime.getDeviceHierarchy()).success, true); sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie'); - assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).success, false); + assert.equal((await runtime.launchProxy(deviceId)).success, false); assert.equal(launches, 0); }); @@ -256,28 +253,6 @@ test('runtime rejects discovery deadlines above 60 seconds', () => { assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i); }); -test('runtime rejects invalid usernames before calling the proxy manager', async () => { - const sessionStore = createSessionStore(); - sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie'); - const deviceId = '550e8400-e29b-41d4-a716-446655440000'; - let launches = 0; - const runtime = new AppRuntime({ - sessionStore, - altaClient: { getDevices: async () => [{ guid: deviceId }] }, - proxyManager: { - launchProxy() { launches += 1; }, - listTrackedProxies() { return []; }, - }, - }); - await runtime.getDevices(); - - for (const username of ['', 'x'.repeat(255), 'operator@example.com\r\n-k secret', null]) { - const result = await runtime.launchProxy(deviceId, username); - assert.equal(result.success, false); - assert.match(result.message, /username/i); - } - assert.equal(launches, 0); -}); test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => { const protect = (value) => Buffer.from(`protected:${value}`); @@ -403,10 +378,10 @@ test('runtime reconciles exited children and permits relaunch for the same devic }, }); await runtime.getDevices(); - assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4101); + assert.equal((await runtime.launchProxy(deviceId)).processId, 4101); tracked.length = 0; assert.deepEqual(runtime.getConnectionState().activeProxies, []); - assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4102); + assert.equal((await runtime.launchProxy(deviceId)).processId, 4102); }); test('disconnect stops every owned proxy before clearing the Alta session', async () => { @@ -474,7 +449,7 @@ test('kill request without exit keeps proxy tracked and session connected until setTimeout(callback) { timeoutCallback = callback; return 1; }, clearTimeout() {}, }); - proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', username: 'operator@example.com', deviceId }); + proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', cookie: 'synthetic-cookie', deviceId }); const runtime = new AppRuntime({ sessionStore, altaClient: {}, proxyManager }); const firstDisconnect = runtime.disconnect(); @@ -525,13 +500,16 @@ test('preload and renderer expose only narrow, credential-free contracts', () => ]; for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`)); assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/); - assert.match(preload, /launchProxy:\s*\(deviceId, username\)/); + assert.match(preload, /launchProxy:\s*\(deviceId\)/); assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i); assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/); assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/); assert.match(read('main.js'), /onConnectionStateChanged:\s*\(\)\s*=>\s*\{\s*runtime\.onSessionChanged\(\)/); - assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/); - assert.match(html, /id="altaUsername"/); + assert.doesNotMatch(html, /id="cookieKey"|id="altaUsername"|type="password"|updateProgress|Install Update/); + assert.match(html, /paired Chrome extension session/i); + assert.match(html, /id="pairingSection"[^>]*hidden/); + assert.match(html, /id="managePairingBtn"/); + assert.match(renderer, /shouldShowPairingOnboarding/); assert.match(renderer, /openFixedReleasesPage/); assert.match(html, /Bridge Pairing/); });