fix: close APT adversarial runtime gaps
This commit is contained in:
+133
-66
@@ -7,108 +7,175 @@ const { PassThrough, Readable } = require('node:stream');
|
||||
const {
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
computeCookieProof,
|
||||
computeServerProof,
|
||||
generatePairingSecret,
|
||||
hashPairingSecret,
|
||||
verifyPairingSecret,
|
||||
readJsonBody,
|
||||
createRequestLimiter
|
||||
} = require('../src/bridge-auth');
|
||||
|
||||
const EXTENSION_ORIGIN = 'chrome-extension://onbkfpbggekakjddomjjnboippimlmch';
|
||||
const CLIENT_NONCE = Buffer.alloc(32, 1).toString('base64url');
|
||||
const protect = (plaintext) => Buffer.from(`protected:${plaintext}`, 'utf8');
|
||||
const unprotect = (ciphertext) => {
|
||||
const value = Buffer.from(ciphertext).toString('utf8');
|
||||
if (!value.startsWith('protected:')) throw new Error('bad ciphertext');
|
||||
return value.slice('protected:'.length);
|
||||
};
|
||||
|
||||
test('pairing secrets are random, URL-safe, and stored as a hash envelope', () => {
|
||||
function createAuth(options = {}) {
|
||||
return new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, protect, unprotect, ...options });
|
||||
}
|
||||
|
||||
test('pairing secrets are random and persisted only in a versioned protected 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);
|
||||
const auth = createAuth();
|
||||
const { secret, envelope } = auth.rotate();
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['algorithm', 'ciphertext', 'digest', 'version']);
|
||||
assert.equal(envelope.version, 2);
|
||||
assert.equal(envelope.algorithm, 'electron-safe-storage');
|
||||
assert.equal(JSON.stringify(envelope).includes(secret), false);
|
||||
assert.equal(Buffer.from(envelope.ciphertext, 'base64').toString('utf8').includes(secret), true,
|
||||
'test protector is intentionally transparent after decoding; disk JSON itself is never plaintext');
|
||||
});
|
||||
|
||||
test('auth rejects missing/wrong secrets and unknown or malformed origins', () => {
|
||||
const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const { secret } = auth.rotate();
|
||||
test('protected envelope restores server authentication without request-time scrypt', () => {
|
||||
let protectCalls = 0;
|
||||
let unprotectCalls = 0;
|
||||
const first = new BridgeAuth({
|
||||
protect(value) { protectCalls += 1; return protect(value); },
|
||||
unprotect(value) { unprotectCalls += 1; return unprotect(value); }
|
||||
});
|
||||
const { secret, envelope } = first.rotate();
|
||||
const restored = new BridgeAuth({
|
||||
envelope: JSON.parse(JSON.stringify(envelope)),
|
||||
protect,
|
||||
unprotect(value) { unprotectCalls += 1; return unprotect(value); }
|
||||
});
|
||||
const challenge = restored.issueChallenge(CLIENT_NONCE);
|
||||
|
||||
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);
|
||||
assert.equal(protectCalls, 1);
|
||||
assert.equal(unprotectCalls, 1);
|
||||
assert.equal(challenge.serverProof, computeServerProof(secret, CLIENT_NONCE, challenge.serverNonce));
|
||||
assert.equal(Object.hasOwn(challenge, 'secret'), 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;
|
||||
test('challenge proof and cookie proof are domain-separated, constant-time authenticated, and one-time', () => {
|
||||
const auth = createAuth();
|
||||
const secret = auth.rotate().secret;
|
||||
const challenge = auth.issueChallenge(CLIENT_NONCE);
|
||||
const cookieRequest = {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: challenge.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'synthetic-cookie'
|
||||
};
|
||||
cookieRequest.proof = computeCookieProof(secret, cookieRequest);
|
||||
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: first }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), true);
|
||||
assert.notEqual(challenge.serverProof, cookieRequest.proof);
|
||||
assert.equal(auth.authenticateCookie(cookieRequest), true);
|
||||
assert.equal(auth.authenticateCookie(cookieRequest), false, 'challenge replay must fail');
|
||||
});
|
||||
|
||||
test('challenge cache is bounded, expires entries, and rejects malformed nonces', () => {
|
||||
let now = 100;
|
||||
const auth = createAuth({ maxChallenges: 1, challengeTtlMs: 50, now: () => now });
|
||||
auth.rotate();
|
||||
auth.issueChallenge(CLIENT_NONCE);
|
||||
assert.throws(() => auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url')),
|
||||
(error) => error.code === 'CHALLENGE_CAPACITY');
|
||||
now = 151;
|
||||
const replacement = auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url'));
|
||||
assert.match(replacement.serverNonce, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.throws(() => auth.issueChallenge('short'), (error) => error.code === 'INVALID_NONCE');
|
||||
});
|
||||
|
||||
test('expired and forged cookie proofs fail and consume the one-time challenge', () => {
|
||||
let now = 100;
|
||||
const auth = createAuth({ challengeTtlMs: 50, now: () => now });
|
||||
const secret = auth.rotate().secret;
|
||||
const expired = auth.issueChallenge(CLIENT_NONCE);
|
||||
now = 151;
|
||||
assert.equal(auth.authenticateCookie({
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: expired.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: computeCookieProof(secret, {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: expired.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie'
|
||||
})
|
||||
}), false);
|
||||
|
||||
now = 200;
|
||||
const forged = auth.issueChallenge(CLIENT_NONCE);
|
||||
const request = {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: forged.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: 'A'.repeat(43)
|
||||
};
|
||||
assert.equal(auth.authenticateCookie(request), false);
|
||||
request.proof = computeCookieProof(secret, request);
|
||||
assert.equal(auth.authenticateCookie(request), false, 'forged attempt consumes challenge');
|
||||
});
|
||||
|
||||
test('rotation clears outstanding challenges and revoke disables challenge issuance', () => {
|
||||
const auth = createAuth();
|
||||
auth.rotate();
|
||||
const prior = auth.issueChallenge(CLIENT_NONCE);
|
||||
auth.rotate();
|
||||
assert.equal(auth.authenticateCookie({
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: prior.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: 'A'.repeat(43)
|
||||
}), false);
|
||||
auth.revoke();
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), false);
|
||||
assert.throws(() => auth.issueChallenge(CLIENT_NONCE), (error) => error.code === 'PAIRING_UNAVAILABLE');
|
||||
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('invalid origin and protected-envelope dependencies fail closed', () => {
|
||||
assert.throws(() => new BridgeAuth({ expectedOrigin: 'https://example.test', protect, unprotect }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN');
|
||||
assert.throws(() => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', protect, unprotect }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN');
|
||||
assert.throws(() => new BridgeAuth(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE');
|
||||
assert.throws(() => new BridgeAuth({ protect, unprotect, envelope: { version: 1, algorithm: 'scrypt' } }),
|
||||
(error) => error.code === 'INVALID_SECRET_ENVELOPE');
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
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 () => {
|
||||
test('readJsonBody aborts a slow body at its absolute 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 () => {
|
||||
test('request limiter rejects excess concurrent work before it 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'
|
||||
);
|
||||
let entered = false;
|
||||
await assert.rejects(limiter.run(async () => { entered = true; }), (error) => error.code === 'TOO_MANY_REQUESTS');
|
||||
assert.equal(entered, false);
|
||||
release('done');
|
||||
assert.equal(await active, 'done');
|
||||
assert.equal(limiter.active, 0);
|
||||
|
||||
Reference in New Issue
Block a user