Files
LumaWeb/experiments/0002-xmpp-library/sasl-validation/test/integration.test.mjs
T
wt 493fbaebe7 S00-017: Validate SCRAM server-final before authenticated SASL completion
Changes: Add an isolated Stanza 12.22.1 SASL feature replacement using existing SCRAM crypto, exact dependency lock and MIT notices, 44 integrated fixture checks, proposed ADR and review handoff. Leave production adapters and the original library experiment unchanged.
Validation: Fresh-cache npm ci and npm test passed on Node 24.21.0/npm 11.19.0; 44 tests cover RFC SHA-1/SHA-256 proofs and verified finals, stock defect reproduction, invalid/premature finals and async cleanup. make check passed 72 tooling tests. Pins/licenses/notices, unchanged vendor source and whitespace verified. Browser/WSS/server interoperability and constant-time assurance remain unverified; independent security review is pending.

Task: S00-017
Agent: auto-protocol-599343cd9e3f
Role: protocol
Action: claim
Status: review

Task title: Establish fail-closed SCRAM server-final validation
2026-10-15 02:17:46 +07:00

278 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {createRequire} from 'node:module';
import validatedSasl from '../validated-sasl.mjs';
import {vectors} from './fixtures.mjs';
const require = createRequire(import.meta.url);
const Client = require('stanza/Client').default;
const {parse} = require('stanza/jxt');
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => {
let resolve;
const promise = new Promise(r => {resolve = r;});
return {promise, resolve};
};
function harness(vector = vectors[1], fixed = true, mechanisms = [vector.mechanism]) {
const client = new Client({jid: 'user@example.invalid', autoReconnect: false,
credentials: {username: vector.username, password: vector.password, clientNonce: vector.nonce}});
const result = {sent: [], success: 0, failed: 0, restart: 0, disconnect: 0, verification: []};
// Capture fixture effects; use the real Client, XML registry, feature dispatcher,
// mechanism factory and SCRAM crypto. Never connect to a real provider.
client.send = async (kind, stanza) => {result.sent.push({kind, stanza});};
client.transport = {authenticated: false, restart: () => {result.restart++;}};
client.disconnect = async () => {result.disconnect++;};
client.on('auth:success', () => {result.success++;});
client.on('auth:failed', () => {result.failed++;});
if (fixed) {
client.use(validatedSasl);
const original = client.sasl.createMechanism.bind(client.sasl);
client.sasl.createMechanism = names => {
const mech = original(names);
if (mech) for (const method of ['processSuccess', 'finalize']) {
const fn = mech[method].bind(mech);
mech[method] = (...args) => {result.verification.push(method); return fn(...args);};
}
return mech;
};
}
const receive = async (type, value) => {
const body = value === undefined ? '' : Buffer.from(value).toString('base64');
const xml = parse(`<${type} xmlns="urn:ietf:params:xml:ns:xmpp-sasl">${body}</${type}>`);
const stanza = client.stanzas.import(xml);
assert.equal(stanza.type, type);
client.emit('sasl', stanza);
await tick();
};
const start = async () => {
client.emit('features', {sasl: {mechanisms}});
await tick();
};
return {client, result, receive, start};
}
function rejected(h) {
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.result.failed, 1);
assert.equal(h.result.disconnect, 1);
assert.equal(h.client.features.negotiated.sasl, false);
assert.equal(h.client.transport.authenticated, false);
assert.equal(h.client.listenerCount('sasl'), 0);
}
for (const vector of vectors) {
test(`${vector.mechanism}: RFC proof and verified final through feature dispatcher`, async () => {
const h = harness(vector);
await h.start();
assert.equal(h.result.sent[0].stanza.value.toString(), `n,,n=user,r=${vector.nonce}`);
await h.receive('challenge', vector.first);
assert.equal(h.result.sent[1].stanza.value.toString(), vector.proof);
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.client.transport.authenticated, false);
await h.receive('success', vector.final);
assert.deepEqual(h.result.verification, ['processSuccess', 'finalize']);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
assert.equal(h.result.disconnect, 0);
assert.equal(h.client.features.negotiated.sasl, true);
assert.equal(h.client.transport.authenticated, true);
await h.receive('success', vector.final);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
});
for (const [label, final] of [
['missing', undefined],
['forged', vector.final.replace(/v=./, 'v=A')],
['error', 'e=invalid-proof'],
['conflicting error', `${vector.final},e=invalid-proof`],
['duplicate verifier', `${vector.final},${vector.final}`],
['bad base64', 'v=***'],
['oversized', 'v=' + 'A'.repeat(4096)]
]) {
test(`${vector.mechanism}: reject ${label} server-final after proof`, async () => {
const h = harness(vector);
await h.start();
await h.receive('challenge', vector.first);
await h.receive('success', final);
rejected(h);
if (label === 'forged') assert.deepEqual(h.result.verification, ['processSuccess', 'finalize']);
await h.receive('success', vector.final);
rejected(h);
});
}
test(`${vector.mechanism}: reject valid-looking final before proof`, async () => {
const h = harness(vector);
await h.start();
await h.receive('success', vector.final);
rejected(h);
assert.deepEqual(h.result.verification, []);
});
}
for (const [label, final] of [['missing', undefined], ['forged', 'v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']]) {
test(`stock 12.22.1 reproduces ${label} premature acceptance`, async () => {
const h = harness(vectors[1], false);
await h.start();
await h.receive('success', final);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
assert.equal(h.client.transport.authenticated, true);
});
test(`replacement rejects original ${label} premature defect`, async () => {
const h = harness();
await h.start();
await h.receive('success', final);
rejected(h);
});
}
for (const [label, first] of [
['wrong nonce', vectors[1].first.replace('r=rOpr', 'r=wrong')],
['unextended nonce', `r=${vectors[1].nonce},s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096`],
['iteration overflow', vectors[1].first.replace('i=4096', 'i=100001')],
['invalid iterations', vectors[1].first.replace('i=4096', 'i=NaN')],
['duplicate attribute', vectors[1].first + ',i=4096'],
['mandatory extension', 'm=required,' + vectors[1].first]
]) {
test(`reject ${label} before library proof work`, async () => {
const h = harness();
await h.start();
await h.receive('challenge', first);
rejected(h);
assert.equal(h.result.sent.length, 1);
});
}
test('reject success while proof send is pending; no late revival', async () => {
const h = harness();
await h.start();
const pending = deferred();
h.client.send = () => pending.promise;
await h.receive('challenge', vectors[1].first);
await h.receive('success', vectors[1].final);
rejected(h);
pending.resolve();
await tick();
await h.receive('success', vectors[1].final);
rejected(h);
});
test('duplicate challenge while sending proof fails closed', async () => {
const h = harness();
await h.start();
const pending = deferred();
h.client.send = () => pending.promise;
await h.receive('challenge', vectors[1].first);
await h.receive('challenge', vectors[1].first);
rejected(h);
pending.resolve();
await tick();
rejected(h);
});
test('reset while awaiting credentials prevents late auth and listener leak', async () => {
const h = harness();
const pending = deferred();
h.client.getCredentials = () => pending.promise;
await h.start();
h.client.emit('--reset-stream-features');
pending.resolve({username: 'user', password: 'pencil', clientNonce: vectors[1].nonce});
await tick();
await h.receive('success', vectors[1].final);
assert.equal(h.result.sent.length, 0);
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.client.listenerCount('sasl'), 0);
});
test('reset after proof, new negotiation succeeds without old handlers', async () => {
const h = harness();
await h.start();
await h.receive('challenge', vectors[1].first);
h.client.emit('--reset-stream-features');
await h.receive('success', vectors[1].final);
assert.equal(h.result.success, 0);
assert.equal(h.client.listenerCount('sasl'), 0);
await h.start();
assert.equal(h.client.listenerCount('sasl'), 1);
await h.receive('challenge', vectors[1].first);
await h.receive('success', vectors[1].final);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
});
test('fixture policy filters PLUS and plaintext; strongest available non-PLUS selected', async () => {
const h = harness(vectors[1], true, ['PLAIN', 'SCRAM-SHA-1', 'SCRAM-SHA-256-PLUS', 'SCRAM-SHA-256']);
await h.start();
assert.equal(h.result.sent[0].stanza.mechanism, 'SCRAM-SHA-256');
});
for (const names of [['PLAIN'], ['SCRAM-SHA-256-PLUS'], []]) {
test(`no allowed mechanism (${names.join(',')}) disconnects`, async () => {
const h = harness(vectors[1], true, names);
await h.start();
rejected(h);
assert.equal(h.result.sent.length, 0);
});
}
for (const type of ['failure', 'abort']) {
test(`${type} is terminal; valid final cannot revive it`, async () => {
const h = harness();
await h.start();
await h.receive(type);
await h.receive('success', vectors[1].final);
rejected(h);
});
}
test('success during pending initial credentials fails closed', async () => {
const h = harness();
const pending = deferred();
h.client.getCredentials = () => pending.promise;
await h.start();
await h.receive('success', vectors[1].final);
rejected(h);
pending.resolve({username: 'user', password: 'pencil', clientNonce: vectors[1].nonce});
await tick();
assert.equal(h.result.sent.length, 0);
rejected(h);
});
test('reset during proof send cancels continuation', async () => {
const h = harness();
await h.start();
const pending = deferred();
h.client.send = () => pending.promise;
await h.receive('challenge', vectors[1].first);
h.client.emit('--reset-stream-features');
pending.resolve();
await tick();
await h.receive('success', vectors[1].final);
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.client.listenerCount('sasl'), 0);
});
for (const stage of ['credentials', 'initial send', 'proof send']) {
test(`${stage} rejection disconnects without success`, async () => {
const h = harness();
const reject = async () => {throw new Error('synthetic fixture failure');};
if (stage === 'credentials') h.client.getCredentials = reject;
if (stage === 'initial send') h.client.send = reject;
await h.start();
if (stage === 'proof send') {
h.client.send = reject;
await h.receive('challenge', vectors[1].first);
}
rejected(h);
});
}
test('repeat challenge after completed proof is rejected', async () => {
const h = harness();
await h.start();
await h.receive('challenge', vectors[1].first);
await h.receive('challenge', vectors[1].first);
rejected(h);
});