feat: add paired Chrome bridge authentication

This commit is contained in:
2026-08-19 21:23:03 +00:00
parent b503b5777a
commit b6df6f1a66
10 changed files with 968 additions and 182 deletions
+250
View File
@@ -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
};