'use strict'; // General Utilities function setButtonClickable(id, state) { document.getElementById(id).disabled = !state; } function registerButtonActions() { document.getElementById('certLoad').onclick = onLoadCert; document.getElementById('certSave').onclick = onSaveCert; document.getElementById('certNew').onclick = onNewCert; document.getElementById('certUpload').onclick = onUploadCert; document.getElementById('certClear').onclick = onClearCert; document.getElementById('chatJoin').onclick = onJoinChat; document.getElementById('chatLeave').onclick = onLeaveChat; document.getElementById('chatPost').onclick = onPostChat; document.getElementById('chatInput').onkeydown = event => { if (document.getElementById('chatPost').disabled) return; if (event.key === 'Enter') { onPostChat(); } }; } // ========================== Certificate Management ========================== let certDB = null; let localCert = null; // Utilities function getCertificateChecksumFromSDP(desc) { let fp = desc.sdp.split('\n').reduce((res, curr) => curr.startsWith('a=fingerprint:sha-256 ') ? curr : res, null); console.assert(fp !== null, 'Description does not have a sha256sum'); return fp.trim().split(' ')[1].split(':').join('').toLowerCase(); } async function getCertificateChecksum(cert) { if (cert.getFingerprints) { let fp = cert.getFingerprints().reduce((res, curr) => curr.algorithm === 'sha-256' ? curr : res, null); console.assert(fp !== null, 'Certificate does not have a sha256sum'); return fp.value.split(':').join(''); } else { // Firefox shim let testConn = new RTCPeerConnection({certificates: [cert]}); let desc = await testConn.createOffer(); testConn.close(); return getCertificateChecksumFromSDP(desc); } } // IndexedDB function openCertDatabase(onSuccess, onError) { let req = window.indexedDB.open('webrtc-poc', 1); req.onupgradeneeded = () => { console.log('Initializing new IndexedDB'); let db = req.result; let certStore = db.createObjectStore('dtlsCerts', {keyPath: 'id'}); certStore.createIndex('by_id', 'id'); }; req.onsuccess = () => onSuccess(req.result); req.onerror = () => onError(req.error); } function saveCertificate(db, cert, onSuccess, onError) { let certTx = db.transaction('dtlsCerts', 'readwrite'); let certStore = certTx.objectStore('dtlsCerts'); let certPut = certStore.put({ // TODO: replace ID int with connection identifier id: 0, cert: cert, }); certPut.onsuccess = onSuccess; certPut.onerror = () => onError(certPut.error); } function loadCertificate(db, onSuccess, onError) { let certTx = db.transaction('dtlsCerts', 'readonly'); let certStore = certTx.objectStore('dtlsCerts'); // TODO: replace ID int with connection identifier let certGet = certStore.get(0) certGet.onsuccess = () => { let match = certGet.result; if (match !== undefined) { onSuccess(match.cert); } else { onSuccess(null); } }; certGet.onerror = () => onError(certGet.error); } // Cert Status Display function setCertStatusDisplay(status) { document.getElementById('certStatus').innerText = status; } // Load Button function onLoadCert() { console.assert(certDB !== null, 'IndexedDB not available'); loadCertificate(certDB, cert => { if (cert !== null) { getCertificateChecksum(cert).then(fp => { let ts = new Date(cert.expires).toISOString(); console.log(`Loaded certificate (expires ${ts}): ${fp}`); }); localCert = cert; setCertStatusDisplay('Loaded'); setButtonClickable('certUpload', true); setButtonClickable('certClear', true); setButtonClickable('certSave', true); setButtonClickable('chatJoin', true); } else { setCertStatusDisplay('Load Failed (No Certificate Found)'); } }, err => { setCertStatusDisplay(`Load Failed (${err})`); }, ); } // Save Button function onSaveCert() { console.assert(localCert !== null, 'No local certificate available'); console.assert(certDB !== null, 'IndexedDB not available'); saveCertificate(certDB, localCert, () => { setCertStatusDisplay('Saved'); }, err => { setCertStatusDisplay(`Save Failed (${err})`); }, ); } // New Button function onNewCert() { RTCPeerConnection.generateCertificate({ name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256', }).then(cert => { getCertificateChecksum(cert).then(fp => { let ts = new Date(cert.expires).toISOString(); console.log(`Generated new certificate (expires ${ts}): ${fp}`); }); localCert = cert; setCertStatusDisplay('Available'); setButtonClickable('certUpload', true); setButtonClickable('certClear', true); setButtonClickable('certSave', true); setButtonClickable('chatJoin', true); }); } // Upload Button function onUploadCert() { console.assert(localCert !== null, 'No local certificate available'); getCertificateChecksum(localCert).then(fp => { fetch('/keys', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ 'key': fp, 'expires': localCert.expires, }), }).then(res => { if (res.status === 200) { setCertStatusDisplay('Uploaded'); } else { res.json().then(res => { setCertStatusDisplay(`Upload Failed (${res.status})`); }) } }) }); } // Clear Button function onClearCert() { console.assert(localCert !== null, 'No local certificate available'); getCertificateChecksum(localCert).then(fp => { fetch(`/keys/${fp}`, { method: 'DELETE', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }).then(res => { if (res.status === 200) { setCertStatusDisplay('Cleared'); localCert = null; setButtonClickable('certUpload', false); setButtonClickable('certClear', false); setButtonClickable('certSave', false); setButtonClickable('chatJoin', false); } else { res.json().then(res => { setCertStatusDisplay(`Delete Failed (${res.status})`); }) } }); }); } // =============================== Chat Control =============================== let socket = null; let localDesc = null; let remoteDesc = null; let activeConnection = null; let activeChannel = null; let deferredCandidates = []; // Chat Status Display function setChatStatusDisplay(status) { document.getElementById('chatStatus').innerText = status; } function verifyFingerprint(desc) { let fp = getCertificateChecksumFromSDP(desc); fetch(`/keys/${fp}`, { method: 'GET', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }).then(res => { if (res.status !== 200) { console.log('Unauthorized certificate detected! Terminating connection...'); res.json().then(res => setChatStatusDisplay(`Terminated (${res.status})`)); appendChatLog('SYSTEM', 'Terminating unauthorized session.'); onLeaveChat(); } }); } function onPeerOfferAvailable(msg) { // Ignore subsequent requests if (remoteDesc !== null) return; console.log('Received WebRTC connection offer, sending answer...'); console.assert(localDesc !== null, 'No local description available'); activeChannel.close(); activeChannel = null; remoteDesc = JSON.parse(msg); verifyFingerprint(remoteDesc.desc); activeConnection.setRemoteDescription(remoteDesc.desc).then(() => activeConnection.createAnswer().then(answer => { localDesc = {src: socket.id, dst: remoteDesc.src, desc: answer}; activeConnection.setLocalDescription(localDesc.desc).then(() => { socket.emit('join-answer', JSON.stringify(localDesc)); for (let candidate of deferredCandidates) { activeConnection.addIceCandidate(candidate); } deferredCandidates = [] }) }) ); } function onPeerAnswerAvailable(msg) { // Ignore subsequent responses let currDesc = JSON.parse(msg); if (remoteDesc !== null && currDesc.src !== remoteDesc.src) return; console.log('Received WebRTC connection answer...'); console.assert(localDesc !== null, 'No local description available'); remoteDesc = currDesc; verifyFingerprint(remoteDesc.desc); activeConnection.setLocalDescription(localDesc.desc).then(() => activeConnection.setRemoteDescription(remoteDesc.desc).then(() => { for (let candidate of deferredCandidates) { activeConnection.addIceCandidate(candidate); } deferredCandidates = [] })); } function onPeerJoinAvailable(msg) { let candidate = JSON.parse(msg); if (candidate.src === remoteDesc.src && candidate.dst === localDesc.src) { if (activeConnection.remoteDescription) { console.log('Received WebRTC ICE candidate info, registering...'); activeConnection.addIceCandidate(candidate.candidate); } else { console.log('Received WebRTC ICE candidate info, deferring registration...'); deferredCandidates.push(candidate.candidate); } } else if (candidate.src === remoteDesc.src) { onLeaveChat(); onJoinChat(); } } function onPeerAvailable(event) { console.assert(remoteDesc !== null, 'No remote description available'); if (event.candidate) { console.log('Sending WebRTC ICE candidate info...'); socket.emit('join', JSON.stringify( { src: localDesc.src, dst: remoteDesc.src, candidate: event.candidate })); } } function registerChannelHandlers(channel) { channel.addEventListener('open', () => { setChatStatusDisplay('Connected'); appendChatLog('SYSTEM', 'User joined the chat.'); setButtonClickable('chatPost', true); channel.addEventListener('close', () => { appendChatLog('SYSTEM', 'User disconnected from the chat.'); onLeaveChat(); }) }); channel.addEventListener('message', event => appendChatLog('Remote', event.data)); } function onRTCConnectionStateChange(event) { let connection = event.target let state = connection.connectionState; console.log('WebRTC connection state update:', state); if (state === 'failed') { onLeaveChat(); } } // Join Button function onJoinChat() { console.assert(localCert !== null, 'No local certificate available'); console.assert(activeConnection === null, 'Local connection exists'); console.assert(activeChannel === null, 'Local channel exists'); socket = io(window.location.protocol + '//' + window.location.host); socket.on('join-offer', onPeerOfferAvailable); socket.on('join-answer', onPeerAnswerAvailable); socket.on('join', onPeerJoinAvailable); socket.on('connect', () => { let rtcCfg = { iceServers: [{urls: `stun:${window.location.hostname}:3478`}], certificates: [localCert], }; activeConnection = new RTCPeerConnection(rtcCfg); activeConnection.addEventListener('icecandidate', onPeerAvailable); activeConnection.addEventListener('connectionstatechange', onRTCConnectionStateChange); activeConnection.addEventListener('datachannel', event => { console.assert(activeChannel === null); activeChannel = event.channel; registerChannelHandlers(activeChannel) }); activeChannel = activeConnection.createDataChannel('chatChannel'); registerChannelHandlers(activeChannel); activeConnection.createOffer().then(offer => { localDesc = {src: socket.id, desc: offer}; // Broadcast join offer console.log('Sending WebRTC connection offer...'); socket.emit('join-offer', JSON.stringify(localDesc)); }) setButtonClickable('chatJoin', false); setButtonClickable('chatLeave', true); setButtonClickable('certNew', false); setButtonClickable('certLoad', false); setButtonClickable('certClear', false); setChatStatusDisplay('Awaiting connection'); clearChatLog(); }); } // Leave Button function onLeaveChat() { if (socket !== null) { socket.disconnect(); socket = null; } if (activeChannel !== null) { activeChannel.close(); activeChannel = null; } if (activeConnection !== null) { activeConnection.close(); activeConnection = null; } localDesc = null; remoteDesc = null; setButtonClickable('chatJoin', true); setButtonClickable('chatLeave', false); setButtonClickable('certNew', true); setButtonClickable('certLoad', true); setButtonClickable('certClear', true); setChatStatusDisplay('Disconnected'); } // ================================= Chat Log ================================= function clearChatLog() { document.getElementById('chatLog').innerHTML = ''; } function appendChatLog(name, msg) { document.getElementById('chatLog').innerHTML += `
${name}: ${msg}
`; } // Post Button function onPostChat() { let input = document.getElementById('chatInput'); let text = input.value; appendChatLog('Local', text); activeChannel.send(text); input.value = ''; } registerButtonActions(); openCertDatabase( db => { console.assert(certDB === null, 'IndexedDB already open'); certDB = db; console.log('IndexedDB opened.'); setButtonClickable('certNew', true); setButtonClickable('certLoad', true); }, err => console.error(`IndexedDB open error: ${err}`) );