From b6df6f1a666da3beb5797c453c1b38cdde6a0411 Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 19 Aug 2026 21:23:03 +0000 Subject: [PATCH] feat: add paired Chrome bridge authentication --- chrome-extension/manifest.json | 8 +- chrome-extension/options.css | 83 ++++++++++ chrome-extension/options.html | 29 ++++ chrome-extension/options.js | 61 +++++++ chrome-extension/popup.css | 129 ++++++++------- chrome-extension/popup.html | 14 +- chrome-extension/popup.js | 282 +++++++++++++++++++------------- src/bridge-auth.js | 250 ++++++++++++++++++++++++++++ test/bridge-auth.test.js | 115 +++++++++++++ test/extension-contract.test.js | 179 ++++++++++++++++++++ 10 files changed, 968 insertions(+), 182 deletions(-) create mode 100644 chrome-extension/options.css create mode 100644 chrome-extension/options.html create mode 100644 chrome-extension/options.js create mode 100644 src/bridge-auth.js create mode 100644 test/bridge-auth.test.js create mode 100644 test/extension-contract.test.js diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 7f457cb..fe64b1f 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,14 +1,16 @@ { "manifest_version": 3, "name": "Alta Proxy Tool Bridge", - "version": "1.0.0", - "description": "Send Alta session cookies to the Alta Proxy Tool desktop app.", - "permissions": ["cookies", "activeTab", "clipboardWrite"], + "version": "1.1.0", + "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"], "host_permissions": [ "https://*.avasecurity.com/*", "https://*.avigilon.com/*", "http://127.0.0.1:18247/*" ], + "options_page": "options.html", "action": { "default_popup": "popup.html", "default_icon": { diff --git a/chrome-extension/options.css b/chrome-extension/options.css new file mode 100644 index 0000000..51251b2 --- /dev/null +++ b/chrome-extension/options.css @@ -0,0 +1,83 @@ +:root { + color-scheme: dark; + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; +} + +body { + margin: 0; + background: #1e1e1e; + color: #e0e0e0; +} + +.options-card { + max-width: 560px; + margin: 48px auto; + padding: 28px; + border: 1px solid #3c3c3c; + border-radius: 8px; + background: #252526; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); +} + +h1 { margin-top: 0; color: #75afff; } +h2 { margin-bottom: 6px; font-size: 16px; } +p { line-height: 1.5; } + +label { + display: block; + margin-bottom: 6px; + font-weight: 600; +} + +input { + box-sizing: border-box; + width: 100%; + margin-bottom: 12px; + padding: 11px; + border: 1px solid #555; + border-radius: 4px; + background: #1e1e1e; + color: #fff; + font: inherit; +} + +input:focus { + border-color: #0e7afe; + outline: 2px solid rgba(14, 122, 254, 0.3); +} + +button { + padding: 10px 16px; + border-radius: 4px; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.primary-btn { border: 0; background: #0e7afe; } +.danger-btn { border: 1px solid #f44336; background: transparent; color: #ff7b72; } +button:disabled { cursor: not-allowed; opacity: 0.45; } + +.status { + min-height: 20px; + margin-top: 14px; + font-weight: 600; +} + +.status.success, +.paired { color: #7bd67e; } +.status.error, +.unpaired { color: #ffc36d; } + +.paired-controls { + margin-top: 24px; + 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 new file mode 100644 index 0000000..0879681 --- /dev/null +++ b/chrome-extension/options.html @@ -0,0 +1,29 @@ + + + + + + + Pair Alta Proxy Tool + + + +
+

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.

+
+ + + +
+
+
+

Current pairing

+

Checking...

+ +
+

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

+
+ + + diff --git a/chrome-extension/options.js b/chrome-extension/options.js new file mode 100644 index 0000000..30c7ce2 --- /dev/null +++ b/chrome-extension/options.js @@ -0,0 +1,61 @@ +'use strict'; + +const PAIRING_STORAGE_KEY = 'aptPairingSecret'; +const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +const pairingForm = document.getElementById('pairingForm'); +const pairingSecretInput = document.getElementById('pairingSecret'); +const pairingStatus = document.getElementById('pairingStatus'); +const currentState = document.getElementById('currentState'); +const forgetBtn = document.getElementById('forgetBtn'); + +function setStatus(message, type) { + pairingStatus.textContent = message; + pairingStatus.className = `status ${type}`; +} + +function renderPairingState(paired) { + currentState.textContent = paired ? 'Paired on this Chrome profile.' : 'Not paired.'; + currentState.className = paired ? 'paired' : 'unpaired'; + forgetBtn.disabled = !paired; +} + +async function refreshPairingState() { + const stored = await chrome.storage.local.get(PAIRING_STORAGE_KEY); + const candidate = stored && stored[PAIRING_STORAGE_KEY]; + renderPairingState(PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : '')); +} + +pairingForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const candidate = pairingSecretInput.value.trim(); + pairingSecretInput.value = ''; + + if (!PAIRING_SECRET_PATTERN.test(candidate)) { + setStatus('That secret is not valid. Generate a new pairing secret in APT and paste it exactly.', 'error'); + return; + } + + try { + await chrome.storage.local.set({ [PAIRING_STORAGE_KEY]: candidate }); + setStatus('Pairing saved. You can now use Send to APT from an Alta tab.', 'success'); + renderPairingState(true); + } catch { + setStatus('Chrome could not save the pairing. Try again.', 'error'); + } +}); + +forgetBtn.addEventListener('click', async () => { + try { + await chrome.storage.local.remove(PAIRING_STORAGE_KEY); + renderPairingState(false); + setStatus('Pairing forgotten. Revoke or rotate it in APT as well.', 'success'); + } catch { + setStatus('Chrome could not forget the pairing. Try again.', 'error'); + } +}); + +refreshPairingState().catch(() => { + renderPairingState(false); + setStatus('Chrome could not read pairing state.', 'error'); +}); diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css index e4c5e8d..6f4387b 100644 --- a/chrome-extension/popup.css +++ b/chrome-extension/popup.css @@ -2,120 +2,131 @@ body { margin: 0; padding: 0; - font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; - background: #1E1E1E; - color: #E0E0E0; - font-size: 14px; - min-width: 300px; + min-width: 320px; + background: #1e1e1e; + color: #e0e0e0; + font: 14px/1.4 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; } -.popup-container { - padding: 16px; -} +.popup-container { padding: 16px; } h1 { + margin: 0 0 12px; + color: #0e7afe; font-size: 16px; font-weight: 600; - color: #0E7AFE; - margin: 0 0 12px 0; + letter-spacing: 0.5px; text-align: center; text-transform: uppercase; - letter-spacing: 0.5px; } -.tab-info { - background: #2D2D30; - border: 1px solid #3C3C3C; +.tab-info, +.pairing-info { + margin-bottom: 10px; + padding: 9px 11px; + border: 1px solid #3c3c3c; border-radius: 4px; - padding: 10px 12px; - margin-bottom: 12px; + background: #2d2d30; + color: #aaa; font-size: 13px; - color: #999999; - word-break: break-all; + overflow-wrap: anywhere; } -.tab-info.detected { - color: #4CAF50; - border-color: #4CAF50; +.tab-info.detected, +.pairing-info.paired { + border-color: #4caf50; + color: #7bd67e; } -.tab-info.not-detected { - color: #F44336; - border-color: #F44336; +.tab-info.not-detected, +.pairing-info.unpaired { + border-color: #f0a33a; + color: #ffc36d; } -.action-buttons { - display: grid; - gap: 8px; -} +.action-buttons { display: grid; gap: 8px; } .primary-btn, -.secondary-btn { - display: block; +.secondary-btn, +.link-btn { width: 100%; padding: 10px 16px; - font-family: inherit; - font-size: 14px; - font-weight: bold; - color: white; - background: #0E7AFE; - border: none; border-radius: 4px; cursor: pointer; - transition: background 0.2s ease; + font-family: inherit; + font-size: 14px; + font-weight: 700; } .primary-btn { - background: #0E7AFE; + border: 0; + background: #0e7afe; + color: #fff; } .secondary-btn { - background: #2D2D30; - border: 1px solid #0E7AFE; - color: #E0E0E0; + border: 1px solid #0e7afe; + background: #2d2d30; + color: #e0e0e0; } -.primary-btn:hover:not(:disabled) { - background: #0A5FD9; +.link-btn { + margin: -2px 0 10px; + padding: 5px; + border: 0; + background: transparent; + color: #75afff; + text-decoration: underline; } -.secondary-btn:hover:not(:disabled) { - background: #0E7AFE; -} +.primary-btn:hover:not(:disabled) { background: #0a5fd9; } +.secondary-btn:hover:not(:disabled) { background: #0e7afe; } .primary-btn:disabled, .secondary-btn:disabled { - opacity: 0.5; cursor: not-allowed; + 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; margin-top: 10px; padding: 8px 12px; + border: 1px solid transparent; border-radius: 4px; font-size: 13px; - font-weight: bold; - display: none; - border: 1px solid transparent; + font-weight: 700; } +.status-msg.success, +.status-msg.error, +.status-msg.info { display: block; } + .status-msg.success { - display: block; + border-color: #4caf50; background: rgba(76, 175, 80, 0.1); - color: #4CAF50; - border-color: #4CAF50; + color: #7bd67e; } .status-msg.error { - display: block; + border-color: #f44336; background: rgba(244, 67, 54, 0.1); - color: #F44336; - border-color: #F44336; + color: #ff7b72; } .status-msg.info { - display: block; + border-color: #0e7afe; background: rgba(14, 122, 254, 0.1); - color: #0E7AFE; - border-color: #0E7AFE; + color: #75afff; } + +[hidden] { display: none !important; } diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index a1e04a2..a859867 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -3,19 +3,23 @@ + Alta Proxy Tool Bridge - +

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 a43bb5d..35f7719 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -1,130 +1,182 @@ +'use strict'; + const APT_URL = 'http://127.0.0.1:18247/cookie'; -const APT_TOKEN = 'apt-local-bridge-token'; +const PAIRING_STORAGE_KEY = 'aptPairingSecret'; +const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; -const tabInfo = document.getElementById('tabInfo'); -const sendBtn = document.getElementById('sendBtn'); -const copyBtn = document.getElementById('copyBtn'); -const statusMsg = document.getElementById('statusMsg'); - -let detectedOrigin = null; - -function showStatus(message, type) { - statusMsg.textContent = message; - statusMsg.className = 'status-msg ' + type; -} - -function setActionButtonsDisabled(disabled) { - sendBtn.disabled = disabled; - copyBtn.disabled = disabled; -} - -async function getVaCookieValue() { - if (!detectedOrigin) { - throw new Error('No Alta deployment detected.'); - } - - const cookie = await chrome.cookies.get({ url: detectedOrigin, name: 'va' }); - - if (!cookie || !cookie.value) { - throw new Error('No "va" session cookie found. Are you logged in?'); - } - - if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) { - throw new Error('Session cookie has expired. Please log in again.'); - } - - return cookie.value; -} - -// Check the active tab on popup open -chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - if (!tabs || tabs.length === 0) { - tabInfo.textContent = 'No active tab found.'; - tabInfo.className = 'tab-info not-detected'; - return; - } - - const tab = tabs[0]; - let url; +function isSupportedDeploymentUrl(value) { try { - url = new URL(tab.url); + const url = new URL(value); + const host = url.hostname.toLowerCase(); + return url.protocol === 'https:' && + (host.endsWith('.avasecurity.com') || host.endsWith('.avigilon.com')); } catch { - tabInfo.textContent = 'Cannot read this tab URL.'; - tabInfo.className = 'tab-info not-detected'; - return; + return false; + } +} + +function createPopupController({ + chromeApi, + documentApi, + navigatorApi, + fetchImpl, + confirmCopy +}) { + const tabInfo = documentApi.getElementById('tabInfo'); + 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'); + + let detectedOrigin = null; + let pairingSecret = null; + let busy = false; + + function showStatus(message, type) { + statusMsg.textContent = message; + statusMsg.className = `status-msg ${type}`; } - const hostname = url.hostname; - const isAlta = hostname.endsWith('.avasecurity.com') || hostname.endsWith('.avigilon.com'); - - if (!isAlta) { - tabInfo.textContent = 'This tab is not an Alta deployment.'; - tabInfo.className = 'tab-info not-detected'; - return; + function updateActions() { + const disabled = busy || !detectedOrigin || !pairingSecret; + sendBtn.disabled = disabled; + copyBtn.disabled = disabled; } - detectedOrigin = url.origin; - tabInfo.textContent = 'Detected: ' + hostname; - tabInfo.className = 'tab-info detected'; - setActionButtonsDisabled(false); -}); + function setBusy(value) { + busy = value; + updateActions(); + } -// Send cookie on button click -sendBtn.addEventListener('click', async () => { - if (!detectedOrigin) return; - - setActionButtonsDisabled(true); - showStatus('Retrieving VA token...', 'info'); - - try { - const cookieValue = await getVaCookieValue(); - - showStatus('Sending to Alta Proxy Tool...', 'info'); - - const response = await fetch(APT_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-APT-Token': APT_TOKEN - }, - body: JSON.stringify({ - deploymentUrl: detectedOrigin, - cookieValue - }) - }); - - const data = await response.json(); - - if (data.success) { - showStatus('VA token sent successfully!', 'success'); - } else { - showStatus('Error: ' + (data.message || 'Unknown error'), 'error'); + async function getVaCookieValue() { + if (!detectedOrigin) throw new Error('NO_DEPLOYMENT'); + const cookie = await chromeApi.cookies.get({ url: detectedOrigin, name: 'va' }); + if (!cookie || !cookie.value) throw new Error('MISSING_COOKIE'); + if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) { + throw new Error('EXPIRED_COOKIE'); } - } catch (err) { - if (err.message && err.message.includes('Failed to fetch')) { - showStatus('Alta Proxy Tool is not running.', 'error'); + return cookie.value; + } + + function showCookieError(error, copyOperation = false) { + if (error && error.message === 'MISSING_COOKIE') { + showStatus('No VA token found. Log in to Alta and try again.', 'error'); + } else if (error && error.message === 'EXPIRED_COOKIE') { + showStatus('The VA token has expired. Log in to Alta again.', 'error'); + } else if (copyOperation) { + showStatus('Could not copy the VA token. Check clipboard permission.', 'error'); } else { - showStatus('Error: ' + err.message, 'error'); + showStatus('Could not reach Alta Proxy Tool on this computer.', 'error'); } - } finally { - setActionButtonsDisabled(false); } -}); -// Copy VA token on button click -copyBtn.addEventListener('click', async () => { - if (!detectedOrigin) return; - - setActionButtonsDisabled(true); - showStatus('Retrieving VA token...', 'info'); - - try { - const cookieValue = await getVaCookieValue(); - await navigator.clipboard.writeText(cookieValue); - showStatus('VA token copied to clipboard!', 'success'); - } catch (err) { - showStatus('Error: ' + err.message, 'error'); - } finally { - setActionButtonsDisabled(false); + async function sendToApt() { + if (!detectedOrigin || !pairingSecret || busy) return; + setBusy(true); + let cookieValue = null; + try { + showStatus('Sending to Alta Proxy Tool...', 'info'); + cookieValue = await getVaCookieValue(); + const response = await fetchImpl(APT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-APT-Pairing-Secret': pairingSecret + }, + body: JSON.stringify({ deploymentUrl: detectedOrigin, cookieValue }) + }); + const data = await response.json(); + if (!response.ok || !data || data.success !== true) throw new Error('BRIDGE_REJECTED'); + showStatus('VA token sent successfully.', 'success'); + } catch (error) { + showCookieError(error); + } finally { + cookieValue = null; + setBusy(false); + } } -}); + + 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.' + ); + if (!confirmed) { + showStatus('Copy cancelled. The VA token was not read.', 'info'); + return; + } + + setBusy(true); + let cookieValue = null; + try { + cookieValue = await getVaCookieValue(); + await navigatorApi.clipboard.writeText(cookieValue); + showStatus('VA token copied. Clear your clipboard after use.', 'success'); + } catch (error) { + showCookieError(error, true); + } finally { + cookieValue = null; + setBusy(false); + } + } + + 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()); + + const stored = await chromeApi.storage.local.get(PAIRING_STORAGE_KEY); + const candidate = stored && stored[PAIRING_STORAGE_KEY]; + if (PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : '')) { + pairingSecret = candidate; + pairingInfo.textContent = 'Paired with Alta Proxy Tool'; + pairingInfo.className = 'pairing-info paired'; + openOptionsBtn.hidden = true; + } else { + pairingInfo.textContent = 'Not paired. Add the one-time secret from APT.'; + pairingInfo.className = 'pairing-info unpaired'; + openOptionsBtn.hidden = false; + } + + const tabs = await chromeApi.tabs.query({ active: true, currentWindow: true }); + const tab = tabs && tabs[0]; + if (!tab || !isSupportedDeploymentUrl(tab.url)) { + tabInfo.textContent = 'This tab is not an Alta deployment.'; + tabInfo.className = 'tab-info not-detected'; + updateActions(); + return; + } + + const url = new URL(tab.url); + detectedOrigin = url.origin; + tabInfo.textContent = `Detected: ${url.hostname}`; + tabInfo.className = 'tab-info detected'; + updateActions(); + } + + return { copyToken, init, sendToApt }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + APT_URL, + PAIRING_STORAGE_KEY, + createPopupController, + isSupportedDeploymentUrl + }; +} else { + createPopupController({ + chromeApi: chrome, + documentApi: document, + navigatorApi: navigator, + fetchImpl: fetch, + confirmCopy: (message) => window.confirm(message) + }).init().catch(() => { + const status = document.getElementById('statusMsg'); + status.textContent = 'Extension initialization failed. Open pairing settings and try again.'; + status.className = 'status-msg error'; + }); +} diff --git a/src/bridge-auth.js b/src/bridge-auth.js new file mode 100644 index 0000000..25f889e --- /dev/null +++ b/src/bridge-auth.js @@ -0,0 +1,250 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const DEFAULT_SECRET_BYTES = 32; +const DEFAULT_MAX_BODY_BYTES = 64 * 1024; +const DEFAULT_BODY_DEADLINE_MS = 2_000; +const APT_EXTENSION_ID = 'onbkfpbggekakjddomjjnboippimlmch'; +const APT_EXTENSION_ORIGIN = `chrome-extension://${APT_EXTENSION_ID}`; +const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/; + +class BridgeAuthError extends Error { + constructor(code, message, statusCode = 400) { + super(message); + this.name = 'BridgeAuthError'; + this.code = code; + this.statusCode = statusCode; + } +} + +function dependencies(overrides = {}) { + return { + randomBytes: overrides.randomBytes || crypto.randomBytes, + scryptSync: overrides.scryptSync || crypto.scryptSync, + timingSafeEqual: overrides.timingSafeEqual || crypto.timingSafeEqual, + setTimeout: overrides.setTimeout || setTimeout, + clearTimeout: overrides.clearTimeout || clearTimeout + }; +} + +function generatePairingSecret(options = {}) { + const deps = dependencies(options); + const byteCount = options.byteCount || DEFAULT_SECRET_BYTES; + if (!Number.isSafeInteger(byteCount) || byteCount < DEFAULT_SECRET_BYTES) { + throw new BridgeAuthError('INVALID_SECRET_SIZE', 'Pairing secrets must contain at least 32 random bytes.'); + } + return deps.randomBytes(byteCount).toString('base64url'); +} + +function assertPairingSecret(secret) { + if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret)) { + throw new BridgeAuthError('INVALID_PAIRING_SECRET', 'The pairing secret has an invalid format.'); + } +} + +function hashPairingSecret(secret, options = {}) { + assertPairingSecret(secret); + const deps = dependencies(options); + const salt = deps.randomBytes(16); + const digest = deps.scryptSync(secret, salt, 32); + return Object.freeze({ + version: 1, + algorithm: 'scrypt', + salt: salt.toString('base64url'), + digest: Buffer.from(digest).toString('base64url') + }); +} + +function isValidEnvelope(envelope) { + return Boolean( + envelope && + envelope.version === 1 && + envelope.algorithm === 'scrypt' && + typeof envelope.salt === 'string' && + /^[A-Za-z0-9_-]{22}$/.test(envelope.salt) && + typeof envelope.digest === 'string' && + /^[A-Za-z0-9_-]{43}$/.test(envelope.digest) + ); +} + +function verifyPairingSecret(secret, envelope, options = {}) { + if (!SECRET_PATTERN.test(typeof secret === 'string' ? secret : '') || !isValidEnvelope(envelope)) { + return false; + } + + try { + const deps = dependencies(options); + const salt = Buffer.from(envelope.salt, 'base64url'); + const expected = Buffer.from(envelope.digest, 'base64url'); + const actual = Buffer.from(deps.scryptSync(secret, salt, expected.length)); + return actual.length === expected.length && deps.timingSafeEqual(actual, expected); + } catch { + return false; + } +} + +function assertExtensionOrigin(origin) { + if (typeof origin !== 'string' || !EXTENSION_ORIGIN_PATTERN.test(origin)) { + throw new BridgeAuthError( + 'INVALID_EXTENSION_ORIGIN', + 'Expected an exact stable chrome-extension origin.' + ); + } +} + +class BridgeAuth { + constructor({ expectedOrigin = APT_EXTENSION_ORIGIN, envelope = null, ...injected } = {}) { + assertExtensionOrigin(expectedOrigin); + if (expectedOrigin !== APT_EXTENSION_ORIGIN) { + throw new BridgeAuthError( + 'INVALID_EXTENSION_ORIGIN', + 'The bridge only accepts the committed Alta Proxy Tool extension origin.' + ); + } + if (envelope !== null && !isValidEnvelope(envelope)) { + throw new BridgeAuthError('INVALID_SECRET_ENVELOPE', 'The persisted pairing envelope is invalid.'); + } + this.expectedOrigin = expectedOrigin; + this._envelope = envelope ? Object.freeze({ ...envelope }) : null; + this._dependencies = dependencies(injected); + } + + get envelope() { + return this._envelope ? { ...this._envelope } : null; + } + + rotate() { + const secret = generatePairingSecret(this._dependencies); + this._envelope = hashPairingSecret(secret, this._dependencies); + return { secret, envelope: this.envelope }; + } + + revoke() { + this._envelope = null; + } + + authenticate({ origin, secret } = {}) { + if (origin !== this.expectedOrigin || !this._envelope) return false; + return verifyPairingSecret(secret, this._envelope, this._dependencies); + } + + authenticateRequest(request) { + if (!request || typeof request !== 'object') return false; + const headers = request.headers || {}; + const secret = headers['x-apt-pairing-secret']; + return this.authenticate({ origin: headers.origin, secret }); + } +} + +function readJsonBody(stream, options = {}) { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BODY_BYTES; + const deadlineMs = options.deadlineMs ?? DEFAULT_BODY_DEADLINE_MS; + const deps = dependencies(options); + + if (!stream || typeof stream.on !== 'function') { + return Promise.reject(new BridgeAuthError('INVALID_BODY_STREAM', 'A readable request body is required.')); + } + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + return Promise.reject(new BridgeAuthError('INVALID_BODY_LIMIT', 'Body limit must be a positive integer.')); + } + if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1) { + return Promise.reject(new BridgeAuthError('INVALID_BODY_DEADLINE', 'Body deadline must be a positive integer.')); + } + + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + let settled = false; + + const cleanup = () => { + deps.clearTimeout(timer); + stream.removeListener('data', onData); + stream.removeListener('end', onEnd); + stream.removeListener('error', onError); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + if (typeof stream.pause === 'function') stream.pause(); + reject(error); + }; + const onData = (chunk) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > maxBytes) { + fail(new BridgeAuthError('BODY_TOO_LARGE', 'Request body exceeds the configured limit.', 413)); + return; + } + chunks.push(buffer); + }; + const onError = () => fail(new BridgeAuthError('BODY_READ_ERROR', 'Could not read the request body.')); + const onEnd = () => { + if (settled) return; + settled = true; + cleanup(); + let parsed; + try { + parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + reject(new BridgeAuthError('MALFORMED_JSON', 'Request body must be valid JSON.')); + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + reject(new BridgeAuthError('INVALID_JSON_BODY', 'Request body must be a JSON object.')); + return; + } + resolve(parsed); + }; + + const timer = deps.setTimeout( + () => fail(new BridgeAuthError('BODY_DEADLINE_EXCEEDED', 'Request body deadline exceeded.', 408)), + deadlineMs + ); + stream.on('data', onData); + stream.on('end', onEnd); + stream.on('error', onError); + }); +} + +function createRequestLimiter({ maxConcurrent = 4 } = {}) { + if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) { + throw new BridgeAuthError('INVALID_CONCURRENCY_LIMIT', 'Concurrency limit must be a positive integer.'); + } + let active = 0; + return { + get active() { return active; }, + async run(work) { + if (typeof work !== 'function') { + throw new BridgeAuthError('INVALID_REQUEST_WORK', 'Request work must be a function.'); + } + if (active >= maxConcurrent) { + throw new BridgeAuthError('TOO_MANY_REQUESTS', 'Too many bridge requests are active.', 429); + } + active += 1; + try { + return await work(); + } finally { + active -= 1; + } + } + }; +} + +module.exports = { + APT_EXTENSION_ID, + APT_EXTENSION_ORIGIN, + BridgeAuth, + BridgeAuthError, + DEFAULT_BODY_DEADLINE_MS, + DEFAULT_MAX_BODY_BYTES, + EXTENSION_ORIGIN_PATTERN, + SECRET_PATTERN, + createRequestLimiter, + generatePairingSecret, + hashPairingSecret, + readJsonBody, + verifyPairingSecret +}; diff --git a/test/bridge-auth.test.js b/test/bridge-auth.test.js new file mode 100644 index 0000000..6c535b3 --- /dev/null +++ b/test/bridge-auth.test.js @@ -0,0 +1,115 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { PassThrough, Readable } = require('node:stream'); + +const { + BridgeAuth, + BridgeAuthError, + generatePairingSecret, + hashPairingSecret, + verifyPairingSecret, + readJsonBody, + createRequestLimiter +} = require('../src/bridge-auth'); + +const EXTENSION_ORIGIN = 'chrome-extension://onbkfpbggekakjddomjjnboippimlmch'; + +test('pairing secrets are random, URL-safe, and stored as a hash envelope', () => { + const first = generatePairingSecret(); + const second = generatePairingSecret(); + assert.match(first, /^[A-Za-z0-9_-]{43}$/); + assert.notEqual(first, second); + + const envelope = hashPairingSecret(first); + assert.equal(envelope.version, 1); + assert.equal(envelope.algorithm, 'scrypt'); + assert.equal(Object.values(envelope).includes(first), false); + assert.equal(verifyPairingSecret(first, envelope), true); + assert.equal(verifyPairingSecret(second, envelope), false); + assert.equal(verifyPairingSecret('', envelope), false); + assert.equal(verifyPairingSecret(null, envelope), false); +}); + +test('auth rejects missing/wrong secrets and unknown or malformed origins', () => { + const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN }); + const { secret } = auth.rotate(); + + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret }), true); + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN }), false); + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: 'wrong' }), false); + assert.equal(auth.authenticate({ origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', secret }), false); + assert.equal(auth.authenticate({ origin: `${EXTENSION_ORIGIN}/`, secret }), false); + assert.equal(auth.authenticate({ origin: `${EXTENSION_ORIGIN}\r\nX-Evil: yes`, secret }), false); + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: `${secret}\r\n` }), false); +}); + +test('rotation invalidates the previous secret and revoke invalidates the current secret', () => { + const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN }); + const first = auth.rotate().secret; + const second = auth.rotate().secret; + + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: first }), false); + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), true); + auth.revoke(); + assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), false); + assert.equal(auth.envelope, null); +}); + +test('an envelope can be safely persisted and loaded by a future desktop wiring', () => { + const first = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN }); + const { secret, envelope } = first.rotate(); + const persisted = JSON.parse(JSON.stringify(envelope)); + const restored = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, envelope: persisted }); + + assert.equal(restored.authenticate({ origin: EXTENSION_ORIGIN, secret }), true); + assert.throws( + () => new BridgeAuth({ expectedOrigin: 'https://example.test' }), + (error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN' + ); + assert.throws( + () => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }), + (error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN' + ); +}); + +test('readJsonBody accepts a bounded JSON object and rejects malformed or oversized input', async () => { + assert.deepEqual( + await readJsonBody(Readable.from(['{"ok":true}']), { maxBytes: 64 }), + { ok: true } + ); + + await assert.rejects( + readJsonBody(Readable.from(['{"nope"']), { maxBytes: 64 }), + (error) => error.code === 'MALFORMED_JSON' + ); + await assert.rejects( + readJsonBody(Readable.from(['{"value":"', 'x'.repeat(100), '"}']), { maxBytes: 32 }), + (error) => error.code === 'BODY_TOO_LARGE' + ); + await assert.rejects( + readJsonBody(Readable.from(['[]']), { maxBytes: 64 }), + (error) => error.code === 'INVALID_JSON_BODY' + ); +}); + +test('readJsonBody aborts a slow body at its deadline', async () => { + const stream = new PassThrough(); + const pending = readJsonBody(stream, { maxBytes: 64, deadlineMs: 15 }); + stream.write('{'); + await assert.rejects(pending, (error) => error.code === 'BODY_DEADLINE_EXCEEDED'); +}); + +test('request limiter rejects excess concurrent bodies before work begins', async () => { + const limiter = createRequestLimiter({ maxConcurrent: 1 }); + let release; + const active = limiter.run(() => new Promise((resolve) => { release = resolve; })); + await assert.rejects( + limiter.run(async () => 'never'), + (error) => error.code === 'TOO_MANY_REQUESTS' + ); + release('done'); + assert.equal(await active, 'done'); + assert.equal(limiter.active, 0); +}); diff --git a/test/extension-contract.test.js b/test/extension-contract.test.js new file mode 100644 index 0000000..368dbd3 --- /dev/null +++ b/test/extension-contract.test.js @@ -0,0 +1,179 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { APT_EXTENSION_ID, APT_EXTENSION_ORIGIN } = require('../src/bridge-auth'); + +const ROOT = path.join(__dirname, '..'); +const EXTENSION = path.join(ROOT, 'chrome-extension'); +const OLD_TOKEN = 'apt-local-' + 'bridge-token'; +const EXPECTED_ID = 'onbkfpbggekakjddomjjnboippimlmch'; + +function read(name) { + return fs.readFileSync(path.join(EXTENSION, name), 'utf8'); +} + +function extensionIdFromKey(key) { + const digest = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest().subarray(0, 16); + return [...digest].map((byte) => + String.fromCharCode(97 + (byte >> 4), 97 + (byte & 0x0f)) + ).join(''); +} + +function makeElement() { + const listeners = {}; + return { + textContent: '', + className: '', + disabled: false, + hidden: false, + checked: false, + value: '', + addEventListener(type, listener) { listeners[type] = listener; }, + async dispatch(type) { return listeners[type]?.({ preventDefault() {} }); } + }; +} + +function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null } = {}) { + const elements = Object.fromEntries( + ['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn'] + .map((id) => [id, makeElement()]) + ); + const clipboardWrites = []; + const fetchCalls = []; + let cookieReads = 0; + const chromeApi = { + storage: { local: { get: async () => paired ? { aptPairingSecret: 'A'.repeat(43) } : {} } }, + tabs: { query: async () => [{ url: 'https://customer.avasecurity.com/devices' }] }, + cookies: { get: async () => { cookieReads += 1; return { value: 'sensitive-va-token' }; } }, + runtime: { openOptionsPage() {} } + }; + const navigatorApi = { + clipboard: { + async writeText(value) { + if (clipboardReject) throw clipboardReject; + clipboardWrites.push(value); + } + } + }; + const documentApi = { getElementById: (id) => elements[id] }; + const { createPopupController } = require('../chrome-extension/popup.js'); + const controller = createPopupController({ + chromeApi, + documentApi, + navigatorApi, + confirmCopy: () => confirmCopy, + fetchImpl: async (...args) => { + fetchCalls.push(args); + return { ok: true, json: async () => ({ success: true }) }; + } + }); + return { controller, elements, clipboardWrites, fetchCalls, get cookieReads() { return cookieReads; } }; +} + +test('manifest commits only a public key, stable ID, local storage, and exact loopback host access', () => { + const manifest = JSON.parse(read('manifest.json')); + assert.equal(extensionIdFromKey(manifest.key), EXPECTED_ID); + assert.equal(APT_EXTENSION_ID, EXPECTED_ID); + assert.equal(APT_EXTENSION_ORIGIN, `chrome-extension://${EXPECTED_ID}`); + assert.ok(manifest.permissions.includes('storage')); + assert.equal(manifest.options_page, 'options.html'); + assert.ok(manifest.host_permissions.includes('http://127.0.0.1:18247/*')); + assert.equal(manifest.host_permissions.some((entry) => entry.includes('localhost')), false); + assert.equal(/PRIVATE KEY/.test(manifest.key), false); +}); + +test('extension source contains no old token and sends only to the exact bridge endpoint', () => { + const sources = ['manifest.json', 'popup.js', 'popup.html', 'options.js', 'options.html'] + .map(read).join('\n'); + assert.equal(sources.includes(OLD_TOKEN), false); + assert.match(read('popup.js'), /http:\/\/127\.0\.0\.1:18247\/cookie/); + assert.doesNotMatch(sources, /http:\/\/(?:localhost|\[::1\]|0\.0\.0\.0):18247/); + assert.doesNotMatch(sources, /console\.(?:log|debug|info)\s*\(/); +}); + +test('popup stays disabled while unpaired and directs the user to pairing options', async () => { + const harness = makePopupHarness({ paired: false }); + await harness.controller.init(); + assert.equal(harness.elements.sendBtn.disabled, true); + assert.equal(harness.elements.copyBtn.disabled, true); + assert.match(harness.elements.pairingInfo.textContent, /Not paired/i); + assert.equal(harness.elements.openOptionsBtn.hidden, false); +}); + +test('paired popup enables preserved actions only on a supported Alta HTTPS tab', async () => { + const harness = makePopupHarness({ paired: true }); + await harness.controller.init(); + assert.equal(harness.elements.sendBtn.textContent || 'Send to APT', 'Send to APT'); + assert.equal(harness.elements.copyBtn.textContent || 'Copy VA Token', 'Copy VA Token'); + assert.equal(harness.elements.sendBtn.disabled, false); + assert.equal(harness.elements.copyBtn.disabled, false); + assert.match(harness.elements.pairingInfo.textContent, /Paired/i); +}); + +test('deployment detection rejects non-HTTPS, bare, and lookalike Alta hosts', () => { + const { isSupportedDeploymentUrl } = require('../chrome-extension/popup.js'); + assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com/path'), true); + assert.equal(isSupportedDeploymentUrl('http://customer.avasecurity.com/path'), false); + assert.equal(isSupportedDeploymentUrl('https://avasecurity.com/path'), false); + assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com.evil.test/path'), false); +}); + +test('Send to APT uses the paired secret header and exact endpoint', async () => { + const harness = makePopupHarness({ paired: true }); + await harness.controller.init(); + await harness.controller.sendToApt(); + assert.equal(harness.fetchCalls.length, 1); + const [url, request] = harness.fetchCalls[0]; + assert.equal(url, 'http://127.0.0.1:18247/cookie'); + assert.equal(request.headers['X-APT-Pairing-Secret'], 'A'.repeat(43)); + assert.deepEqual(JSON.parse(request.body), { + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'sensitive-va-token' + }); +}); + +test('copy cancellation occurs before cookie access and never writes the token', async () => { + const harness = makePopupHarness({ paired: true, confirmCopy: false }); + await harness.controller.init(); + await harness.controller.copyToken(); + assert.equal(harness.cookieReads, 0); + assert.deepEqual(harness.clipboardWrites, []); + assert.match(harness.elements.statusMsg.textContent, /cancelled/i); +}); + +test('confirmed copy warns about clipboard history 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.clipboardWrites, ['sensitive-va-token']); + assert.match(harness.elements.statusMsg.textContent, /copied/i); + assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false); +}); + +test('clipboard permission denial is handled without leaking the token', async () => { + const harness = makePopupHarness({ + paired: true, + confirmCopy: true, + clipboardReject: new Error('NotAllowedError') + }); + await harness.controller.init(); + await harness.controller.copyToken(); + assert.match(harness.elements.statusMsg.textContent, /could not copy/i); + assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false); +}); + +test('options UI stores a validated pairing secret locally and can forget pairing', () => { + const html = read('options.html'); + const js = read('options.js'); + assert.match(html, /pairingSecret/); + assert.match(html, /type="password"/); + assert.match(js, /chrome\.storage\.local\.set/); + assert.match(js, /chrome\.storage\.local\.remove/); + assert.match(js, /aptPairingSecret/); + assert.doesNotMatch(js, /console\./); +});