From cf75ea9bc242622b308612b25170ae689f6ef525 Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 19 Aug 2026 21:59:19 +0000 Subject: [PATCH] fix: close APT adversarial runtime gaps --- chrome-extension/popup.js | 76 +++++++++- main.js | 17 ++- src/bridge-auth.js | 257 ++++++++++++++++++++------------ src/electron-runtime.js | 85 ++++++++--- src/update-policy.js | 31 +++- test/bridge-auth.test.js | 199 +++++++++++++++++-------- test/extension-contract.test.js | 47 ++++-- test/runtime-contract.test.js | 175 ++++++++++++++++++---- test/update-policy.test.js | 34 +++++ 9 files changed, 688 insertions(+), 233 deletions(-) diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index 55a1832..9af6a92 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -1,8 +1,26 @@ 'use strict'; const APT_URL = 'http://127.0.0.1:18247/cookie'; +const CHALLENGE_URL = 'http://127.0.0.1:18247/challenge'; const PAIRING_STORAGE_KEY = 'aptPairingSecret'; const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const NONCE_PATTERN = PAIRING_SECRET_PATTERN; +const BRIDGE_DEADLINE_MS = 3000; + +function base64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +async function hmacBase64Url(cryptoApi, secret, value) { + const encoder = new TextEncoder(); + const key = await cryptoApi.subtle.importKey( + 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] + ); + const signature = await cryptoApi.subtle.sign('HMAC', key, encoder.encode(value)); + return base64Url(new Uint8Array(signature)); +} function isSupportedDeploymentUrl(value) { try { @@ -20,7 +38,8 @@ function createPopupController({ documentApi, navigatorApi, fetchImpl, - confirmCopy + confirmCopy, + cryptoApi }) { const tabInfo = documentApi.getElementById('tabInfo'); const pairingInfo = documentApi.getElementById('pairingInfo'); @@ -34,6 +53,16 @@ function createPopupController({ let pairingSecret = null; let busy = false; + async function bridgeFetch(url, options) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), BRIDGE_DEADLINE_MS); + try { + return await fetchImpl(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + function showStatus(message, type) { statusMsg.textContent = message; statusMsg.className = `status-msg ${type}`; @@ -77,15 +106,45 @@ function createPopupController({ setBusy(true); let cookieValue = null; try { + showStatus('Authenticating Alta Proxy Tool...', 'info'); + if (!cryptoApi || !cryptoApi.subtle || typeof cryptoApi.getRandomValues !== 'function') { + throw new Error('CRYPTO_UNAVAILABLE'); + } + const clientNonce = base64Url(cryptoApi.getRandomValues(new Uint8Array(32))); + const challengeResponse = await bridgeFetch(CHALLENGE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientNonce }) + }); + const challenge = await challengeResponse.json(); + if (!challengeResponse.ok || !challenge || challenge.success !== true || + !NONCE_PATTERN.test(challenge.serverNonce) || !NONCE_PATTERN.test(challenge.serverProof)) { + throw new Error('BRIDGE_REJECTED'); + } + const expectedServerProof = await hmacBase64Url( + cryptoApi, + pairingSecret, + JSON.stringify(['apt-server-challenge-v1', clientNonce, challenge.serverNonce]) + ); + if (challenge.serverProof !== expectedServerProof) throw new Error('BRIDGE_AUTH_FAILED'); + showStatus('Sending to Alta Proxy Tool...', 'info'); cookieValue = await getVaCookieValue(); - const response = await fetchImpl(APT_URL, { + const cookieRequest = { + clientNonce, + serverNonce: challenge.serverNonce, + deploymentUrl: detectedOrigin, + cookieValue + }; + cookieRequest.proof = await hmacBase64Url( + cryptoApi, + pairingSecret, + JSON.stringify(['apt-cookie-request-v1', clientNonce, challenge.serverNonce, detectedOrigin, cookieValue]) + ); + const response = await bridgeFetch(APT_URL, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-APT-Pairing': pairingSecret - }, - body: JSON.stringify({ deploymentUrl: detectedOrigin, cookieValue }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cookieRequest) }); const data = await response.json(); if (!response.ok || !data || data.success !== true) throw new Error('BRIDGE_REJECTED'); @@ -173,7 +232,8 @@ if (typeof module !== 'undefined' && module.exports) { documentApi: document, navigatorApi: navigator, fetchImpl: fetch, - confirmCopy: (message) => window.confirm(message) + confirmCopy: (message) => window.confirm(message), + cryptoApi: crypto }).init().catch(() => { const status = document.getElementById('statusMsg'); status.textContent = 'Extension initialization failed. Open pairing settings and try again.'; diff --git a/main.js b/main.js index 284e159..f2dfea9 100644 --- a/main.js +++ b/main.js @@ -1,6 +1,6 @@ 'use strict'; -const { app, BrowserWindow, ipcMain, shell } = require('electron'); +const { app, BrowserWindow, ipcMain, safeStorage, shell } = require('electron'); const http = require('node:http'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -110,6 +110,7 @@ function registerIpcHandlers() { } function startBridgeServer() { + if (!pairingController.bridgeAuth) return; const handler = createBridgeHandler({ bridgeAuth: pairingController.bridgeAuth, sessionStore: runtime.sessionStore, @@ -134,8 +135,11 @@ app.whenReady().then(() => { const sessionStore = createSessionStore(); const altaClient = createAltaClient({ sessionStore }); const proxyManager = createProxyManager({ appDirectory: getAppDirectory() }); + const secureStorageAvailable = safeStorage && safeStorage.isEncryptionAvailable(); pairingController = new PairingController({ envelopePath: path.join(app.getPath('userData'), PAIRING_ENVELOPE_FILENAME), + protect: secureStorageAvailable ? (value) => safeStorage.encryptString(value) : undefined, + unprotect: secureStorageAvailable ? (value) => safeStorage.decryptString(value) : undefined, }); const pairingState = pairingController.initialize(); firstRunPairingSecret = pairingState.secret || null; @@ -153,12 +157,17 @@ app.whenReady().then(() => { startBridgeServer(); }); -app.on('before-quit', () => { - if (bridgeServer) bridgeServer.close(); +app.on('before-quit', (event) => { if (runtime) { - for (const proxy of runtime.getConnectionState().activeProxies) runtime.stopProxy(proxy.processId); + const state = runtime.disconnect(); + if (state.activeProxies.length > 0) { + event.preventDefault(); + sendConnectionState(); + return; + } runtime.sessionStore.dispose(); } + if (bridgeServer) bridgeServer.close(); }); app.on('window-all-closed', () => { diff --git a/src/bridge-auth.js b/src/bridge-auth.js index 2adf53e..5c3c804 100644 --- a/src/bridge-auth.js +++ b/src/bridge-auth.js @@ -5,10 +5,16 @@ 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 DEFAULT_CHALLENGE_TTL_MS = 5_000; +const DEFAULT_MAX_CHALLENGES = 64; const APT_EXTENSION_ID = 'onbkfpbggekakjddomjjnboippimlmch'; const APT_EXTENSION_ORIGIN = `chrome-extension://${APT_EXTENSION_ID}`; const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const NONCE_PATTERN = SECRET_PATTERN; +const PROOF_PATTERN = SECRET_PATTERN; const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/; +const SERVER_PROOF_DOMAIN = 'apt-server-challenge-v1'; +const COOKIE_PROOF_DOMAIN = 'apt-cookie-request-v1'; class BridgeAuthError extends Error { constructor(code, message, statusCode = 400) { @@ -22,10 +28,12 @@ class BridgeAuthError extends Error { function dependencies(overrides = {}) { return { randomBytes: overrides.randomBytes || crypto.randomBytes, - scryptSync: overrides.scryptSync || crypto.scryptSync, timingSafeEqual: overrides.timingSafeEqual || crypto.timingSafeEqual, + createHmac: overrides.createHmac || crypto.createHmac, + createHash: overrides.createHash || crypto.createHash, setTimeout: overrides.setTimeout || setTimeout, - clearTimeout: overrides.clearTimeout || clearTimeout + clearTimeout: overrides.clearTimeout || clearTimeout, + now: overrides.now || Date.now }; } @@ -44,71 +52,98 @@ function assertPairingSecret(secret) { } } -function hashPairingSecret(secret, options = {}) { +function canonicalServerProof(clientNonce, serverNonce) { + return JSON.stringify([SERVER_PROOF_DOMAIN, clientNonce, serverNonce]); +} + +function canonicalCookieProof({ clientNonce, serverNonce, deploymentUrl, cookieValue } = {}) { + return JSON.stringify([COOKIE_PROOF_DOMAIN, clientNonce, serverNonce, deploymentUrl, cookieValue]); +} + +function computeHmac(secret, message, 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') - }); + return dependencies(options).createHmac('sha256', secret).update(message, 'utf8').digest('base64url'); +} + +function computeServerProof(secret, clientNonce, serverNonce, options = {}) { + return computeHmac(secret, canonicalServerProof(clientNonce, serverNonce), options); +} + +function computeCookieProof(secret, request, options = {}) { + return computeHmac(secret, canonicalCookieProof(request), options); } function isValidEnvelope(envelope) { return Boolean( envelope && - envelope.version === 1 && - envelope.algorithm === 'scrypt' && - typeof envelope.salt === 'string' && - /^[A-Za-z0-9_-]{22}$/.test(envelope.salt) && + envelope.version === 2 && + envelope.algorithm === 'electron-safe-storage' && + typeof envelope.ciphertext === 'string' && + /^[A-Za-z0-9+/]+={0,2}$/.test(envelope.ciphertext) && + envelope.ciphertext.length >= 16 && envelope.ciphertext.length <= 8192 && typeof envelope.digest === 'string' && - /^[A-Za-z0-9_-]{43}$/.test(envelope.digest) + PROOF_PATTERN.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) || origin !== APT_EXTENSION_ORIGIN) { + throw new BridgeAuthError('INVALID_EXTENSION_ORIGIN', 'The bridge only accepts the committed Alta Proxy Tool extension origin.'); } } -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.' - ); - } +function safeEqualText(left, right, deps) { + if (typeof left !== 'string' || typeof right !== 'string') return false; + const leftBytes = Buffer.from(left, 'utf8'); + const rightBytes = Buffer.from(right, 'utf8'); + return leftBytes.length === rightBytes.length && deps.timingSafeEqual(leftBytes, rightBytes); } class BridgeAuth { - constructor({ expectedOrigin = APT_EXTENSION_ORIGIN, envelope = null, ...injected } = {}) { + constructor({ + expectedOrigin = APT_EXTENSION_ORIGIN, + envelope = null, + protect, + unprotect, + maxChallenges = DEFAULT_MAX_CHALLENGES, + challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS, + ...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 (typeof protect !== 'function' || typeof unprotect !== 'function') { + throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage is unavailable.', 503); + } + if (!Number.isSafeInteger(maxChallenges) || maxChallenges < 1 || maxChallenges > 1024) { + throw new BridgeAuthError('INVALID_CHALLENGE_LIMIT', 'Challenge limit is invalid.'); + } + if (!Number.isSafeInteger(challengeTtlMs) || challengeTtlMs < 1 || challengeTtlMs > 60_000) { + throw new BridgeAuthError('INVALID_CHALLENGE_TTL', 'Challenge lifetime is invalid.'); } 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._protect = protect; + this._unprotect = unprotect; + this._maxChallenges = maxChallenges; + this._challengeTtlMs = challengeTtlMs; this._dependencies = dependencies(injected); + this._challenges = new Map(); + this._secret = null; + this._envelope = envelope ? Object.freeze({ ...envelope }) : null; + + if (envelope) { + try { + const secret = unprotect(Buffer.from(envelope.ciphertext, 'base64')); + assertPairingSecret(secret); + const digest = this._dependencies.createHash('sha256').update(secret, 'utf8').digest('base64url'); + if (!safeEqualText(digest, envelope.digest, this._dependencies)) throw new Error('digest mismatch'); + this._secret = secret; + } catch { + throw new BridgeAuthError('INVALID_SECRET_ENVELOPE', 'The persisted pairing envelope could not be decrypted.'); + } + } } get envelope() { @@ -117,24 +152,75 @@ class BridgeAuth { rotate() { const secret = generatePairingSecret(this._dependencies); - this._envelope = hashPairingSecret(secret, this._dependencies); + let protectedBytes; + try { + protectedBytes = this._protect(secret); + } catch { + throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage is unavailable.', 503); + } + if (!Buffer.isBuffer(protectedBytes) || protectedBytes.length === 0 || protectedBytes.length > 6144) { + throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage returned invalid ciphertext.', 503); + } + this._secret = secret; + this._challenges.clear(); + this._envelope = Object.freeze({ + version: 2, + algorithm: 'electron-safe-storage', + ciphertext: protectedBytes.toString('base64'), + digest: this._dependencies.createHash('sha256').update(secret, 'utf8').digest('base64url') + }); return { secret, envelope: this.envelope }; } revoke() { + this._secret = null; this._envelope = null; + this._challenges.clear(); } - authenticate({ origin, secret } = {}) { - if (origin !== this.expectedOrigin || !this._envelope) return false; - return verifyPairingSecret(secret, this._envelope, this._dependencies); + _purgeExpired(now) { + for (const [nonce, challenge] of this._challenges) { + if (challenge.expiresAt <= now) this._challenges.delete(nonce); + } } - authenticateRequest(request) { - if (!request || typeof request !== 'object') return false; - const headers = request.headers || {}; - const secret = headers['x-apt-pairing']; - return this.authenticate({ origin: headers.origin, secret }); + issueChallenge(clientNonce) { + if (!this._secret) throw new BridgeAuthError('PAIRING_UNAVAILABLE', 'Bridge pairing is unavailable.', 503); + if (typeof clientNonce !== 'string' || !NONCE_PATTERN.test(clientNonce)) { + throw new BridgeAuthError('INVALID_NONCE', 'Client nonce is invalid.'); + } + const now = this._dependencies.now(); + this._purgeExpired(now); + if (this._challenges.size >= this._maxChallenges) { + throw new BridgeAuthError('CHALLENGE_CAPACITY', 'Too many bridge challenges are outstanding.', 429); + } + let serverNonce; + do { + serverNonce = this._dependencies.randomBytes(32).toString('base64url'); + } while (this._challenges.has(serverNonce)); + const expiresAt = now + this._challengeTtlMs; + this._challenges.set(serverNonce, { clientNonce, expiresAt }); + return { + serverNonce, + serverProof: computeServerProof(this._secret, clientNonce, serverNonce, this._dependencies) + }; + } + + authenticateCookie(request = {}) { + const { clientNonce, serverNonce, deploymentUrl, cookieValue, proof } = request; + if (!this._secret || !NONCE_PATTERN.test(typeof clientNonce === 'string' ? clientNonce : '') || + !NONCE_PATTERN.test(typeof serverNonce === 'string' ? serverNonce : '') || + !PROOF_PATTERN.test(typeof proof === 'string' ? proof : '') || + typeof deploymentUrl !== 'string' || deploymentUrl.length < 1 || deploymentUrl.length > 2048 || + typeof cookieValue !== 'string' || cookieValue.length < 1 || cookieValue.length > 4096) { + return false; + } + const challenge = this._challenges.get(serverNonce); + if (!challenge) return false; + this._challenges.delete(serverNonce); + if (challenge.expiresAt <= this._dependencies.now() || challenge.clientNonce !== clientNonce) return false; + const expected = computeCookieProof(this._secret, request, this._dependencies); + return safeEqualText(proof, expected, this._dependencies); } } @@ -142,22 +228,14 @@ 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.')); - } + 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); @@ -174,10 +252,7 @@ function readJsonBody(stream, options = {}) { 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; - } + if (total > maxBytes) return fail(new BridgeAuthError('BODY_TOO_LARGE', 'Request body exceeds the configured limit.', 413)); chunks.push(buffer); }; const onError = () => fail(new BridgeAuthError('BODY_READ_ERROR', 'Could not read the request body.')); @@ -186,23 +261,15 @@ function readJsonBody(stream, options = {}) { 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; - } + 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 - ); + 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); @@ -210,25 +277,15 @@ function readJsonBody(stream, options = {}) { } function createRequestLimiter({ maxConcurrent = 4 } = {}) { - if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) { - throw new BridgeAuthError('INVALID_CONCURRENCY_LIMIT', 'Concurrency limit must be a positive integer.'); - } + 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); - } + 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; - } + try { return await work(); } finally { active -= 1; } } }; } @@ -238,13 +295,21 @@ module.exports = { APT_EXTENSION_ORIGIN, BridgeAuth, BridgeAuthError, + COOKIE_PROOF_DOMAIN, DEFAULT_BODY_DEADLINE_MS, + DEFAULT_CHALLENGE_TTL_MS, DEFAULT_MAX_BODY_BYTES, + DEFAULT_MAX_CHALLENGES, EXTENSION_ORIGIN_PATTERN, + NONCE_PATTERN, SECRET_PATTERN, + SERVER_PROOF_DOMAIN, + canonicalCookieProof, + canonicalServerProof, + computeCookieProof, + computeServerProof, createRequestLimiter, generatePairingSecret, - hashPairingSecret, - readJsonBody, - verifyPairingSecret + isValidEnvelope, + readJsonBody }; diff --git a/src/electron-runtime.js b/src/electron-runtime.js index 37af5cb..a65926c 100644 --- a/src/electron-runtime.js +++ b/src/electron-runtime.js @@ -26,13 +26,13 @@ function safeErrorMessage(error, fallback) { : fallback; } -function loadPairingEnvelope(envelopePath) { +function loadPairingEnvelope(envelopePath, { protect, unprotect } = {}) { if (!fs.existsSync(envelopePath)) return null; const bytes = fs.readFileSync(envelopePath); if (bytes.length === 0 || bytes.length > 4096) throw new Error('Invalid pairing envelope file'); const parsed = JSON.parse(bytes.toString('utf8')); // BridgeAuth performs the authoritative envelope schema validation. - return new BridgeAuth({ envelope: parsed }).envelope; + return new BridgeAuth({ envelope: parsed, protect, unprotect }).envelope; } function atomicWritePairingEnvelope(envelopePath, envelope) { @@ -51,30 +51,38 @@ function atomicWritePairingEnvelope(envelopePath, envelope) { } class PairingController { - constructor({ envelopePath, bridgeAuth } = {}) { + constructor({ envelopePath, bridgeAuth, protect, unprotect } = {}) { if (typeof envelopePath !== 'string' || envelopePath.length === 0) { throw new TypeError('PairingController requires an envelope path'); } this.envelopePath = envelopePath; + this.unavailable = false; if (bridgeAuth) { this.bridgeAuth = bridgeAuth; + } else if (typeof protect !== 'function' || typeof unprotect !== 'function') { + this.bridgeAuth = null; + this.unavailable = true; } else { let envelope = null; try { - envelope = loadPairingEnvelope(envelopePath); + envelope = loadPairingEnvelope(envelopePath, { protect, unprotect }); } catch { try { fs.unlinkSync(envelopePath); } catch {} } - this.bridgeAuth = new BridgeAuth({ envelope }); + this.bridgeAuth = new BridgeAuth({ envelope, protect, unprotect }); } } initialize() { + if (this.unavailable) return { paired: false, unavailable: true }; if (this.bridgeAuth.envelope) return { paired: true }; return this.rotate(); } rotate() { + if (this.unavailable || !this.bridgeAuth) { + throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage is unavailable.', 503); + } const { secret, envelope } = this.bridgeAuth.rotate(); try { atomicWritePairingEnvelope(this.envelopePath, envelope); @@ -86,17 +94,18 @@ class PairingController { } revoke() { - this.bridgeAuth.revoke(); + if (this.bridgeAuth) this.bridgeAuth.revoke(); try { fs.unlinkSync(this.envelopePath); } catch (error) { if (error.code !== 'ENOENT') throw error; } - return { paired: false }; + return this.unavailable ? { paired: false, unavailable: true } : { paired: false }; } getStatus() { - return { paired: Boolean(this.bridgeAuth.envelope) }; + if (this.unavailable) return { paired: false, unavailable: true }; + return { paired: Boolean(this.bridgeAuth && this.bridgeAuth.envelope) }; } } @@ -118,7 +127,7 @@ function createBridgeHandler({ maxBodyBytes, deadlineMs, } = {}) { - if (!bridgeAuth || typeof bridgeAuth.authenticateRequest !== 'function') { + if (!bridgeAuth || typeof bridgeAuth.issueChallenge !== 'function' || typeof bridgeAuth.authenticateCookie !== 'function') { throw new TypeError('Bridge handler requires bridge authentication'); } if (!sessionStore || typeof sessionStore.establish !== 'function') { @@ -135,7 +144,7 @@ function createBridgeHandler({ response.setHeader('Access-Control-Allow-Origin', APT_EXTENSION_ORIGIN); response.setHeader('Vary', 'Origin'); response.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Pairing'); + response.setHeader('Access-Control-Allow-Headers', 'Content-Type'); response.setHeader('Access-Control-Max-Age', '600'); if (request.method === 'OPTIONS') { @@ -143,29 +152,38 @@ function createBridgeHandler({ response.end(); return; } - if (request.method !== 'POST' || request.url !== '/cookie') { + if (request.method !== 'POST' || (request.url !== '/challenge' && request.url !== '/cookie')) { writeJson(response, 404, { success: false, message: 'Not found' }); return; } - if (!bridgeAuth.authenticateRequest(request)) { - writeJson(response, 403, { success: false, message: 'Forbidden' }); - return; - } try { + let payload; await limiter.run(async () => { const data = await readJsonBody(request, { maxBytes: maxBodyBytes, deadlineMs }); + if (request.url === '/challenge') { + payload = { success: true, ...bridgeAuth.issueChallenge(data.clientNonce) }; + return; + } + if (!bridgeAuth.authenticateCookie(data)) { + throw new BridgeAuthError('FORBIDDEN', 'Cookie proof was rejected.', 403); + } const state = sessionStore.establish(data.deploymentUrl, data.cookieValue); onConnectionStateChanged({ connected: state.connected, origin: state.origin }); + payload = { success: true, message: 'Session received' }; }); - writeJson(response, 200, { success: true, message: 'Session received' }); + writeJson(response, 200, payload); } catch (error) { const statusCode = error instanceof BridgeAuthError ? error.statusCode : error && error.code === 'INVALID_SESSION_COOKIE' ? 400 : 400; - writeJson(response, statusCode, { success: false, message: statusCode === 429 ? 'Too many requests' : 'Invalid request' }); + const message = statusCode === 429 ? 'Too many requests' + : statusCode === 403 ? 'Forbidden' + : statusCode === 503 ? 'Pairing unavailable' + : 'Invalid request'; + writeJson(response, statusCode, { success: false, message }); } }; } @@ -189,6 +207,22 @@ class AppRuntime { this.proxyByDevice = new Map(); } + _reconcileProxies() { + const tracked = this.proxyManager && typeof this.proxyManager.listTrackedProxies === 'function' + ? this.proxyManager.listTrackedProxies() + : []; + this.proxyByDevice = new Map(); + for (const proxy of tracked) { + try { + const deviceId = validateDeviceId(proxy.deviceId); + if (Number.isSafeInteger(proxy.processId) && proxy.processId > 0) { + this.proxyByDevice.set(deviceId, proxy.processId); + } + } catch {} + } + return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId); + } + async getDevices() { try { const devices = await this.altaClient.getDevices(); @@ -222,6 +256,7 @@ class AppRuntime { async launchProxy(deviceId) { try { const validatedId = validateDeviceId(deviceId); + this._reconcileProxies(); if (!this.allowedDeviceIds.has(validatedId)) { return { success: false, message: 'Select a device from the current Alta device list.' }; } @@ -242,6 +277,7 @@ class AppRuntime { } async stopProxy(key) { + this._reconcileProxies(); let deviceId = null; let processId = null; if (typeof key === 'string') { @@ -261,12 +297,25 @@ class AppRuntime { } disconnect() { + const tracked = this._reconcileProxies(); + for (const proxy of tracked) { + try { this.proxyManager.stopProxy(proxy.processId); } catch {} + } + const remaining = this._reconcileProxies(); + if (remaining.length > 0) { + return { + success: false, + message: 'Could not stop every active proxy. The Alta session remains connected.', + ...this.getConnectionState(), + }; + } this.sessionStore.clear(); this.allowedDeviceIds.clear(); - return this.getConnectionState(); + return { success: true, ...this.getConnectionState() }; } getConnectionState() { + this._reconcileProxies(); const state = this.sessionStore.describe(); return { connected: state.connected, diff --git a/src/update-policy.js b/src/update-policy.js index b01a3a1..8f3fb6e 100644 --- a/src/update-policy.js +++ b/src/update-policy.js @@ -140,27 +140,44 @@ function parseRelease(body) { }; } -function defaultRequest({ url, timeoutMs, maxBodyBytes }) { +function defaultRequest({ + url, + timeoutMs, + maxBodyBytes, + httpsGet = https.get, + setTimer = setTimeout, + clearTimer = clearTimeout, +}) { if (url !== LATEST_RELEASE_URL) { return Promise.reject(policyError('UNTRUSTED_REQUEST_URL', 'Update checks are restricted to GitPeji')); } return new Promise((resolve, reject) => { let settled = false; + let responseStream = null; + let deadlineTimer = null; + const clearDeadline = () => { + if (deadlineTimer !== null) { + clearTimer(deadlineTimer); + deadlineTimer = null; + } + }; const finishReject = (error) => { if (!settled) { settled = true; + clearDeadline(); reject(error); } }; - const request = https.get(url, { + const request = httpsGet(url, { headers: { accept: 'application/json', 'user-agent': 'Alta-Proxy-Tool-update-check', }, agent: false, }, (response) => { + responseStream = response; const chunks = []; let receivedBytes = 0; const contentLength = Number(response.headers['content-length']); @@ -183,6 +200,7 @@ function defaultRequest({ url, timeoutMs, maxBodyBytes }) { response.on('end', () => { if (settled) return; settled = true; + clearDeadline(); resolve({ statusCode: response.statusCode, headers: response.headers, @@ -193,9 +211,12 @@ function defaultRequest({ url, timeoutMs, maxBodyBytes }) { response.on('error', finishReject); }); - request.setTimeout(timeoutMs, () => { - request.destroy(policyError('REQUEST_TIMEOUT', 'Release check timed out')); - }); + deadlineTimer = setTimer(() => { + const timeoutError = policyError('REQUEST_TIMEOUT', 'Release check timed out'); + if (responseStream && typeof responseStream.destroy === 'function') responseStream.destroy(); + request.destroy(timeoutError); + finishReject(timeoutError); + }, timeoutMs); request.on('error', (error) => { if (error instanceof UpdatePolicyError) { finishReject(error); diff --git a/test/bridge-auth.test.js b/test/bridge-auth.test.js index 6c535b3..24b0e1c 100644 --- a/test/bridge-auth.test.js +++ b/test/bridge-auth.test.js @@ -7,108 +7,175 @@ const { PassThrough, Readable } = require('node:stream'); const { BridgeAuth, BridgeAuthError, + computeCookieProof, + computeServerProof, generatePairingSecret, - hashPairingSecret, - verifyPairingSecret, readJsonBody, createRequestLimiter } = require('../src/bridge-auth'); const EXTENSION_ORIGIN = 'chrome-extension://onbkfpbggekakjddomjjnboippimlmch'; +const CLIENT_NONCE = Buffer.alloc(32, 1).toString('base64url'); +const protect = (plaintext) => Buffer.from(`protected:${plaintext}`, 'utf8'); +const unprotect = (ciphertext) => { + const value = Buffer.from(ciphertext).toString('utf8'); + if (!value.startsWith('protected:')) throw new Error('bad ciphertext'); + return value.slice('protected:'.length); +}; -test('pairing secrets are random, URL-safe, and stored as a hash envelope', () => { +function createAuth(options = {}) { + return new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, protect, unprotect, ...options }); +} + +test('pairing secrets are random and persisted only in a versioned protected 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); + const auth = createAuth(); + const { secret, envelope } = auth.rotate(); + assert.deepEqual(Object.keys(envelope).sort(), ['algorithm', 'ciphertext', 'digest', 'version']); + assert.equal(envelope.version, 2); + assert.equal(envelope.algorithm, 'electron-safe-storage'); + assert.equal(JSON.stringify(envelope).includes(secret), false); + assert.equal(Buffer.from(envelope.ciphertext, 'base64').toString('utf8').includes(secret), true, + 'test protector is intentionally transparent after decoding; disk JSON itself is never plaintext'); }); -test('auth rejects missing/wrong secrets and unknown or malformed origins', () => { - const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN }); - const { secret } = auth.rotate(); +test('protected envelope restores server authentication without request-time scrypt', () => { + let protectCalls = 0; + let unprotectCalls = 0; + const first = new BridgeAuth({ + protect(value) { protectCalls += 1; return protect(value); }, + unprotect(value) { unprotectCalls += 1; return unprotect(value); } + }); + const { secret, envelope } = first.rotate(); + const restored = new BridgeAuth({ + envelope: JSON.parse(JSON.stringify(envelope)), + protect, + unprotect(value) { unprotectCalls += 1; return unprotect(value); } + }); + const challenge = restored.issueChallenge(CLIENT_NONCE); - 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); + assert.equal(protectCalls, 1); + assert.equal(unprotectCalls, 1); + assert.equal(challenge.serverProof, computeServerProof(secret, CLIENT_NONCE, challenge.serverNonce)); + assert.equal(Object.hasOwn(challenge, 'secret'), 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; +test('challenge proof and cookie proof are domain-separated, constant-time authenticated, and one-time', () => { + const auth = createAuth(); + const secret = auth.rotate().secret; + const challenge = auth.issueChallenge(CLIENT_NONCE); + const cookieRequest = { + clientNonce: CLIENT_NONCE, + serverNonce: challenge.serverNonce, + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'synthetic-cookie' + }; + cookieRequest.proof = computeCookieProof(secret, cookieRequest); - assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: first }), false); - assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), true); + assert.notEqual(challenge.serverProof, cookieRequest.proof); + assert.equal(auth.authenticateCookie(cookieRequest), true); + assert.equal(auth.authenticateCookie(cookieRequest), false, 'challenge replay must fail'); +}); + +test('challenge cache is bounded, expires entries, and rejects malformed nonces', () => { + let now = 100; + const auth = createAuth({ maxChallenges: 1, challengeTtlMs: 50, now: () => now }); + auth.rotate(); + auth.issueChallenge(CLIENT_NONCE); + assert.throws(() => auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url')), + (error) => error.code === 'CHALLENGE_CAPACITY'); + now = 151; + const replacement = auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url')); + assert.match(replacement.serverNonce, /^[A-Za-z0-9_-]{43}$/); + assert.throws(() => auth.issueChallenge('short'), (error) => error.code === 'INVALID_NONCE'); +}); + +test('expired and forged cookie proofs fail and consume the one-time challenge', () => { + let now = 100; + const auth = createAuth({ challengeTtlMs: 50, now: () => now }); + const secret = auth.rotate().secret; + const expired = auth.issueChallenge(CLIENT_NONCE); + now = 151; + assert.equal(auth.authenticateCookie({ + clientNonce: CLIENT_NONCE, + serverNonce: expired.serverNonce, + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'cookie', + proof: computeCookieProof(secret, { + clientNonce: CLIENT_NONCE, + serverNonce: expired.serverNonce, + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'cookie' + }) + }), false); + + now = 200; + const forged = auth.issueChallenge(CLIENT_NONCE); + const request = { + clientNonce: CLIENT_NONCE, + serverNonce: forged.serverNonce, + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'cookie', + proof: 'A'.repeat(43) + }; + assert.equal(auth.authenticateCookie(request), false); + request.proof = computeCookieProof(secret, request); + assert.equal(auth.authenticateCookie(request), false, 'forged attempt consumes challenge'); +}); + +test('rotation clears outstanding challenges and revoke disables challenge issuance', () => { + const auth = createAuth(); + auth.rotate(); + const prior = auth.issueChallenge(CLIENT_NONCE); + auth.rotate(); + assert.equal(auth.authenticateCookie({ + clientNonce: CLIENT_NONCE, + serverNonce: prior.serverNonce, + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'cookie', + proof: 'A'.repeat(43) + }), false); auth.revoke(); - assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), false); + assert.throws(() => auth.issueChallenge(CLIENT_NONCE), (error) => error.code === 'PAIRING_UNAVAILABLE'); 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('invalid origin and protected-envelope dependencies fail closed', () => { + assert.throws(() => new BridgeAuth({ expectedOrigin: 'https://example.test', protect, unprotect }), + (error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN'); + assert.throws(() => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', protect, unprotect }), + (error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN'); + assert.throws(() => new BridgeAuth(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE'); + assert.throws(() => new BridgeAuth({ protect, unprotect, envelope: { version: 1, algorithm: 'scrypt' } }), + (error) => error.code === 'INVALID_SECRET_ENVELOPE'); }); 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' - ); + 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 () => { +test('readJsonBody aborts a slow body at its absolute 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 () => { +test('request limiter rejects excess concurrent work before it 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' - ); + let entered = false; + await assert.rejects(limiter.run(async () => { entered = true; }), (error) => error.code === 'TOO_MANY_REQUESTS'); + assert.equal(entered, false); 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 index 871a2df..6c47b53 100644 --- a/test/extension-contract.test.js +++ b/test/extension-contract.test.js @@ -37,7 +37,7 @@ function makeElement() { }; } -function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null } = {}) { +function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null, validServerProof = true } = {}) { const elements = Object.fromEntries( ['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn'] .map((id) => [id, makeElement()]) @@ -66,8 +66,18 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject documentApi, navigatorApi, confirmCopy: () => confirmCopy, + cryptoApi: crypto.webcrypto, fetchImpl: async (...args) => { fetchCalls.push(args); + if (args[0].endsWith('/challenge')) { + const { clientNonce } = JSON.parse(args[1].body); + const serverNonce = 'B'.repeat(43); + const canonical = JSON.stringify(['apt-server-challenge-v1', clientNonce, serverNonce]); + const serverProof = validServerProof + ? crypto.createHmac('sha256', 'A'.repeat(43)).update(canonical).digest('base64url') + : 'C'.repeat(43); + return { ok: true, json: async () => ({ success: true, serverNonce, serverProof }) }; + } return { ok: true, json: async () => ({ success: true }) }; } }); @@ -122,18 +132,37 @@ test('deployment detection rejects non-HTTPS, bare, and lookalike Alta hosts', ( assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com.evil.test/path'), false); }); -test('Send to APT uses the paired secret header and exact endpoint', async () => { +test('Send to APT authenticates the listener before reading or sending the cookie', 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(harness.fetchCalls.length, 2); + const [challengeUrl, challengeRequest] = harness.fetchCalls[0]; + assert.equal(challengeUrl, 'http://127.0.0.1:18247/challenge'); + assert.equal(JSON.stringify(challengeRequest).includes('sensitive-va-token'), false); + assert.equal(JSON.stringify(challengeRequest).includes('A'.repeat(43)), false); + const [url, request] = harness.fetchCalls[1]; assert.equal(url, 'http://127.0.0.1:18247/cookie'); - assert.equal(request.headers['X-APT-Pairing'], 'A'.repeat(43)); - assert.deepEqual(JSON.parse(request.body), { - deploymentUrl: 'https://customer.avasecurity.com', - cookieValue: 'sensitive-va-token' - }); + assert.equal(Object.keys(request.headers).some((name) => /pairing/i.test(name)), false); + const body = JSON.parse(request.body); + assert.equal(body.deploymentUrl, 'https://customer.avasecurity.com'); + assert.equal(body.cookieValue, 'sensitive-va-token'); + assert.match(body.clientNonce, /^[A-Za-z0-9_-]{43}$/); + assert.equal(body.serverNonce, 'B'.repeat(43)); + assert.match(body.proof, /^[A-Za-z0-9_-]{43}$/); + assert.equal(request.body.includes('A'.repeat(43)), false); +}); + +test('a port-squatting fake server with an invalid proof receives no cookie or pairing secret', async () => { + const harness = makePopupHarness({ paired: true, validServerProof: false }); + await harness.controller.init(); + await harness.controller.sendToApt(); + assert.equal(harness.fetchCalls.length, 1); + assert.equal(harness.fetchCalls[0][0], 'http://127.0.0.1:18247/challenge'); + assert.equal(harness.cookieReads, 0); + const network = JSON.stringify(harness.fetchCalls); + assert.equal(network.includes('sensitive-va-token'), false); + assert.equal(network.includes('A'.repeat(43)), false); }); test('copy cancellation occurs before cookie access and never writes the token', async () => { diff --git a/test/runtime-contract.test.js b/test/runtime-contract.test.js index 9a98262..80ad2db 100644 --- a/test/runtime-contract.test.js +++ b/test/runtime-contract.test.js @@ -13,7 +13,12 @@ const { createBridgeHandler, loadPairingEnvelope, } = require('../src/electron-runtime'); -const { APT_EXTENSION_ORIGIN, BridgeAuth } = require('../src/bridge-auth'); +const { + APT_EXTENSION_ORIGIN, + BridgeAuth, + computeCookieProof, + createRequestLimiter, +} = require('../src/bridge-auth'); const { createSessionStore } = require('../src/session-store'); const ROOT = path.join(__dirname, '..'); @@ -33,12 +38,11 @@ function responseHarness() { }; } -function requestHarness({ method = 'POST', origin = APT_EXTENSION_ORIGIN, secret, body = '{}' } = {}) { +function requestHarness({ method = 'POST', origin = APT_EXTENSION_ORIGIN, url = '/cookie', body = '{}' } = {}) { const request = new PassThrough(); request.method = method; - request.url = '/cookie'; + request.url = url; request.headers = { origin }; - if (secret !== undefined) request.headers['x-apt-pairing'] = secret; process.nextTick(() => request.end(body)); return request; } @@ -47,10 +51,19 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- const sessionStore = createSessionStore(); sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie'); const calls = []; + const tracked = []; const proxyManager = { - launchProxy(request) { calls.push(request); return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' }; }, - stopProxy(processId) { calls.push({ processId }); return { success: true, processId, status: 'stop-requested' }; }, - listTrackedProxies() { return []; }, + launchProxy(request) { + calls.push(request); + tracked.push({ processId: 91, deviceId: request.deviceId, status: 'running', startedAt: 1 }); + return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' }; + }, + stopProxy(processId) { + calls.push({ processId }); + tracked.splice(0, tracked.length); + return { success: true, processId, status: 'stop-requested' }; + }, + listTrackedProxies() { return tracked.slice(); }, }; const runtime = new AppRuntime({ sessionStore, @@ -86,8 +99,10 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- assert.equal((await runtime.stopProxy(999)).success, false); }); -test('bridge rejects unknown preflight and unauthenticated requests before reading their body', async () => { - const auth = new BridgeAuth(); +test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => { + const protect = (value) => Buffer.from(`protected:${value}`); + const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length); + const auth = new BridgeAuth({ protect, unprotect }); const secret = auth.rotate().secret; const sessionStore = createSessionStore(); let stateNotifications = 0; @@ -103,44 +118,72 @@ test('bridge rejects unknown preflight and unauthenticated requests before readi assert.equal(unknownResponse.statusCode, 403); assert.equal(unknownResponse.headers['access-control-allow-origin'], undefined); - const unauthenticated = requestHarness({ body: '{'.repeat(1000) }); - let dataRead = false; - unauthenticated.on('data', () => { dataRead = true; }); - const unauthenticatedResponse = responseHarness(); - await handler(unauthenticated, unauthenticatedResponse); - assert.equal(unauthenticatedResponse.statusCode, 403); - assert.equal(dataRead, false); - - const allowedPreflight = requestHarness({ method: 'OPTIONS', secret }); + const allowedPreflight = requestHarness({ method: 'OPTIONS' }); const allowedResponse = responseHarness(); await handler(allowedPreflight, allowedResponse); assert.equal(allowedResponse.statusCode, 204); assert.equal(allowedResponse.headers['access-control-allow-origin'], APT_EXTENSION_ORIGIN); - assert.match(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/); + assert.doesNotMatch(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/i); - const accepted = requestHarness({ - secret, - body: JSON.stringify({ deploymentUrl: 'https://customer.avasecurity.com', cookieValue: 'valid-cookie' }), - }); + const clientNonce = Buffer.alloc(32, 3).toString('base64url'); + const challengeResponse = responseHarness(); + await handler(requestHarness({ url: '/challenge', body: JSON.stringify({ clientNonce }) }), challengeResponse); + assert.equal(challengeResponse.statusCode, 200); + const challenge = JSON.parse(challengeResponse.body); + assert.equal(JSON.stringify(challenge).includes(secret), false); + + const cookieBody = { + clientNonce, + serverNonce: challenge.serverNonce, + deploymentUrl: 'https://customer.avasecurity.com', + cookieValue: 'valid-cookie', + }; + cookieBody.proof = computeCookieProof(secret, cookieBody); const acceptedResponse = responseHarness(); - await handler(accepted, acceptedResponse); + await handler(requestHarness({ body: JSON.stringify(cookieBody) }), acceptedResponse); assert.equal(acceptedResponse.statusCode, 200); assert.deepEqual(sessionStore.describe(), { connected: true, origin: 'https://customer.avasecurity.com' }); assert.equal(stateNotifications, 1); assert.equal(acceptedResponse.body.includes('valid-cookie'), false); + + const replayResponse = responseHarness(); + await handler(requestHarness({ body: JSON.stringify(cookieBody) }), replayResponse); + assert.equal(replayResponse.statusCode, 403); }); -test('pairing envelope persists with restrictive permissions and secrets are returned once', () => { +test('bridge limiter encloses body reads and HMAC authentication under forged floods', async () => { + const auth = new BridgeAuth({ protect: (value) => Buffer.from(value), unprotect: (value) => Buffer.from(value).toString() }); + auth.rotate(); + const limiter = createRequestLimiter({ maxConcurrent: 1 }); + const handler = createBridgeHandler({ bridgeAuth: auth, sessionStore: createSessionStore(), limiter, deadlineMs: 50 }); + const held = new PassThrough(); + held.method = 'POST'; + held.url = '/challenge'; + held.headers = { origin: APT_EXTENSION_ORIGIN }; + const first = handler(held, responseHarness()); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(limiter.active, 1); + const rejected = responseHarness(); + await handler(requestHarness({ url: '/challenge', body: JSON.stringify({ clientNonce: 'A'.repeat(43) }) }), rejected); + assert.equal(rejected.statusCode, 429); + held.end('{}'); + await first; +}); + +test('pairing envelope persists encrypted with restrictive permissions and secrets are returned once', () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-')); const envelopePath = path.join(directory, 'bridge-pairing.json'); - const controller = new PairingController({ envelopePath }); + const protect = (value) => Buffer.from(`dpapi:${value}`); + const unprotect = (value) => Buffer.from(value).toString().slice('dpapi:'.length); + const controller = new PairingController({ envelopePath, protect, unprotect }); const first = controller.initialize(); assert.equal(first.paired, true); assert.match(first.secret, /^[A-Za-z0-9_-]{43}$/); assert.deepEqual(controller.getStatus(), { paired: true }); - const persisted = loadPairingEnvelope(envelopePath); + const persisted = loadPairingEnvelope(envelopePath, { protect, unprotect }); assert.equal(Object.values(persisted).includes(first.secret), false); + assert.equal(fs.readFileSync(envelopePath, 'utf8').includes(first.secret), false); if (process.platform !== 'win32') assert.equal(fs.statSync(envelopePath).mode & 0o777, 0o600); const rotated = controller.rotate(); @@ -152,6 +195,84 @@ test('pairing envelope persists with restrictive permissions and secrets are ret assert.equal(fs.existsSync(envelopePath), false); }); +test('pairing fails closed and reports unavailable without secure storage', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-unavailable-')); + const controller = new PairingController({ envelopePath: path.join(directory, 'bridge-pairing.json') }); + assert.deepEqual(controller.initialize(), { paired: false, unavailable: true }); + assert.deepEqual(controller.getStatus(), { paired: false, unavailable: true }); + assert.throws(() => controller.rotate(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE'); +}); + +test('runtime reconciles exited children and permits relaunch for the same device', async () => { + const sessionStore = createSessionStore(); + sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie'); + const deviceId = '550e8400-e29b-41d4-a716-446655440000'; + const tracked = []; + let nextPid = 4101; + const runtime = new AppRuntime({ + sessionStore, + altaClient: { getDevices: async () => [{ guid: deviceId }] }, + proxyManager: { + launchProxy() { + const entry = { processId: nextPid++, deviceId, status: 'running', startedAt: 1 }; + tracked.push(entry); + return { success: true, ...entry }; + }, + stopProxy() { throw new Error('unused'); }, + listTrackedProxies() { return tracked.slice(); }, + }, + }); + await runtime.getDevices(); + assert.equal((await runtime.launchProxy(deviceId)).processId, 4101); + tracked.length = 0; + assert.deepEqual(runtime.getConnectionState().activeProxies, []); + assert.equal((await runtime.launchProxy(deviceId)).processId, 4102); +}); + +test('disconnect stops every owned proxy before clearing the Alta session', async () => { + const sessionStore = createSessionStore(); + sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie'); + const tracked = [ + { processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 }, + { processId: 4102, deviceId: '550e8400-e29b-41d4-a716-446655440001', status: 'running', startedAt: 1 }, + ]; + const stopped = []; + const runtime = new AppRuntime({ + sessionStore, + altaClient: {}, + proxyManager: { + listTrackedProxies() { return tracked.slice(); }, + stopProxy(processId) { + stopped.push(processId); + tracked.splice(tracked.findIndex((entry) => entry.processId === processId), 1); + return { success: true, processId, status: 'stop-requested' }; + }, + }, + }); + const state = runtime.disconnect(); + assert.deepEqual(stopped, [4101, 4102]); + assert.equal(state.connected, false); + assert.deepEqual(state.activeProxies, []); +}); + +test('disconnect reports failure truthfully and retains session when an owned proxy cannot stop', () => { + const sessionStore = createSessionStore(); + sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie'); + const tracked = [{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 }]; + const runtime = new AppRuntime({ + sessionStore, + altaClient: {}, + proxyManager: { + listTrackedProxies() { return tracked.slice(); }, + stopProxy() { return { success: false, processId: 4101, status: 'permission-denied' }; }, + }, + }); + const state = runtime.disconnect(); + assert.equal(state.success, false); + assert.equal(state.connected, true); + assert.deepEqual(state.activeProxies.map((entry) => entry.processId), [4101]); +}); + test('update runtime is check-only and opens only the fixed GitPeji releases page', async () => { const opened = []; const runtime = new AppRuntime({ diff --git a/test/update-policy.test.js b/test/update-policy.test.js index 626ce3f..956f61c 100644 --- a/test/update-policy.test.js +++ b/test/update-policy.test.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); +const { EventEmitter } = require('node:events'); const { LATEST_RELEASE_URL, @@ -9,6 +10,7 @@ const { MAX_BODY_BYTES, checkForUpdate, compareSemver, + defaultRequest, } = require('../src/update-policy'); function response(body, overrides = {}) { @@ -188,3 +190,35 @@ test('unexpected HTTP and content types fail closed', async () => { 'INVALID_CONTENT_TYPE', ); }); + +test('default transport enforces an absolute deadline despite trickled response bytes', async () => { + const request = new EventEmitter(); + const responseStream = new EventEmitter(); + request.destroyedWith = null; + responseStream.destroyed = false; + request.destroy = (error) => { request.destroyedWith = error; request.emit('error', error); }; + request.setTimeout = () => { throw new Error('inactivity timeout must not be used'); }; + responseStream.destroy = () => { responseStream.destroyed = true; }; + responseStream.headers = { 'content-type': 'application/json' }; + responseStream.statusCode = 200; + let deadline; + let cleared = false; + const pending = defaultRequest({ + url: LATEST_RELEASE_URL, + timeoutMs: 5000, + maxBodyBytes: MAX_BODY_BYTES, + httpsGet: (_url, _options, onResponse) => { + onResponse(responseStream); + return request; + }, + setTimer: (callback, milliseconds) => { assert.equal(milliseconds, 5000); deadline = callback; return 7; }, + clearTimer: (timer) => { assert.equal(timer, 7); cleared = true; }, + }); + responseStream.emit('data', Buffer.from('{')); + responseStream.emit('data', Buffer.from(' ')); + deadline(); + await rejectsWithCode(pending, 'REQUEST_TIMEOUT'); + assert.equal(responseStream.destroyed, true); + assert.equal(request.destroyedWith.code, 'REQUEST_TIMEOUT'); + assert.equal(cleared, true); +});