Files
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

123 lines
8.0 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {createServer} from 'node:https';
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {execFileSync} from 'node:child_process';
import {once} from 'node:events';
import WS, {WebSocketServer} from 'ws';
import {Client, installBoundedTransport, validatedSasl, importMessage, validateMessage, backend} from '../dist/browser.mjs';
import {vectors as rfc} from './fixtures.mjs';
const sha512 = JSON.parse(readFileSync(new URL('./scramp-vectors.json', import.meta.url)));
const vectors = [...rfc, sha512[0]];
const enc = s => Buffer.from(s).toString('base64');
const sasl = (type, data='') => `<${type} xmlns="urn:ietf:params:xml:ns:xmpp-sasl">${enc(data)}</${type}>`;
const waitFor = async fn => {
for (let i=0;i<300;i++) { if (fn()) return; await new Promise(r=>setTimeout(r,10)); }
throw new Error('Fixture timeout');
};
const dir = mkdtempSync(join(tmpdir(), 'luma-wss-'));
// Ephemeral synthetic localhost CA/server certificate; never committed.
execFileSync('openssl', ['req','-x509','-newkey','rsa:2048','-nodes','-keyout',join(dir,'key.pem'),
'-out',join(dir,'cert.pem'),'-days','1','-subj','/CN=localhost',
'-addext','subjectAltName=DNS:localhost,IP:127.0.0.1'], {stdio:'ignore'});
const cert = readFileSync(join(dir,'cert.pem'));
const key = readFileSync(join(dir,'key.pem'));
test.after(()=>rmSync(dir,{recursive:true,force:true}));
async function scenario(t, vector, offers, mode='valid') {
const server=createServer({key,cert});
const wss=new WebSocketServer({server,handleProtocols: protocols=>protocols.has('xmpp')?'xmpp':false});
const seen={opens:0,success:0,failed:0,rejected:[],policies:[],peerErrors:[]};
const client=new Client({jid:'user@example.invalid',server:'example.invalid',autoReconnect:false,
credentials:{username:vector.username,password:vector.password,clientNonce:vector.nonce},
transports:{'bounded-websocket':'wss://127.0.0.1'},transportPreferenceOrder:['bounded-websocket']});
// Explicit CA trust for this fixture only; hostname/TLS validation stays enabled.
class TrustedSocket extends WS {constructor(url,protocol){super(url,protocol,{ca:cert,rejectUnauthorized:true});}}
installBoundedTransport(client,TrustedSocket);
client.use(validatedSasl);
client.on('auth:success',()=>seen.success++);
client.on('auth:failed',()=>seen.failed++);
client.on('ingress:rejected',code=>seen.rejected.push(code));
client.on('sasl:policy',policy=>seen.policies.push(policy));
wss.on('connection',(socket,req)=>{
seen.protocol=req.headers['sec-websocket-protocol'];
seen.tls=socket._socket.encrypted;
socket.on('message', (bytes,isBinary)=>{
try {
assert.equal(isBinary,false);
const xml=bytes.toString();
if (xml.startsWith('<open')) {
seen.opens++;
socket.send('<open xmlns="urn:ietf:params:xml:ns:xmpp-framing" from="example.invalid" version="1.0" id="fixture"/>');
if (seen.opens===1) {
if (mode==='oversize') return socket.send('<message xmlns="jabber:client"><body>'+ 'x'.repeat(4096)+'</body></message>');
if (mode==='split') return socket.send('<message xmlns="jabber:client">');
socket.send('<stream:features xmlns:stream="http://etherx.jabber.org/streams"><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">'+offers.map(x=>`<mechanism>${x}</mechanism>`).join('')+'</mechanisms></stream:features>');
}
} else if (xml.startsWith('<auth')) {
seen.mechanism=/mechanism="([^"]+)"/.exec(xml)?.[1];
const initial=Buffer.from(/>([^<]+)</.exec(xml)[1],'base64').toString();
assert.equal(initial,`n,,n=user,r=${vector.nonce}`);
socket.send(mode==='premature'?sasl('success',vector.final):sasl('challenge',vector.first));
} else if (xml.startsWith('<response')) {
seen.proof=Buffer.from(/>([^<]+)</.exec(xml)[1],'base64').toString();
assert.equal(seen.proof,vector.proof);
socket.send(sasl('success',mode==='forged'?'v='+ 'A'.repeat(vector.final.length-2):vector.final));
}
} catch(error) {seen.peerErrors.push(error.message); socket.close();}
});
});
server.listen(0,'127.0.0.1'); await once(server,'listening');
client.updateConfig({transports:{'bounded-websocket':`wss://127.0.0.1:${server.address().port}`}});
t.after(async()=>{await client.disconnect(); for(const socket of wss.clients)socket.terminate(); await new Promise(r=>wss.close(r)); await new Promise(r=>server.close(r));});
await client.connect();
await waitFor(()=> mode==='valid'? seen.opens===2 : seen.failed||seen.rejected.length||seen.peerErrors.length);
assert.deepEqual(seen.peerErrors,[]);
assert.equal(seen.protocol,'xmpp'); assert.equal(seen.tls,true);
if(mode==='valid') {
assert.equal(seen.success,1); assert.equal(seen.failed,0); assert.equal(seen.mechanism,vector.mechanism);
assert.equal(seen.proof,vector.proof); assert.equal(seen.opens,2);
assert.deepEqual(seen.policies,[{selected:vector.mechanism,channelBinding:'unavailable',fallback:'disabled'}]);
} else {assert.equal(seen.success,0);assert.equal(seen.opens,1);}
return seen;
}
test('browser bundle uses browser backend with TLS validation enabled',()=>{assert.equal(backend,'browser');assert.notEqual(process.env.NODE_TLS_REJECT_UNAUTHORIZED,'0');});
test('public transport rejects plaintext endpoint before socket creation',async()=>{
const client=new Client({transports:{'bounded-websocket':'ws://127.0.0.1'},transportPreferenceOrder:['bounded-websocket']});
let sockets=0; installBoundedTransport(client,class{constructor(){sockets++;}});
await assert.rejects(client.connect(),/WSS required/);assert.equal(sockets,0);
});
for(const vector of vectors) {
const offers=['PLAIN','SCRAM-SHA-512-PLUS','SCRAM-SHA-256-PLUS','SCRAM-SHA-1',
...(vector.mechanism!=='SCRAM-SHA-1'?['SCRAM-SHA-256']:[]),
...(vector.mechanism==='SCRAM-SHA-512'?['SCRAM-SHA-512']:[])];
test(`trusted WSS strongest compatible ${vector.mechanism}, verified restart`,t=>scenario(t,vector,offers));
for(const mode of ['forged','premature'])test(`WSS ${vector.mechanism} rejects ${mode}`,t=>scenario(t,vector,offers,mode));
}
for(const mode of ['oversize','split']) test(`WSS bounded ingress rejects ${mode}`,async t=>{
const seen=await scenario(t,vectors[2],['SCRAM-SHA-512'],mode);assert.equal(seen.rejected.length,1);
});
for(const offers of [['SCRAM-SHA-512-PLUS'],['PLAIN'],[]]) test(`WSS refuses unsupported offer ${offers}`,async t=>{
const seen=await scenario(t,vectors[2],offers,'unsupported'); assert.equal(seen.failed,1); assert.equal(seen.mechanism,undefined);
});
test('default native WebSocket rejects untrusted WSS certificate',async t=>{
const server=createServer({key,cert}); const wss=new WebSocketServer({server});
server.listen(0,'127.0.0.1');await once(server,'listening');
t.after(async()=>{wss.close();await new Promise(r=>server.close(r));});
const socket=new globalThis.WebSocket(`wss://127.0.0.1:${server.address().port}`,'xmpp');
let opened=false;socket.onopen=()=>{opened=true;socket.close();};
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{socket.close();reject(new Error('TLS timeout'));},3000);socket.onerror=()=>{clearTimeout(timer);resolve();};});
assert.equal(opened,false);
});
test('XML resource and standalone boundaries compose before registry import',()=>{
const client=new Client();let imports=0; const registry={getImportKey:()=>{imports++;return 'message';},import:()=>{imports++;return {};}};
for(const data of ['<message>', '<message/><message/>','<!DOCTYPE x><x/>','<x>'+ '<x>'.repeat(8)+'</x>'.repeat(8)+'</x>',new Uint8Array(1)]) {
assert.throws(()=>importMessage(data,registry,{}));assert.equal(imports,0);
}
assert.doesNotThrow(()=>validateMessage('<message xmlns="jabber:client"><body>safe &amp; synthetic</body></message>'));
assert.equal(importMessage('<message xmlns="jabber:client"><body>synthetic</body></message>',client.stanzas,{}).stanza.body,'synthetic');
});