feat: add paired Chrome bridge authentication
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { PassThrough, Readable } = require('node:stream');
|
||||
|
||||
const {
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
generatePairingSecret,
|
||||
hashPairingSecret,
|
||||
verifyPairingSecret,
|
||||
readJsonBody,
|
||||
createRequestLimiter
|
||||
} = require('../src/bridge-auth');
|
||||
|
||||
const EXTENSION_ORIGIN = 'chrome-extension://onbkfpbggekakjddomjjnboippimlmch';
|
||||
|
||||
test('pairing secrets are random, URL-safe, and stored as a hash envelope', () => {
|
||||
const first = generatePairingSecret();
|
||||
const second = generatePairingSecret();
|
||||
assert.match(first, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.notEqual(first, second);
|
||||
|
||||
const envelope = hashPairingSecret(first);
|
||||
assert.equal(envelope.version, 1);
|
||||
assert.equal(envelope.algorithm, 'scrypt');
|
||||
assert.equal(Object.values(envelope).includes(first), false);
|
||||
assert.equal(verifyPairingSecret(first, envelope), true);
|
||||
assert.equal(verifyPairingSecret(second, envelope), false);
|
||||
assert.equal(verifyPairingSecret('', envelope), false);
|
||||
assert.equal(verifyPairingSecret(null, envelope), false);
|
||||
});
|
||||
|
||||
test('auth rejects missing/wrong secrets and unknown or malformed origins', () => {
|
||||
const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const { secret } = auth.rotate();
|
||||
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret }), true);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: 'wrong' }), false);
|
||||
assert.equal(auth.authenticate({ origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', secret }), false);
|
||||
assert.equal(auth.authenticate({ origin: `${EXTENSION_ORIGIN}/`, secret }), false);
|
||||
assert.equal(auth.authenticate({ origin: `${EXTENSION_ORIGIN}\r\nX-Evil: yes`, secret }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: `${secret}\r\n` }), false);
|
||||
});
|
||||
|
||||
test('rotation invalidates the previous secret and revoke invalidates the current secret', () => {
|
||||
const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const first = auth.rotate().secret;
|
||||
const second = auth.rotate().secret;
|
||||
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: first }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), true);
|
||||
auth.revoke();
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), false);
|
||||
assert.equal(auth.envelope, null);
|
||||
});
|
||||
|
||||
test('an envelope can be safely persisted and loaded by a future desktop wiring', () => {
|
||||
const first = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const { secret, envelope } = first.rotate();
|
||||
const persisted = JSON.parse(JSON.stringify(envelope));
|
||||
const restored = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, envelope: persisted });
|
||||
|
||||
assert.equal(restored.authenticate({ origin: EXTENSION_ORIGIN, secret }), true);
|
||||
assert.throws(
|
||||
() => new BridgeAuth({ expectedOrigin: 'https://example.test' }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN'
|
||||
);
|
||||
assert.throws(
|
||||
() => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN'
|
||||
);
|
||||
});
|
||||
|
||||
test('readJsonBody accepts a bounded JSON object and rejects malformed or oversized input', async () => {
|
||||
assert.deepEqual(
|
||||
await readJsonBody(Readable.from(['{"ok":true}']), { maxBytes: 64 }),
|
||||
{ ok: true }
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
readJsonBody(Readable.from(['{"nope"']), { maxBytes: 64 }),
|
||||
(error) => error.code === 'MALFORMED_JSON'
|
||||
);
|
||||
await assert.rejects(
|
||||
readJsonBody(Readable.from(['{"value":"', 'x'.repeat(100), '"}']), { maxBytes: 32 }),
|
||||
(error) => error.code === 'BODY_TOO_LARGE'
|
||||
);
|
||||
await assert.rejects(
|
||||
readJsonBody(Readable.from(['[]']), { maxBytes: 64 }),
|
||||
(error) => error.code === 'INVALID_JSON_BODY'
|
||||
);
|
||||
});
|
||||
|
||||
test('readJsonBody aborts a slow body at its deadline', async () => {
|
||||
const stream = new PassThrough();
|
||||
const pending = readJsonBody(stream, { maxBytes: 64, deadlineMs: 15 });
|
||||
stream.write('{');
|
||||
await assert.rejects(pending, (error) => error.code === 'BODY_DEADLINE_EXCEEDED');
|
||||
});
|
||||
|
||||
test('request limiter rejects excess concurrent bodies before work begins', async () => {
|
||||
const limiter = createRequestLimiter({ maxConcurrent: 1 });
|
||||
let release;
|
||||
const active = limiter.run(() => new Promise((resolve) => { release = resolve; }));
|
||||
await assert.rejects(
|
||||
limiter.run(async () => 'never'),
|
||||
(error) => error.code === 'TOO_MANY_REQUESTS'
|
||||
);
|
||||
release('done');
|
||||
assert.equal(await active, 'done');
|
||||
assert.equal(limiter.active, 0);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { APT_EXTENSION_ID, APT_EXTENSION_ORIGIN } = require('../src/bridge-auth');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const EXTENSION = path.join(ROOT, 'chrome-extension');
|
||||
const OLD_TOKEN = 'apt-local-' + 'bridge-token';
|
||||
const EXPECTED_ID = 'onbkfpbggekakjddomjjnboippimlmch';
|
||||
|
||||
function read(name) {
|
||||
return fs.readFileSync(path.join(EXTENSION, name), 'utf8');
|
||||
}
|
||||
|
||||
function extensionIdFromKey(key) {
|
||||
const digest = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest().subarray(0, 16);
|
||||
return [...digest].map((byte) =>
|
||||
String.fromCharCode(97 + (byte >> 4), 97 + (byte & 0x0f))
|
||||
).join('');
|
||||
}
|
||||
|
||||
function makeElement() {
|
||||
const listeners = {};
|
||||
return {
|
||||
textContent: '',
|
||||
className: '',
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
checked: false,
|
||||
value: '',
|
||||
addEventListener(type, listener) { listeners[type] = listener; },
|
||||
async dispatch(type) { return listeners[type]?.({ preventDefault() {} }); }
|
||||
};
|
||||
}
|
||||
|
||||
function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null } = {}) {
|
||||
const elements = Object.fromEntries(
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn']
|
||||
.map((id) => [id, makeElement()])
|
||||
);
|
||||
const clipboardWrites = [];
|
||||
const fetchCalls = [];
|
||||
let cookieReads = 0;
|
||||
const chromeApi = {
|
||||
storage: { local: { get: async () => paired ? { aptPairingSecret: 'A'.repeat(43) } : {} } },
|
||||
tabs: { query: async () => [{ url: 'https://customer.avasecurity.com/devices' }] },
|
||||
cookies: { get: async () => { cookieReads += 1; return { value: 'sensitive-va-token' }; } },
|
||||
runtime: { openOptionsPage() {} }
|
||||
};
|
||||
const navigatorApi = {
|
||||
clipboard: {
|
||||
async writeText(value) {
|
||||
if (clipboardReject) throw clipboardReject;
|
||||
clipboardWrites.push(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
const documentApi = { getElementById: (id) => elements[id] };
|
||||
const { createPopupController } = require('../chrome-extension/popup.js');
|
||||
const controller = createPopupController({
|
||||
chromeApi,
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
confirmCopy: () => confirmCopy,
|
||||
fetchImpl: async (...args) => {
|
||||
fetchCalls.push(args);
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
}
|
||||
});
|
||||
return { controller, elements, clipboardWrites, fetchCalls, get cookieReads() { return cookieReads; } };
|
||||
}
|
||||
|
||||
test('manifest commits only a public key, stable ID, local storage, and exact loopback host access', () => {
|
||||
const manifest = JSON.parse(read('manifest.json'));
|
||||
assert.equal(extensionIdFromKey(manifest.key), EXPECTED_ID);
|
||||
assert.equal(APT_EXTENSION_ID, EXPECTED_ID);
|
||||
assert.equal(APT_EXTENSION_ORIGIN, `chrome-extension://${EXPECTED_ID}`);
|
||||
assert.ok(manifest.permissions.includes('storage'));
|
||||
assert.equal(manifest.options_page, 'options.html');
|
||||
assert.ok(manifest.host_permissions.includes('http://127.0.0.1:18247/*'));
|
||||
assert.equal(manifest.host_permissions.some((entry) => entry.includes('localhost')), false);
|
||||
assert.equal(/PRIVATE KEY/.test(manifest.key), false);
|
||||
});
|
||||
|
||||
test('extension source contains no old token and sends only to the exact bridge endpoint', () => {
|
||||
const sources = ['manifest.json', 'popup.js', 'popup.html', 'options.js', 'options.html']
|
||||
.map(read).join('\n');
|
||||
assert.equal(sources.includes(OLD_TOKEN), false);
|
||||
assert.match(read('popup.js'), /http:\/\/127\.0\.0\.1:18247\/cookie/);
|
||||
assert.doesNotMatch(sources, /http:\/\/(?:localhost|\[::1\]|0\.0\.0\.0):18247/);
|
||||
assert.doesNotMatch(sources, /console\.(?:log|debug|info)\s*\(/);
|
||||
});
|
||||
|
||||
test('popup stays disabled while unpaired and directs the user to pairing options', async () => {
|
||||
const harness = makePopupHarness({ paired: false });
|
||||
await harness.controller.init();
|
||||
assert.equal(harness.elements.sendBtn.disabled, true);
|
||||
assert.equal(harness.elements.copyBtn.disabled, true);
|
||||
assert.match(harness.elements.pairingInfo.textContent, /Not paired/i);
|
||||
assert.equal(harness.elements.openOptionsBtn.hidden, false);
|
||||
});
|
||||
|
||||
test('paired popup enables preserved actions only on a supported Alta HTTPS tab', async () => {
|
||||
const harness = makePopupHarness({ paired: true });
|
||||
await harness.controller.init();
|
||||
assert.equal(harness.elements.sendBtn.textContent || 'Send to APT', 'Send to APT');
|
||||
assert.equal(harness.elements.copyBtn.textContent || 'Copy VA Token', 'Copy VA Token');
|
||||
assert.equal(harness.elements.sendBtn.disabled, false);
|
||||
assert.equal(harness.elements.copyBtn.disabled, false);
|
||||
assert.match(harness.elements.pairingInfo.textContent, /Paired/i);
|
||||
});
|
||||
|
||||
test('deployment detection rejects non-HTTPS, bare, and lookalike Alta hosts', () => {
|
||||
const { isSupportedDeploymentUrl } = require('../chrome-extension/popup.js');
|
||||
assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com/path'), true);
|
||||
assert.equal(isSupportedDeploymentUrl('http://customer.avasecurity.com/path'), false);
|
||||
assert.equal(isSupportedDeploymentUrl('https://avasecurity.com/path'), false);
|
||||
assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com.evil.test/path'), false);
|
||||
});
|
||||
|
||||
test('Send to APT uses the paired secret header and exact endpoint', async () => {
|
||||
const harness = makePopupHarness({ paired: true });
|
||||
await harness.controller.init();
|
||||
await harness.controller.sendToApt();
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
const [url, request] = harness.fetchCalls[0];
|
||||
assert.equal(url, 'http://127.0.0.1:18247/cookie');
|
||||
assert.equal(request.headers['X-APT-Pairing-Secret'], 'A'.repeat(43));
|
||||
assert.deepEqual(JSON.parse(request.body), {
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'sensitive-va-token'
|
||||
});
|
||||
});
|
||||
|
||||
test('copy cancellation occurs before cookie access and never writes the token', async () => {
|
||||
const harness = makePopupHarness({ paired: true, confirmCopy: false });
|
||||
await harness.controller.init();
|
||||
await harness.controller.copyToken();
|
||||
assert.equal(harness.cookieReads, 0);
|
||||
assert.deepEqual(harness.clipboardWrites, []);
|
||||
assert.match(harness.elements.statusMsg.textContent, /cancelled/i);
|
||||
});
|
||||
|
||||
test('confirmed copy warns about clipboard history and reports success without exposing token', async () => {
|
||||
const harness = makePopupHarness({ paired: true, confirmCopy: true });
|
||||
await harness.controller.init();
|
||||
assert.match(harness.elements.copyWarning.textContent, /clipboard (?:history|sync)/i);
|
||||
await harness.controller.copyToken();
|
||||
assert.deepEqual(harness.clipboardWrites, ['sensitive-va-token']);
|
||||
assert.match(harness.elements.statusMsg.textContent, /copied/i);
|
||||
assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false);
|
||||
});
|
||||
|
||||
test('clipboard permission denial is handled without leaking the token', async () => {
|
||||
const harness = makePopupHarness({
|
||||
paired: true,
|
||||
confirmCopy: true,
|
||||
clipboardReject: new Error('NotAllowedError')
|
||||
});
|
||||
await harness.controller.init();
|
||||
await harness.controller.copyToken();
|
||||
assert.match(harness.elements.statusMsg.textContent, /could not copy/i);
|
||||
assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false);
|
||||
});
|
||||
|
||||
test('options UI stores a validated pairing secret locally and can forget pairing', () => {
|
||||
const html = read('options.html');
|
||||
const js = read('options.js');
|
||||
assert.match(html, /pairingSecret/);
|
||||
assert.match(html, /type="password"/);
|
||||
assert.match(js, /chrome\.storage\.local\.set/);
|
||||
assert.match(js, /chrome\.storage\.local\.remove/);
|
||||
assert.match(js, /aptPairingSecret/);
|
||||
assert.doesNotMatch(js, /console\./);
|
||||
});
|
||||
Reference in New Issue
Block a user