WebRTC-PoC/public/js/main.js

439 lines
14 KiB
JavaScript

'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 += `<p>${name}: ${msg}</p>`;
}
// 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}`)
);