422 lines
14 KiB
JavaScript
422 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 getCertificateChecksum(cert) {
|
|
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('');
|
|
}
|
|
|
|
// 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) {
|
|
console.log('Loaded certificate:', getCertificateChecksum(cert));
|
|
console.log('Expires:', cert.expires);
|
|
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 => {
|
|
console.log('Generated new certificate:', getCertificateChecksum(cert));
|
|
console.log('Expires:', cert.expires);
|
|
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');
|
|
fetch('/keys', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
'key': getCertificateChecksum(localCert),
|
|
'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');
|
|
let fp = getCertificateChecksum(localCert);
|
|
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 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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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}`)
|
|
); |