Initial commit (Chrome PoC, tested on Vivaldi v7.0.3495.6)

This commit is contained in:
Jack Yu 2024-10-26 21:42:25 -07:00
commit 860fa8ad90
13 changed files with 2904 additions and 0 deletions

243
.gitignore vendored Normal file
View File

@ -0,0 +1,243 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,vim,emacs,webstorm
# Edit at https://www.toptal.com/developers/gitignore?templates=node,vim,emacs,webstorm
### Emacs ###
# -*- mode: gitignore; -*-
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# Org-mode
.org-id-locations
*_archive
# flymake-mode
*_flymake.*
# eshell files
/eshell/history
/eshell/lastdir
# elpa packages
/elpa/
# reftex files
*.rel
# AUCTeX auto folder
/auto/
# cask packages
.cask/
dist/
# Flycheck
flycheck_*.el
# server auth directory
/server/
# projectiles files
.projectile
# directory configuration
.dir-locals.el
# network security
/network-security.data
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### WebStorm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/
# CMake
cmake-build-*/
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# End of https://www.toptal.com/developers/gitignore/api/node,vim,emacs,webstorm

45
app.js Normal file
View File

@ -0,0 +1,45 @@
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var indexRouter = require('./routes/index');
var keysRouter = require('./routes/keys');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(favicon(path.join(__dirname,'public', 'images', 'favicon.ico')));
app.use('/js/socket.io', express.static(path.join(
__dirname, 'node_modules', 'socket.io','client-dist')));
app.use('/', indexRouter);
app.use('/keys', keysRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;

124
bin/www Executable file
View File

@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('webrtc-poc:server');
var http = require('http');
var socketio = require('socket.io');
var Turn = require('node-turn');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
var io = socketio(server);
/**
* Handle signaling events
*/
io.on('connection', socket => {
console.log('Socket connected:', socket.id, 'at',
socket.request.connection.remoteAddress);
socket.on('disconnect', () => {
console.log('Socket disconnected:', socket.id, 'at', socket.request.connection.remoteAddress)
});
// Broadcast the client's offer
socket.on('join-offer', offer => {
console.log('Broadcast join-offer event from', socket.id);
socket.broadcast.emit('join-offer', offer);
});
socket.on('join-answer', answer => {
let dstSocket = JSON.parse(answer).dst;
console.log('Relaying join-answer event from', socket.id, 'to', dstSocket);
io.sockets.sockets.get(dstSocket).emit('join-answer', answer);
});
socket.on('join', info => {
console.log('Broadcast join event from', socket.id);
socket.broadcast.emit('join', info);
});
socket.on('leave', id => {
socket.broadcast.emit('leave', id);
});
});
var turn_server = new Turn();
turn_server.start();
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}

1953
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "webrtc-poc",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"morgan": "~1.9.1",
"node-turn": "^0.0.6",
"pug": "2.0.0-beta11",
"serve-favicon": "^2.5.0",
"socket.io": "^4.8.1"
}
}

8
public/css/style.css Normal file
View File

@ -0,0 +1,8 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}

BIN
public/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

422
public/js/main.js Normal file
View File

@ -0,0 +1,422 @@
'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}`)
);

9
routes/index.js Normal file
View File

@ -0,0 +1,9 @@
let express = require('express');
let router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'WebRTC Auth PoC' });
});
module.exports = router;

43
routes/keys.js Normal file
View File

@ -0,0 +1,43 @@
let express = require('express');
let router = express.Router();
let keymap = {}
router.get('/:id', (req, res) => {
let key = req.params.id.toLowerCase();
if (key in keymap) {
if (keymap[key] < Date.now()) {
delete keymap[key];
res.status(403).json({status: 'Key Expired'});
} else {
res.json({status: 'OK'});
}
} else {
res.status(404).json({status: 'Not found'});
}
});
router.post('/', (req, res) => {
if (!('key' in req.body && 'expires' in req.body)) {
res.status(400).json({status: 'Bad Request'});
}
let key = req.body.key.toLowerCase();
let expires = req.body.expires;
if (key in keymap) {
res.status(400).json({status: 'Key exists'});
} else {
keymap[key] = expires
res.json({status: 'OK'});
}
});
router.delete('/:id', (req, res) => {
let key = req.params.id.toLowerCase();
if (key in keymap) {
delete keymap[key];
res.json({status: 'OK'});
} else {
res.status(404).json({status: 'Not found'});
}
});
module.exports = router;

6
views/error.pug Normal file
View File

@ -0,0 +1,6 @@
extends layout
block content
h1= message
h2= error.status
pre #{error.stack}

25
views/index.pug Normal file
View File

@ -0,0 +1,25 @@
extends layout
block content
script(src='https://webrtc.github.io/adapter/adapter-latest.js' defer)
script(src='js/socket.io/socket.io.js' defer)
script(src='js/main.js' defer)
h1= title
h2 Certificate Status:#{' '}
span(id='certStatus') Unknown
div
button(id='certLoad' disabled=true) Load
button(id='certSave' disabled=true) Save
button(id='certNew' disabled=true) New
button(id='certUpload' disabled=true) Upload
button(id='certClear' disabled=true) Clear
h2 Chat Status:#{' '}
span(id='chatStatus') Disconnected
div
button(id='chatJoin' disabled=true) Join
button(id='chatLeave' disabled=true) Leave
h2 Chat Log
div
input(id='chatInput')
button(id='chatPost' disabled=true) Post
div(id='chatLog')

7
views/layout.pug Normal file
View File

@ -0,0 +1,7 @@
doctype html
html
head
title= title
link(rel='stylesheet', href='/css/style.css')
body
block content