Files
LumaWeb/experiments/0002-xmpp-library/selection-spike/validated-sasl.mjs
T
wt 51a414d386 S00-003: Evaluate Stanza with verified SCRAM and bounded WSS ingress
Changes: Propose the pinned XMPP library decision and add an isolated browser-built selection spike combining reviewed public SASL, SHA-512 and parser seams. Exercise TLS/xmpp handshake, strongest compatible non-PLUS authentication, rejected unsafe paths and extension mappings; document duplicate-body export projection and remaining limits. Preserve dependency notices, evidence and QA handoff.
Validation: Clean-cache pinned npm ci succeeded; npm test passed 112 checks; check-dependencies.py verified 51 locked entries, 26 installed packages and 340 unchanged vendor files; make check passed 72 tooling tests. No actual browser runtime, independent full XMPP server/client or production encryption/release readiness claim.

Task: S00-003
Agent: auto-protocol-f474114f0168
Role: protocol
Action: claim
Status: review

Task title: Проверить XMPP WSS/auth библиотеку
2026-10-15 18:20:05 +07:00

114 lines
4.6 KiB
JavaScript

// Selection experiment: reviewed guard with explicit strongest-first non-PLUS policy.
import {registerSha512} from './sha512-mechanism.mjs';
// No production transport, credential persistence, or channel binding is provided.
const SUPPORTED = new Set(['SCRAM-SHA-512', 'SCRAM-SHA-256', 'SCRAM-SHA-1']);
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
function text(value) {
// SASL's parsed additional-data is a Buffer, not raw XML/base64 text.
if (!value || typeof value.toString !== 'function' || value.length > 4096) {
throw new Error('Invalid SASL data');
}
const result = value.toString('utf8');
if (!/^[\x21-\x7e]*$/.test(result)) throw new Error('Invalid SASL data');
return result;
}
function firstMessage(value, nonce) {
// Deliberately narrow, no extensions. Bound work before calling library crypto.
const match = /^r=([\x21-\x2b\x2d-\x7e]+),s=([A-Za-z0-9+/=]+),i=([1-9][0-9]*)$/.exec(text(value));
if (!match || !BASE64.test(match[2]) || !match[1].startsWith(nonce) ||
match[1].length <= nonce.length || Number(match[3]) > 100000) {
throw new Error('Invalid SCRAM server-first');
}
}
function finalMessage(value, name) {
const final = text(value);
const length = {'SCRAM-SHA-512': 88, 'SCRAM-SHA-256': 44, 'SCRAM-SHA-1': 28}[name];
if (!length) throw new Error('Unsupported mechanism');
if (!final.startsWith('v=') || final.length !== length + 2 || !BASE64.test(final.slice(2))) {
throw new Error('Invalid SCRAM server-final');
}
}
export default function validatedSasl(client) {
registerSha512(client);
let active;
// Public plugin + named feature registration replaces the stock handler before use.
client.registerFeature('sasl', 100, async (features, done) => {
if (active) active();
const offered = features.sasl.mechanisms;
const names = ['SCRAM-SHA-512', 'SCRAM-SHA-256', 'SCRAM-SHA-1'].filter(name => offered.includes(name));
client.emit('sasl:policy', {selected: names[0] ?? null, channelBinding: 'unavailable', fallback: 'disabled'});
const mech = client.sasl.createMechanism(names);
let phase = 'starting';
let credentials;
let nonce;
const cleanup = () => {
client.off('sasl', handler);
client.off('--reset-stream-features', reset);
if (active === reset) active = undefined;
};
const reset = () => {
phase = 'closed';
cleanup();
client.features.negotiated.sasl = false;
};
const fail = () => {
if (phase === 'closed') return;
phase = 'closed';
cleanup();
client.features.negotiated.sasl = false;
if (client.transport) client.transport.authenticated = false;
client.emit('auth:failed');
done('disconnect', 'SCRAM validation failed'); // Never log peer data/credentials.
};
const handler = async sasl => {
if (phase === 'closed') return;
try {
if (sasl.type === 'challenge' && phase === 'server-first') {
phase = 'sending-proof'; // Reject duplicate challenges and racing success.
firstMessage(sasl.value, nonce);
mech.processChallenge(sasl.value);
const response = mech.createResponse(credentials);
await client.send('sasl', {type: 'response', value: response});
if (phase !== 'closed') phase = 'server-final';
return;
}
if (sasl.type !== 'success' || phase !== 'server-final') return fail();
finalMessage(sasl.value, mech.name);
mech.processSuccess(sasl.value);
const result = mech.finalize();
if (!result.authenticated || !result.mutuallyAuthenticated) return fail();
phase = 'closed';
cleanup();
client.features.negotiated.sasl = true;
if (client.transport) client.transport.authenticated = true;
client.emit('auth:success', client.config.credentials);
done('restart');
} catch {
fail();
}
};
active = reset;
client.features.negotiated.sasl = false;
client.on('sasl', handler);
client.once('--reset-stream-features', reset);
try {
if (!mech || !SUPPORTED.has(mech.name)) return fail();
credentials = await client.getCredentials();
if (phase === 'closed') return;
// Restrict this experiment to non-PLUS; never invent TLS binding bytes.
if (credentials.tlsUnique) return fail();
const response = mech.createResponse(credentials);
nonce = /,r=([^,]+)$/.exec(response.toString('utf8'))?.[1];
if (!nonce) return fail();
await client.send('sasl', {type: 'auth', mechanism: mech.name, value: response});
if (phase !== 'closed') phase = 'server-first';
} catch {
fail();
}
});
}