fix: close APT adversarial runtime gaps

This commit is contained in:
2026-08-19 21:59:19 +00:00
parent 2f492b662d
commit cf75ea9bc2
9 changed files with 688 additions and 233 deletions
+68 -8
View File
@@ -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.';
+13 -4
View File
@@ -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', () => {
+161 -96
View File
@@ -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
};
+67 -18
View File
@@ -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,
+26 -5
View File
@@ -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);
+133 -66
View File
@@ -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);
+38 -9
View File
@@ -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 () => {
+148 -27
View File
@@ -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({
+34
View File
@@ -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);
});