feat: add paired Chrome bridge authentication
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Alta Proxy Tool Bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "Send Alta session cookies to the Alta Proxy Tool desktop app.",
|
||||
"permissions": ["cookies", "activeTab", "clipboardWrite"],
|
||||
"version": "1.1.0",
|
||||
"description": "Send Alta session cookies to a paired Alta Proxy Tool desktop app.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4EZdkSgOsyiy5DRe0JkX+BpK94FpMjBU59NVIqDPO8QBDwvqNDWT/UjqHK/0aqSxzed5KibX6MdAvc495+u1sCybFdjDdXyBewEvg+PDqGiJketlZKC9dcR1RXHuPgAoM3NaNbMb3TqYcS9J4iGq0UwadxubkQrEcPiuyR6oriOkop8q9/5DWGb15wOGmiCuVmlXfUjNJIvNBm9P/ZHtgFBYDI2PuSSI5GI4j04VFpEyfNlFCrpi8GQ7bYZzezigZWXRjhhNwkx39bNHlkAWYa8XGZseCpKKvi0EaeCoBPjoYSAt161SM1dqX+/UC61/sLOU/SpDB1SYGTm5DC+7wIDAQAB",
|
||||
"permissions": ["cookies", "activeTab", "clipboardWrite", "storage"],
|
||||
"host_permissions": [
|
||||
"https://*.avasecurity.com/*",
|
||||
"https://*.avigilon.com/*",
|
||||
"http://127.0.0.1:18247/*"
|
||||
],
|
||||
"options_page": "options.html",
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.options-card {
|
||||
max-width: 560px;
|
||||
margin: 48px auto;
|
||||
padding: 28px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
background: #252526;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 { margin-top: 0; color: #75afff; }
|
||||
h2 { margin-bottom: 6px; font-size: 16px; }
|
||||
p { line-height: 1.5; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #1e1e1e;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #0e7afe;
|
||||
outline: 2px solid rgba(14, 122, 254, 0.3);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-btn { border: 0; background: #0e7afe; }
|
||||
.danger-btn { border: 1px solid #f44336; background: transparent; color: #ff7b72; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
|
||||
.status {
|
||||
min-height: 20px;
|
||||
margin-top: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.success,
|
||||
.paired { color: #7bd67e; }
|
||||
.status.error,
|
||||
.unpaired { color: #ffc36d; }
|
||||
|
||||
.paired-controls {
|
||||
margin-top: 24px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin-top: 24px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
|
||||
<title>Pair Alta Proxy Tool</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="options-card">
|
||||
<h1>Pair with Alta Proxy Tool</h1>
|
||||
<p>In the desktop app, create a one-time pairing secret. Paste it below on this computer. Treat it like a password.</p>
|
||||
<form id="pairingForm">
|
||||
<label for="pairingSecret">One-time pairing secret</label>
|
||||
<input id="pairingSecret" name="pairingSecret" type="password" inputmode="text" autocomplete="off" spellcheck="false" required>
|
||||
<button type="submit" class="primary-btn">Pair extension</button>
|
||||
</form>
|
||||
<div id="pairingStatus" class="status" role="status" aria-live="polite"></div>
|
||||
<section class="paired-controls">
|
||||
<h2>Current pairing</h2>
|
||||
<p id="currentState">Checking...</p>
|
||||
<button id="forgetBtn" type="button" class="danger-btn" disabled>Forget pairing</button>
|
||||
</section>
|
||||
<p class="privacy-note">The secret is stored only in Chrome extension local storage. It is never displayed again or copied automatically.</p>
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
const PAIRING_STORAGE_KEY = 'aptPairingSecret';
|
||||
const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
const pairingForm = document.getElementById('pairingForm');
|
||||
const pairingSecretInput = document.getElementById('pairingSecret');
|
||||
const pairingStatus = document.getElementById('pairingStatus');
|
||||
const currentState = document.getElementById('currentState');
|
||||
const forgetBtn = document.getElementById('forgetBtn');
|
||||
|
||||
function setStatus(message, type) {
|
||||
pairingStatus.textContent = message;
|
||||
pairingStatus.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function renderPairingState(paired) {
|
||||
currentState.textContent = paired ? 'Paired on this Chrome profile.' : 'Not paired.';
|
||||
currentState.className = paired ? 'paired' : 'unpaired';
|
||||
forgetBtn.disabled = !paired;
|
||||
}
|
||||
|
||||
async function refreshPairingState() {
|
||||
const stored = await chrome.storage.local.get(PAIRING_STORAGE_KEY);
|
||||
const candidate = stored && stored[PAIRING_STORAGE_KEY];
|
||||
renderPairingState(PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : ''));
|
||||
}
|
||||
|
||||
pairingForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const candidate = pairingSecretInput.value.trim();
|
||||
pairingSecretInput.value = '';
|
||||
|
||||
if (!PAIRING_SECRET_PATTERN.test(candidate)) {
|
||||
setStatus('That secret is not valid. Generate a new pairing secret in APT and paste it exactly.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.storage.local.set({ [PAIRING_STORAGE_KEY]: candidate });
|
||||
setStatus('Pairing saved. You can now use Send to APT from an Alta tab.', 'success');
|
||||
renderPairingState(true);
|
||||
} catch {
|
||||
setStatus('Chrome could not save the pairing. Try again.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
forgetBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await chrome.storage.local.remove(PAIRING_STORAGE_KEY);
|
||||
renderPairingState(false);
|
||||
setStatus('Pairing forgotten. Revoke or rotate it in APT as well.', 'success');
|
||||
} catch {
|
||||
setStatus('Chrome could not forget the pairing. Try again.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
refreshPairingState().catch(() => {
|
||||
renderPairingState(false);
|
||||
setStatus('Chrome could not read pairing state.', 'error');
|
||||
});
|
||||
+70
-59
@@ -2,120 +2,131 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
background: #1E1E1E;
|
||||
color: #E0E0E0;
|
||||
font-size: 14px;
|
||||
min-width: 300px;
|
||||
min-width: 320px;
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
font: 14px/1.4 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.popup-container { padding: 16px; }
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
color: #0e7afe;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0E7AFE;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tab-info {
|
||||
background: #2D2D30;
|
||||
border: 1px solid #3C3C3C;
|
||||
.tab-info,
|
||||
.pairing-info {
|
||||
margin-bottom: 10px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #2d2d30;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
color: #999999;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tab-info.detected {
|
||||
color: #4CAF50;
|
||||
border-color: #4CAF50;
|
||||
.tab-info.detected,
|
||||
.pairing-info.paired {
|
||||
border-color: #4caf50;
|
||||
color: #7bd67e;
|
||||
}
|
||||
|
||||
.tab-info.not-detected {
|
||||
color: #F44336;
|
||||
border-color: #F44336;
|
||||
.tab-info.not-detected,
|
||||
.pairing-info.unpaired {
|
||||
border-color: #f0a33a;
|
||||
color: #ffc36d;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-buttons { display: grid; gap: 8px; }
|
||||
|
||||
.primary-btn,
|
||||
.secondary-btn {
|
||||
display: block;
|
||||
.secondary-btn,
|
||||
.link-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background: #0E7AFE;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: #0E7AFE;
|
||||
border: 0;
|
||||
background: #0e7afe;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #2D2D30;
|
||||
border: 1px solid #0E7AFE;
|
||||
color: #E0E0E0;
|
||||
border: 1px solid #0e7afe;
|
||||
background: #2d2d30;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.primary-btn:hover:not(:disabled) {
|
||||
background: #0A5FD9;
|
||||
.link-btn {
|
||||
margin: -2px 0 10px;
|
||||
padding: 5px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #75afff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.secondary-btn:hover:not(:disabled) {
|
||||
background: #0E7AFE;
|
||||
}
|
||||
.primary-btn:hover:not(:disabled) { background: #0a5fd9; }
|
||||
.secondary-btn:hover:not(:disabled) { background: #0e7afe; }
|
||||
|
||||
.primary-btn:disabled,
|
||||
.secondary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.copy-warning {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #f0a33a;
|
||||
background: rgba(240, 163, 58, 0.08);
|
||||
color: #d7c29f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
display: none;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-msg.success,
|
||||
.status-msg.error,
|
||||
.status-msg.info { display: block; }
|
||||
|
||||
.status-msg.success {
|
||||
display: block;
|
||||
border-color: #4caf50;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4CAF50;
|
||||
border-color: #4CAF50;
|
||||
color: #7bd67e;
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
display: block;
|
||||
border-color: #f44336;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #F44336;
|
||||
border-color: #F44336;
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.status-msg.info {
|
||||
display: block;
|
||||
border-color: #0e7afe;
|
||||
background: rgba(14, 122, 254, 0.1);
|
||||
color: #0E7AFE;
|
||||
border-color: #0E7AFE;
|
||||
color: #75afff;
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@@ -3,19 +3,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src http://127.0.0.1:18247;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Alta Proxy Tool Bridge</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-container">
|
||||
<main class="popup-container">
|
||||
<h1>Alta Proxy Tool</h1>
|
||||
<div id="pairingInfo" class="pairing-info">Checking pairing...</div>
|
||||
<button id="openOptionsBtn" class="link-btn" type="button" hidden>Open pairing settings</button>
|
||||
<div id="tabInfo" class="tab-info">Checking tab...</div>
|
||||
<div class="action-buttons">
|
||||
<button id="sendBtn" class="primary-btn" disabled>Send to APT</button>
|
||||
<button id="copyBtn" class="secondary-btn" disabled>Copy VA Token</button>
|
||||
<button id="sendBtn" class="primary-btn" type="button" disabled>Send to APT</button>
|
||||
<button id="copyBtn" class="secondary-btn" type="button" disabled>Copy VA Token</button>
|
||||
</div>
|
||||
<div id="statusMsg" class="status-msg"></div>
|
||||
</div>
|
||||
<p id="copyWarning" class="copy-warning">Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.</p>
|
||||
<div id="statusMsg" class="status-msg" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+167
-115
@@ -1,130 +1,182 @@
|
||||
'use strict';
|
||||
|
||||
const APT_URL = 'http://127.0.0.1:18247/cookie';
|
||||
const APT_TOKEN = 'apt-local-bridge-token';
|
||||
const PAIRING_STORAGE_KEY = 'aptPairingSecret';
|
||||
const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
const tabInfo = document.getElementById('tabInfo');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const statusMsg = document.getElementById('statusMsg');
|
||||
|
||||
let detectedOrigin = null;
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusMsg.textContent = message;
|
||||
statusMsg.className = 'status-msg ' + type;
|
||||
}
|
||||
|
||||
function setActionButtonsDisabled(disabled) {
|
||||
sendBtn.disabled = disabled;
|
||||
copyBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
async function getVaCookieValue() {
|
||||
if (!detectedOrigin) {
|
||||
throw new Error('No Alta deployment detected.');
|
||||
}
|
||||
|
||||
const cookie = await chrome.cookies.get({ url: detectedOrigin, name: 'va' });
|
||||
|
||||
if (!cookie || !cookie.value) {
|
||||
throw new Error('No "va" session cookie found. Are you logged in?');
|
||||
}
|
||||
|
||||
if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) {
|
||||
throw new Error('Session cookie has expired. Please log in again.');
|
||||
}
|
||||
|
||||
return cookie.value;
|
||||
}
|
||||
|
||||
// Check the active tab on popup open
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (!tabs || tabs.length === 0) {
|
||||
tabInfo.textContent = 'No active tab found.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = tabs[0];
|
||||
let url;
|
||||
function isSupportedDeploymentUrl(value) {
|
||||
try {
|
||||
url = new URL(tab.url);
|
||||
const url = new URL(value);
|
||||
const host = url.hostname.toLowerCase();
|
||||
return url.protocol === 'https:' &&
|
||||
(host.endsWith('.avasecurity.com') || host.endsWith('.avigilon.com'));
|
||||
} catch {
|
||||
tabInfo.textContent = 'Cannot read this tab URL.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createPopupController({
|
||||
chromeApi,
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
fetchImpl,
|
||||
confirmCopy
|
||||
}) {
|
||||
const tabInfo = documentApi.getElementById('tabInfo');
|
||||
const pairingInfo = documentApi.getElementById('pairingInfo');
|
||||
const sendBtn = documentApi.getElementById('sendBtn');
|
||||
const copyBtn = documentApi.getElementById('copyBtn');
|
||||
const copyWarning = documentApi.getElementById('copyWarning');
|
||||
const statusMsg = documentApi.getElementById('statusMsg');
|
||||
const openOptionsBtn = documentApi.getElementById('openOptionsBtn');
|
||||
|
||||
let detectedOrigin = null;
|
||||
let pairingSecret = null;
|
||||
let busy = false;
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusMsg.textContent = message;
|
||||
statusMsg.className = `status-msg ${type}`;
|
||||
}
|
||||
|
||||
const hostname = url.hostname;
|
||||
const isAlta = hostname.endsWith('.avasecurity.com') || hostname.endsWith('.avigilon.com');
|
||||
|
||||
if (!isAlta) {
|
||||
tabInfo.textContent = 'This tab is not an Alta deployment.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
return;
|
||||
function updateActions() {
|
||||
const disabled = busy || !detectedOrigin || !pairingSecret;
|
||||
sendBtn.disabled = disabled;
|
||||
copyBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
detectedOrigin = url.origin;
|
||||
tabInfo.textContent = 'Detected: ' + hostname;
|
||||
tabInfo.className = 'tab-info detected';
|
||||
setActionButtonsDisabled(false);
|
||||
});
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
updateActions();
|
||||
}
|
||||
|
||||
// Send cookie on button click
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
if (!detectedOrigin) return;
|
||||
|
||||
setActionButtonsDisabled(true);
|
||||
showStatus('Retrieving VA token...', 'info');
|
||||
|
||||
try {
|
||||
const cookieValue = await getVaCookieValue();
|
||||
|
||||
showStatus('Sending to Alta Proxy Tool...', 'info');
|
||||
|
||||
const response = await fetch(APT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-APT-Token': APT_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deploymentUrl: detectedOrigin,
|
||||
cookieValue
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showStatus('VA token sent successfully!', 'success');
|
||||
} else {
|
||||
showStatus('Error: ' + (data.message || 'Unknown error'), 'error');
|
||||
async function getVaCookieValue() {
|
||||
if (!detectedOrigin) throw new Error('NO_DEPLOYMENT');
|
||||
const cookie = await chromeApi.cookies.get({ url: detectedOrigin, name: 'va' });
|
||||
if (!cookie || !cookie.value) throw new Error('MISSING_COOKIE');
|
||||
if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) {
|
||||
throw new Error('EXPIRED_COOKIE');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('Failed to fetch')) {
|
||||
showStatus('Alta Proxy Tool is not running.', 'error');
|
||||
return cookie.value;
|
||||
}
|
||||
|
||||
function showCookieError(error, copyOperation = false) {
|
||||
if (error && error.message === 'MISSING_COOKIE') {
|
||||
showStatus('No VA token found. Log in to Alta and try again.', 'error');
|
||||
} else if (error && error.message === 'EXPIRED_COOKIE') {
|
||||
showStatus('The VA token has expired. Log in to Alta again.', 'error');
|
||||
} else if (copyOperation) {
|
||||
showStatus('Could not copy the VA token. Check clipboard permission.', 'error');
|
||||
} else {
|
||||
showStatus('Error: ' + err.message, 'error');
|
||||
showStatus('Could not reach Alta Proxy Tool on this computer.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setActionButtonsDisabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy VA token on button click
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
if (!detectedOrigin) return;
|
||||
|
||||
setActionButtonsDisabled(true);
|
||||
showStatus('Retrieving VA token...', 'info');
|
||||
|
||||
try {
|
||||
const cookieValue = await getVaCookieValue();
|
||||
await navigator.clipboard.writeText(cookieValue);
|
||||
showStatus('VA token copied to clipboard!', 'success');
|
||||
} catch (err) {
|
||||
showStatus('Error: ' + err.message, 'error');
|
||||
} finally {
|
||||
setActionButtonsDisabled(false);
|
||||
async function sendToApt() {
|
||||
if (!detectedOrigin || !pairingSecret || busy) return;
|
||||
setBusy(true);
|
||||
let cookieValue = null;
|
||||
try {
|
||||
showStatus('Sending to Alta Proxy Tool...', 'info');
|
||||
cookieValue = await getVaCookieValue();
|
||||
const response = await fetchImpl(APT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-APT-Pairing-Secret': pairingSecret
|
||||
},
|
||||
body: JSON.stringify({ deploymentUrl: detectedOrigin, cookieValue })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data || data.success !== true) throw new Error('BRIDGE_REJECTED');
|
||||
showStatus('VA token sent successfully.', 'success');
|
||||
} catch (error) {
|
||||
showCookieError(error);
|
||||
} finally {
|
||||
cookieValue = null;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function copyToken() {
|
||||
if (!detectedOrigin || !pairingSecret || busy) return;
|
||||
const confirmed = confirmCopy(
|
||||
'Copy the full VA bearer token? Clipboard history or sync may retain it. Continue only if you will paste it into a trusted destination.'
|
||||
);
|
||||
if (!confirmed) {
|
||||
showStatus('Copy cancelled. The VA token was not read.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
let cookieValue = null;
|
||||
try {
|
||||
cookieValue = await getVaCookieValue();
|
||||
await navigatorApi.clipboard.writeText(cookieValue);
|
||||
showStatus('VA token copied. Clear your clipboard after use.', 'success');
|
||||
} catch (error) {
|
||||
showCookieError(error, true);
|
||||
} finally {
|
||||
cookieValue = null;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
copyWarning.textContent = 'Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.';
|
||||
sendBtn.addEventListener('click', sendToApt);
|
||||
copyBtn.addEventListener('click', copyToken);
|
||||
openOptionsBtn.addEventListener('click', () => chromeApi.runtime.openOptionsPage());
|
||||
|
||||
const stored = await chromeApi.storage.local.get(PAIRING_STORAGE_KEY);
|
||||
const candidate = stored && stored[PAIRING_STORAGE_KEY];
|
||||
if (PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : '')) {
|
||||
pairingSecret = candidate;
|
||||
pairingInfo.textContent = 'Paired with Alta Proxy Tool';
|
||||
pairingInfo.className = 'pairing-info paired';
|
||||
openOptionsBtn.hidden = true;
|
||||
} else {
|
||||
pairingInfo.textContent = 'Not paired. Add the one-time secret from APT.';
|
||||
pairingInfo.className = 'pairing-info unpaired';
|
||||
openOptionsBtn.hidden = false;
|
||||
}
|
||||
|
||||
const tabs = await chromeApi.tabs.query({ active: true, currentWindow: true });
|
||||
const tab = tabs && tabs[0];
|
||||
if (!tab || !isSupportedDeploymentUrl(tab.url)) {
|
||||
tabInfo.textContent = 'This tab is not an Alta deployment.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
updateActions();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(tab.url);
|
||||
detectedOrigin = url.origin;
|
||||
tabInfo.textContent = `Detected: ${url.hostname}`;
|
||||
tabInfo.className = 'tab-info detected';
|
||||
updateActions();
|
||||
}
|
||||
|
||||
return { copyToken, init, sendToApt };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
APT_URL,
|
||||
PAIRING_STORAGE_KEY,
|
||||
createPopupController,
|
||||
isSupportedDeploymentUrl
|
||||
};
|
||||
} else {
|
||||
createPopupController({
|
||||
chromeApi: chrome,
|
||||
documentApi: document,
|
||||
navigatorApi: navigator,
|
||||
fetchImpl: fetch,
|
||||
confirmCopy: (message) => window.confirm(message)
|
||||
}).init().catch(() => {
|
||||
const status = document.getElementById('statusMsg');
|
||||
status.textContent = 'Extension initialization failed. Open pairing settings and try again.';
|
||||
status.className = 'status-msg error';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const DEFAULT_SECRET_BYTES = 32;
|
||||
const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
||||
const DEFAULT_BODY_DEADLINE_MS = 2_000;
|
||||
const APT_EXTENSION_ID = 'onbkfpbggekakjddomjjnboippimlmch';
|
||||
const APT_EXTENSION_ORIGIN = `chrome-extension://${APT_EXTENSION_ID}`;
|
||||
const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/;
|
||||
|
||||
class BridgeAuthError extends Error {
|
||||
constructor(code, message, statusCode = 400) {
|
||||
super(message);
|
||||
this.name = 'BridgeAuthError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(overrides = {}) {
|
||||
return {
|
||||
randomBytes: overrides.randomBytes || crypto.randomBytes,
|
||||
scryptSync: overrides.scryptSync || crypto.scryptSync,
|
||||
timingSafeEqual: overrides.timingSafeEqual || crypto.timingSafeEqual,
|
||||
setTimeout: overrides.setTimeout || setTimeout,
|
||||
clearTimeout: overrides.clearTimeout || clearTimeout
|
||||
};
|
||||
}
|
||||
|
||||
function generatePairingSecret(options = {}) {
|
||||
const deps = dependencies(options);
|
||||
const byteCount = options.byteCount || DEFAULT_SECRET_BYTES;
|
||||
if (!Number.isSafeInteger(byteCount) || byteCount < DEFAULT_SECRET_BYTES) {
|
||||
throw new BridgeAuthError('INVALID_SECRET_SIZE', 'Pairing secrets must contain at least 32 random bytes.');
|
||||
}
|
||||
return deps.randomBytes(byteCount).toString('base64url');
|
||||
}
|
||||
|
||||
function assertPairingSecret(secret) {
|
||||
if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret)) {
|
||||
throw new BridgeAuthError('INVALID_PAIRING_SECRET', 'The pairing secret has an invalid format.');
|
||||
}
|
||||
}
|
||||
|
||||
function hashPairingSecret(secret, options = {}) {
|
||||
assertPairingSecret(secret);
|
||||
const deps = dependencies(options);
|
||||
const salt = deps.randomBytes(16);
|
||||
const digest = deps.scryptSync(secret, salt, 32);
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
algorithm: 'scrypt',
|
||||
salt: salt.toString('base64url'),
|
||||
digest: Buffer.from(digest).toString('base64url')
|
||||
});
|
||||
}
|
||||
|
||||
function isValidEnvelope(envelope) {
|
||||
return Boolean(
|
||||
envelope &&
|
||||
envelope.version === 1 &&
|
||||
envelope.algorithm === 'scrypt' &&
|
||||
typeof envelope.salt === 'string' &&
|
||||
/^[A-Za-z0-9_-]{22}$/.test(envelope.salt) &&
|
||||
typeof envelope.digest === 'string' &&
|
||||
/^[A-Za-z0-9_-]{43}$/.test(envelope.digest)
|
||||
);
|
||||
}
|
||||
|
||||
function verifyPairingSecret(secret, envelope, options = {}) {
|
||||
if (!SECRET_PATTERN.test(typeof secret === 'string' ? secret : '') || !isValidEnvelope(envelope)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const deps = dependencies(options);
|
||||
const salt = Buffer.from(envelope.salt, 'base64url');
|
||||
const expected = Buffer.from(envelope.digest, 'base64url');
|
||||
const actual = Buffer.from(deps.scryptSync(secret, salt, expected.length));
|
||||
return actual.length === expected.length && deps.timingSafeEqual(actual, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertExtensionOrigin(origin) {
|
||||
if (typeof origin !== 'string' || !EXTENSION_ORIGIN_PATTERN.test(origin)) {
|
||||
throw new BridgeAuthError(
|
||||
'INVALID_EXTENSION_ORIGIN',
|
||||
'Expected an exact stable chrome-extension origin.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BridgeAuth {
|
||||
constructor({ expectedOrigin = APT_EXTENSION_ORIGIN, envelope = null, ...injected } = {}) {
|
||||
assertExtensionOrigin(expectedOrigin);
|
||||
if (expectedOrigin !== APT_EXTENSION_ORIGIN) {
|
||||
throw new BridgeAuthError(
|
||||
'INVALID_EXTENSION_ORIGIN',
|
||||
'The bridge only accepts the committed Alta Proxy Tool extension origin.'
|
||||
);
|
||||
}
|
||||
if (envelope !== null && !isValidEnvelope(envelope)) {
|
||||
throw new BridgeAuthError('INVALID_SECRET_ENVELOPE', 'The persisted pairing envelope is invalid.');
|
||||
}
|
||||
this.expectedOrigin = expectedOrigin;
|
||||
this._envelope = envelope ? Object.freeze({ ...envelope }) : null;
|
||||
this._dependencies = dependencies(injected);
|
||||
}
|
||||
|
||||
get envelope() {
|
||||
return this._envelope ? { ...this._envelope } : null;
|
||||
}
|
||||
|
||||
rotate() {
|
||||
const secret = generatePairingSecret(this._dependencies);
|
||||
this._envelope = hashPairingSecret(secret, this._dependencies);
|
||||
return { secret, envelope: this.envelope };
|
||||
}
|
||||
|
||||
revoke() {
|
||||
this._envelope = null;
|
||||
}
|
||||
|
||||
authenticate({ origin, secret } = {}) {
|
||||
if (origin !== this.expectedOrigin || !this._envelope) return false;
|
||||
return verifyPairingSecret(secret, this._envelope, this._dependencies);
|
||||
}
|
||||
|
||||
authenticateRequest(request) {
|
||||
if (!request || typeof request !== 'object') return false;
|
||||
const headers = request.headers || {};
|
||||
const secret = headers['x-apt-pairing-secret'];
|
||||
return this.authenticate({ origin: headers.origin, secret });
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonBody(stream, options = {}) {
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BODY_BYTES;
|
||||
const deadlineMs = options.deadlineMs ?? DEFAULT_BODY_DEADLINE_MS;
|
||||
const deps = dependencies(options);
|
||||
|
||||
if (!stream || typeof stream.on !== 'function') {
|
||||
return Promise.reject(new BridgeAuthError('INVALID_BODY_STREAM', 'A readable request body is required.'));
|
||||
}
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
return Promise.reject(new BridgeAuthError('INVALID_BODY_LIMIT', 'Body limit must be a positive integer.'));
|
||||
}
|
||||
if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1) {
|
||||
return Promise.reject(new BridgeAuthError('INVALID_BODY_DEADLINE', 'Body deadline must be a positive integer.'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
deps.clearTimeout(timer);
|
||||
stream.removeListener('data', onData);
|
||||
stream.removeListener('end', onEnd);
|
||||
stream.removeListener('error', onError);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (typeof stream.pause === 'function') stream.pause();
|
||||
reject(error);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buffer.length;
|
||||
if (total > maxBytes) {
|
||||
fail(new BridgeAuthError('BODY_TOO_LARGE', 'Request body exceeds the configured limit.', 413));
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
};
|
||||
const onError = () => fail(new BridgeAuthError('BODY_READ_ERROR', 'Could not read the request body.'));
|
||||
const onEnd = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
reject(new BridgeAuthError('MALFORMED_JSON', 'Request body must be valid JSON.'));
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
reject(new BridgeAuthError('INVALID_JSON_BODY', 'Request body must be a JSON object.'));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
};
|
||||
|
||||
const timer = deps.setTimeout(
|
||||
() => fail(new BridgeAuthError('BODY_DEADLINE_EXCEEDED', 'Request body deadline exceeded.', 408)),
|
||||
deadlineMs
|
||||
);
|
||||
stream.on('data', onData);
|
||||
stream.on('end', onEnd);
|
||||
stream.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
function createRequestLimiter({ maxConcurrent = 4 } = {}) {
|
||||
if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) {
|
||||
throw new BridgeAuthError('INVALID_CONCURRENCY_LIMIT', 'Concurrency limit must be a positive integer.');
|
||||
}
|
||||
let active = 0;
|
||||
return {
|
||||
get active() { return active; },
|
||||
async run(work) {
|
||||
if (typeof work !== 'function') {
|
||||
throw new BridgeAuthError('INVALID_REQUEST_WORK', 'Request work must be a function.');
|
||||
}
|
||||
if (active >= maxConcurrent) {
|
||||
throw new BridgeAuthError('TOO_MANY_REQUESTS', 'Too many bridge requests are active.', 429);
|
||||
}
|
||||
active += 1;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
active -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APT_EXTENSION_ID,
|
||||
APT_EXTENSION_ORIGIN,
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
DEFAULT_BODY_DEADLINE_MS,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
EXTENSION_ORIGIN_PATTERN,
|
||||
SECRET_PATTERN,
|
||||
createRequestLimiter,
|
||||
generatePairingSecret,
|
||||
hashPairingSecret,
|
||||
readJsonBody,
|
||||
verifyPairingSecret
|
||||
};
|
||||
@@ -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