316 lines
12 KiB
JavaScript
316 lines
12 KiB
JavaScript
'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 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) {
|
|
super(message);
|
|
this.name = 'BridgeAuthError';
|
|
this.code = code;
|
|
this.statusCode = statusCode;
|
|
}
|
|
}
|
|
|
|
function dependencies(overrides = {}) {
|
|
return {
|
|
randomBytes: overrides.randomBytes || crypto.randomBytes,
|
|
timingSafeEqual: overrides.timingSafeEqual || crypto.timingSafeEqual,
|
|
createHmac: overrides.createHmac || crypto.createHmac,
|
|
createHash: overrides.createHash || crypto.createHash,
|
|
setTimeout: overrides.setTimeout || setTimeout,
|
|
clearTimeout: overrides.clearTimeout || clearTimeout,
|
|
now: overrides.now || Date.now
|
|
};
|
|
}
|
|
|
|
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 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);
|
|
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 === 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' &&
|
|
PROOF_PATTERN.test(envelope.digest)
|
|
);
|
|
}
|
|
|
|
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 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,
|
|
protect,
|
|
unprotect,
|
|
maxChallenges = DEFAULT_MAX_CHALLENGES,
|
|
challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS,
|
|
...injected
|
|
} = {}) {
|
|
assertExtensionOrigin(expectedOrigin);
|
|
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._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() {
|
|
return this._envelope ? { ...this._envelope } : null;
|
|
}
|
|
|
|
rotate() {
|
|
const secret = generatePairingSecret(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();
|
|
}
|
|
|
|
_purgeExpired(now) {
|
|
for (const [nonce, challenge] of this._challenges) {
|
|
if (challenge.expiresAt <= now) this._challenges.delete(nonce);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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) 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.'));
|
|
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,
|
|
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,
|
|
isValidEnvelope,
|
|
readJsonBody
|
|
};
|