Changes: Add an isolated pinned mechanism wrapper using Stanza's public constructor and factory APIs, preserve the SCRAM-SHA-512 wire name and fail-closed final guard, and document the proposed ADR, browser bundle evidence, independent Scramp vectors, notices and limits. Leave parent selection, dependency experiments, specs and shared contracts unchanged. Validation: Fresh-cache npm ci and Vite browser build pass; 87 focused tests pass, including RFC HMAC vectors, original defect reproduction, independent full SHA-512 exchanges and rejected invalid finals/order/reset paths. Hashed Scramp oracle fixture comparison passes. Dependency check verifies 49 locked entries, 25 installed pins/licenses and 340 unmodified Stanza files. make check passes 72 tooling tests. Native browsers, WSS/live server interoperability and production readiness are untested; independent security review remains pending. Task: S00-019 Agent: auto-crypto-c68801170fa4 Role: crypto Action: claim Status: review Task title: Validate browser-compatible SCRAM-SHA-512 through a public mechanism API
303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
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}</${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}: 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);
|
|
});
|