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)}`; 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(''); if (seen.opens===1) { if (mode==='oversize') return socket.send(''+ 'x'.repeat(4096)+''); if (mode==='split') return socket.send(''); socket.send(''+offers.map(x=>`${x}`).join('')+''); } } else if (xml.startsWith('([^<]+)([^<]+){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 ['', '','',''+ ''.repeat(8)+''.repeat(8)+'',new Uint8Array(1)]) { assert.throws(()=>importMessage(data,registry,{}));assert.equal(imports,0); } assert.doesNotThrow(()=>validateMessage('safe & synthetic')); assert.equal(importMessage('synthetic',client.stanzas,{}).stanza.body,'synthetic'); });