239 lines
8.0 KiB
JavaScript
239 lines
8.0 KiB
JavaScript
'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 {
|
|
const url = new URL(value);
|
|
const host = url.hostname.toLowerCase();
|
|
return url.protocol === 'https:' &&
|
|
(host.endsWith('.avasecurity.com') || host.endsWith('.avigilon.com'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createPopupController({
|
|
chromeApi,
|
|
documentApi,
|
|
navigatorApi,
|
|
fetchImpl,
|
|
confirmCopy,
|
|
cryptoApi
|
|
}) {
|
|
const tabInfo = documentApi.getElementById('tabInfo');
|
|
const pairingInfo = documentApi.getElementById('pairingInfo');
|
|
const sendBtn = documentApi.getElementById('sendBtn');
|
|
const copyBtn = documentApi.getElementById('copyBtn');
|
|
const statusMsg = documentApi.getElementById('statusMsg');
|
|
const openOptionsBtn = documentApi.getElementById('openOptionsBtn');
|
|
|
|
let detectedOrigin = null;
|
|
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}`;
|
|
}
|
|
|
|
function updateActions() {
|
|
const disabled = busy || !detectedOrigin || !pairingSecret;
|
|
sendBtn.disabled = disabled;
|
|
copyBtn.disabled = disabled;
|
|
}
|
|
|
|
function setBusy(value) {
|
|
busy = value;
|
|
updateActions();
|
|
}
|
|
|
|
async function getVaCookieValue() {
|
|
if (!detectedOrigin) throw new Error('NO_DEPLOYMENT');
|
|
const cookie = await chromeApi.cookies.get({ url: detectedOrigin, name: 'va' });
|
|
if (!cookie || !cookie.value) throw new Error('MISSING_COOKIE');
|
|
if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) {
|
|
throw new Error('EXPIRED_COOKIE');
|
|
}
|
|
return cookie.value;
|
|
}
|
|
|
|
function showCookieError(error, copyOperation = false) {
|
|
if (error && error.message === 'MISSING_COOKIE') {
|
|
showStatus('No VA token found. Log in to Alta and try again.', 'error');
|
|
} else if (error && error.message === 'EXPIRED_COOKIE') {
|
|
showStatus('The VA token has expired. Log in to Alta again.', 'error');
|
|
} else if (copyOperation) {
|
|
showStatus('Could not copy the VA token. Check clipboard permission.', 'error');
|
|
} else {
|
|
showStatus('Could not reach Alta Proxy Tool on this computer.', 'error');
|
|
}
|
|
}
|
|
|
|
async function sendToApt() {
|
|
if (!detectedOrigin || !pairingSecret || busy) return;
|
|
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 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' },
|
|
body: JSON.stringify(cookieRequest)
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data || data.success !== true) throw new Error('BRIDGE_REJECTED');
|
|
showStatus('VA token sent successfully.', 'success');
|
|
} catch (error) {
|
|
showCookieError(error);
|
|
} finally {
|
|
cookieValue = null;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function copyToken() {
|
|
if (!detectedOrigin || !pairingSecret || busy) return;
|
|
const confirmed = confirmCopy('Copy VA token?');
|
|
if (!confirmed) {
|
|
showStatus('Copy cancelled. The VA token was not read.', 'info');
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
let cookieValue = null;
|
|
try {
|
|
cookieValue = await getVaCookieValue();
|
|
await navigatorApi.clipboard.writeText(cookieValue);
|
|
showStatus('VA token copied.', 'success');
|
|
} catch (error) {
|
|
showCookieError(error, true);
|
|
} finally {
|
|
cookieValue = null;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
sendBtn.addEventListener('click', sendToApt);
|
|
copyBtn.addEventListener('click', copyToken);
|
|
openOptionsBtn.addEventListener('click', () => chromeApi.runtime.openOptionsPage());
|
|
|
|
const stored = await chromeApi.storage.local.get(PAIRING_STORAGE_KEY);
|
|
const candidate = stored && stored[PAIRING_STORAGE_KEY];
|
|
if (PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : '')) {
|
|
pairingSecret = candidate;
|
|
pairingInfo.textContent = 'Paired with Alta Proxy Tool';
|
|
pairingInfo.className = 'pairing-info paired';
|
|
openOptionsBtn.hidden = true;
|
|
} else {
|
|
pairingInfo.textContent = 'Not paired. Add the one-time secret from APT.';
|
|
pairingInfo.className = 'pairing-info unpaired';
|
|
openOptionsBtn.hidden = false;
|
|
}
|
|
|
|
const tabs = await chromeApi.tabs.query({ active: true, currentWindow: true });
|
|
const tab = tabs && tabs[0];
|
|
if (!tab || !isSupportedDeploymentUrl(tab.url)) {
|
|
tabInfo.textContent = 'This tab is not an Alta deployment.';
|
|
tabInfo.className = 'tab-info not-detected';
|
|
updateActions();
|
|
return;
|
|
}
|
|
|
|
const url = new URL(tab.url);
|
|
detectedOrigin = url.origin;
|
|
tabInfo.textContent = `Detected: ${url.hostname}`;
|
|
tabInfo.className = 'tab-info detected';
|
|
updateActions();
|
|
}
|
|
|
|
return { copyToken, init, sendToApt };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = {
|
|
APT_URL,
|
|
PAIRING_STORAGE_KEY,
|
|
createPopupController,
|
|
isSupportedDeploymentUrl
|
|
};
|
|
} else {
|
|
createPopupController({
|
|
chromeApi: chrome,
|
|
documentApi: document,
|
|
navigatorApi: navigator,
|
|
fetchImpl: fetch,
|
|
confirmCopy: (message) => window.confirm(message),
|
|
cryptoApi: crypto
|
|
}).init().catch(() => {
|
|
const status = document.getElementById('statusMsg');
|
|
status.textContent = 'Extension initialization failed. Open pairing settings and try again.';
|
|
status.className = 'status-msg error';
|
|
});
|
|
}
|