import assert from 'node:assert/strict'; import test from 'node:test'; import {readFileSync} from 'node:fs'; import {Client, parse, validatedSasl, StockScram} from '../dist/browser.mjs'; const vectors = JSON.parse(readFileSync(new URL('./scramp-vectors.json', import.meta.url))); 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.sasl.register('SCRAM-SHA-512', StockScram, 1000); 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}`); 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}: independent Scramp proof and verified final through feature dispatcher`, async () => { const h = harness(vector); await h.start(); assert.equal(h.result.sent[0].stanza.mechanism, vector.mechanism); 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', 'v=' + (vector.final[2] === 'A' ? 'B' : 'A') + vector.final.slice(3)], ['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-512-PLUS', 'SCRAM-SHA-256', 'SCRAM-SHA-512']); await h.start(); assert.equal(h.result.sent[0].stanza.mechanism, 'SCRAM-SHA-512'); }); for (const names of [['PLAIN'], ['SCRAM-SHA-512-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); }); test('synthetic binding input fails without auth or plaintext fallback', async () => { const h = harness(); h.client.config.credentials.tlsUnique = Buffer.alloc(32); await h.start(); rejected(h); assert.equal(h.result.sent.length, 0); }); test('server silence after proof never authenticates; reset discards attempt', async () => { const h = harness(); let cacheEvents = 0; h.client.on('credentials:update', () => {cacheEvents++;}); await h.start(); await h.receive('challenge', vectors[0].first); assert.equal(h.result.success, 0); assert.equal(h.result.restart, 0); assert.equal(h.client.transport.authenticated, false); assert.equal(cacheEvents, 0); assert.equal(h.client.config.credentials.saltedPassword, undefined); h.client.emit('--reset-stream-features'); await h.receive('success', vectors[0].final); assert.equal(h.result.success, 0); assert.equal(h.result.restart, 0); });