allow encryption and authorization over untrusted signaling server

This commit is contained in:
Kevin Jahns 2019-12-10 00:47:21 +01:00
parent fcc52ff25c
commit b6466fb9ba
10 changed files with 3122 additions and 158 deletions

View file

@ -3,7 +3,8 @@
It propagates document updates directly to all users via WebRTC.
* Fast message propagation
* No setup required, a default signalling server is available
* Encryption and authorization over untrusted signaling server
* No setup required, public signaling servers are available
* Very little server load
* Not suited for a large amount of collaborators on a single document (each peer is connected to each other)
@ -23,23 +24,23 @@ import { WebrtcProvider } from '../src/y-webrtc.js'
const ydoc = new Y.Doc()
// clients connected to the same room-name share document updates
const provider = new WebrtcProvider('your-room-name', ydoc)
const provider = new WebrtcProvider('your-room-name', ydoc, { password: 'optional-room-password' })
const yarray = ydoc.get('array', Y.Array)
```
##### Signalling
##### Signaling
The peers find each other by connecting to a signalling server. This package implements a small signalling server in `./bin/server.js`.
The peers find each other by connecting to a signaling server. This package implements a small signaling server in `./bin/server.js`.
```sh
# start signalling server
# start signaling server
PORT=4444 node ./bin/server.js
```
Peers using the same signalling server will find each other. You can specify several custom signalling servers like so:
Peers using the same signaling server will find each other. You can specify several custom signaling servers like so:
```js
const provider = new WebrtcProvider('your-room-name', ydoc, { signalling: ['wss://y-webrtc-ckynwnzncc.now.sh', 'ws://localhost:4444'] })
const provider = new WebrtcProvider('your-room-name', ydoc, { signaling: ['wss://y-webrtc-ckynwnzncc.now.sh', 'ws://localhost:4444'] })
```
### Logging

View file

@ -106,15 +106,13 @@ const onconnection = conn => {
})
break
case 'publish':
if (message.topics) {
/**
* @type {Set<any>}
*/
const receivers = new Set()
message.topics.forEach(/** @param {string} topicName */ topicName => {
(topics.get(topicName) || []).forEach(sub => receivers.add(sub))
})
receivers.forEach(receiver => send(receiver, message))
if (message.topic) {
const receivers = topics.get(message.topic)
if (receivers) {
receivers.forEach(receiver =>
send(receiver, message)
)
}
}
break
case 'ping':
@ -138,4 +136,4 @@ server.on('upgrade', (request, socket, head) => {
server.listen(port)
console.log('Signalling server running on localhost:', port)
console.log('Signaling server running on localhost:', port)

9
index.html Normal file
View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Testing y-webrtc</title>
</head>
<body>
<script type="module" src="./dist/test.js"></script>
</body>
</html>

2751
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,12 +7,14 @@
"sideEffects": false,
"scripts": {
"start": "node ./bin/server.js",
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
"dist": "rm -rf dist && rollup -c",
"watch": "rollup -wc",
"lint": "standard",
"preversion": "npm run lint && npm run dist"
},
"bin": {
"y-webrtc-signalling": "./bin/server.js"
"y-webrtc-signaling": "./bin/server.js"
},
"files": [
"dist/*",
@ -40,9 +42,11 @@
},
"dependencies": {
"simple-peer": "^9.6.2",
"lib0": "^0.1.5"
"lib0": "^0.1.6"
},
"devDependencies": {
"concurrently": "^5.0.0",
"live-server": "^1.2.1",
"rollup": "^1.27.8",
"rollup-cli": "^1.0.9",
"rollup-plugin-commonjs": "^10.1.0",
@ -50,8 +54,7 @@
"rollup-plugin-terser": "^5.1.2",
"standard": "^12.0.1",
"y-protocols": "^0.1.0",
"lib0": "^0.1.5",
"yjs": "13.0.0-102"
"yjs": "^13.0.0-102"
},
"peerDependenies": {
"yjs": ">=13.0.0-102"

View file

@ -79,6 +79,15 @@ export default [
sourcemap: true
}],
plugins
}, {
input: './test/index.js',
output: [{
name: 'test',
file: 'dist/test.js',
format: 'iife',
sourcemap: true
}],
plugins
}, {
input: './src/y-webrtc.js',
external: id => /^(lib0|yjs|y-protocols|simple-peer)/.test(id),

95
src/crypto.js Normal file
View file

@ -0,0 +1,95 @@
/* eslint-env browser */
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as buffer from 'lib0/buffer.js'
import * as promise from 'lib0/promise.js'
import * as error from 'lib0/error.js'
import * as string from 'lib0/string.js'
/**
* @param {string} secret
* @param {string} roomName
* @return {PromiseLike<CryptoKey>}
*/
export const deriveKey = (secret, roomName) => {
const secretBuffer = string.encodeUtf8(secret).buffer
const salt = string.encodeUtf8(roomName).buffer
return crypto.subtle.importKey(
'raw',
secretBuffer,
'PBKDF2',
false,
['deriveKey']
).then(keyMaterial =>
crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{
name: 'AES-GCM',
length: 256
},
true,
[ 'encrypt', 'decrypt' ]
)
)
}
/**
* @param {any} data A json object to be encrypted
* @param {CryptoKey} key
* @return {PromiseLike<string>} encrypted, base64 encoded message
*/
export const encrypt = (data, key) => {
const iv = crypto.getRandomValues(new Uint8Array(12))
const dataEncoder = encoding.createEncoder()
encoding.writeAny(dataEncoder, data)
const dataBuffer = encoding.toUint8Array(dataEncoder)
return crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv
},
key,
dataBuffer
).then(cipher => {
const encryptedDataEncoder = encoding.createEncoder()
encoding.writeVarString(encryptedDataEncoder, 'AES-GCM')
encoding.writeVarUint8Array(encryptedDataEncoder, iv)
encoding.writeVarUint8Array(encryptedDataEncoder, new Uint8Array(cipher))
return buffer.toBase64(encoding.toUint8Array(encryptedDataEncoder))
})
}
/**
* @param {string} data
* @param {CryptoKey} key
* @return {PromiseLike<any>} decrypted object
*/
export const decrypt = (data, key) => {
if (typeof data !== 'string') {
return promise.reject()
}
const dataDecoder = decoding.createDecoder(buffer.fromBase64(data))
const algorithm = decoding.readVarString(dataDecoder)
if (algorithm !== 'AES-GCM') {
promise.reject(error.create('Unknown encryption algorithm'))
}
const iv = decoding.readVarUint8Array(dataDecoder)
const cipher = decoding.readVarUint8Array(dataDecoder)
return crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv
},
key,
cipher
).then(decryptedValue =>
decoding.readAny(decoding.createDecoder(new Uint8Array(decryptedValue)))
)
}

View file

@ -5,13 +5,16 @@ import * as random from 'lib0/random.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import { Observable } from 'lib0/observable.js'
import * as logging from 'lib0/logging.js'
import * as promise from 'lib0/promise.js'
import * as Y from 'yjs' // eslint-disable-line
import Peer from 'simple-peer/simplepeer.min.js'
import * as syncProtocol from 'y-protocols/sync.js'
import * as awarenessProtocol from 'y-protocols/awareness.js'
import * as logging from 'lib0/logging.js'
import * as cryptoutils from './crypto.js'
const log = logging.createModuleLogger('y-webrtc')
@ -19,36 +22,30 @@ const messageSync = 0
const messageQueryAwareness = 3
const messageAwareness = 1
const peerId = random.uuidv4()
/**
* @type {Map<string, SignalingConn>}
*/
const signalingConns = new Map()
/**
* @type {Map<string, SignallingConn>}
* @type {Map<string,Room>}
*/
const signallingConns = new Map()
/**
* @type {Map<string, WebrtcConn>}
*/
const webrtcConns = new Map()
const rooms = new Map()
/**
* @type {Map<string,WebrtcRoom>}
* @param {Room} room
*/
const webrtcRooms = new Map()
/**
* @param {WebrtcRoom} webrtcRoom
*/
const checkIsSynced = webrtcRoom => {
const checkIsSynced = room => {
let synced = true
webrtcRoom.peers.forEach(peer => {
if (!peer.syncedRooms.has(webrtcRoom.name)) {
room.webrtcConns.forEach(peer => {
if (!peer.synced) {
synced = false
}
})
if ((!synced && webrtcRoom.synced) || (synced && !webrtcRoom.synced)) {
webrtcRoom.synced = synced
webrtcRoom.provider.emit('synced', [{ synced }])
log('synced ', logging.BOLD, webrtcRoom.name, logging.UNBOLD, ' with all peers')
if ((!synced && room.synced) || (synced && !room.synced)) {
room.synced = synced
room.provider.emit('synced', [{ synced }])
log('synced ', logging.BOLD, room.name, logging.UNBOLD, ' with all peers')
}
}
@ -61,23 +58,21 @@ const readPeerMessage = (peerConn, buf) => {
const decoder = decoding.createDecoder(buf)
const encoder = encoding.createEncoder()
const messageType = decoding.readVarUint(decoder)
const roomName = decoding.readVarString(decoder)
const webrtcRoom = webrtcRooms.get(roomName)
if (webrtcRoom === undefined) {
const room = peerConn.room
if (room === undefined) {
return null
}
const provider = webrtcRoom.provider
const doc = webrtcRoom.doc
const provider = room.provider
const doc = room.doc
let sendReply = false
switch (messageType) {
case messageSync:
encoding.writeVarUint(encoder, messageSync)
encoding.writeVarString(encoder, roomName)
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, doc, webrtcRoom.provider)
if (syncMessageType === syncProtocol.messageYjsSyncStep2 && !webrtcRoom.synced) {
peerConn.syncedRooms.add(roomName)
log('synced ', logging.BOLD, roomName, logging.UNBOLD, ' with ', logging.BOLD, peerConn.remotePeerId)
checkIsSynced(webrtcRoom)
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, doc, room.provider)
if (syncMessageType === syncProtocol.messageYjsSyncStep2 && !room.synced) {
peerConn.synced = true
log('synced ', logging.BOLD, room.name, logging.UNBOLD, ' with ', logging.BOLD, peerConn.remotePeerId)
checkIsSynced(room)
}
if (syncMessageType === syncProtocol.messageYjsSyncStep1) {
sendReply = true
@ -85,7 +80,6 @@ const readPeerMessage = (peerConn, buf) => {
break
case messageQueryAwareness:
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarString(encoder, roomName)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(provider.awareness, Array.from(provider.awareness.getStates().keys())))
sendReply = true
break
@ -107,78 +101,70 @@ const readPeerMessage = (peerConn, buf) => {
* @param {WebrtcConn} webrtcConn
* @param {encoding.Encoder} encoder
*/
const send = (webrtcConn, encoder) => {
const sendWebrtcConn = (webrtcConn, encoder) => {
if (webrtcConn.connected) {
webrtcConn.peer.send(encoding.toUint8Array(encoder))
}
}
/**
* @param {WebrtcRoom} webrtcRoom
* @param {Room} room
* @param {encoding.Encoder} encoder
*/
const broadcast = (webrtcRoom, encoder) => {
const broadcastWebrtcConn = (room, encoder) => {
const m = encoding.toUint8Array(encoder)
webrtcRoom.peers.forEach(peer => peer.peer.send(m))
room.webrtcConns.forEach(conn => {
if (conn.connected) {
conn.peer.send(m)
}
})
}
export class WebrtcConn {
/**
* @param {SignallingConn} signalingConn
* @param {SignalingConn} signalingConn
* @param {boolean} initiator
* @param {string} remotePeerId
* @param {Array<string>} announcedTopics
* @param {Room} room
*/
constructor (signalingConn, initiator, remotePeerId, announcedTopics) {
constructor (signalingConn, initiator, remotePeerId, room) {
log('establishing connection to ', logging.BOLD, remotePeerId)
this.room = room
this.remotePeerId = remotePeerId
this.closed = false
this.connected = false
/**
* @type {Set<string>}
*/
this.syncedRooms = new Set()
this.synced = false
/**
* @type {any}
*/
this.peer = new Peer({ initiator })
this.peer.on('signal', data => {
signalingConn.send({ type: 'publish', topics: announcedTopics, to: remotePeerId, from: peerId, messageType: 'signal', data })
this.peer.on('signal', signal => {
publishSignalingMessage(signalingConn, room, { to: remotePeerId, from: room.peerId, type: 'signal', signal })
})
this.peer.on('connect', () => {
log('connected to ', logging.BOLD, remotePeerId)
this.connected = true
announcedTopics.forEach(roomName => {
const room = webrtcRooms.get(roomName)
if (room) {
// add peer to room
room.peers.add(this)
// send sync step 1
const provider = room.provider
const doc = provider.doc
const awareness = provider.awareness
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
encoding.writeVarString(encoder, room.name)
syncProtocol.writeSyncStep1(encoder, doc)
send(this, encoder)
sendWebrtcConn(this, encoder)
const awarenessStates = awareness.getStates()
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarString(encoder, room.name)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(awarenessStates.keys())))
send(this, encoder)
sendWebrtcConn(this, encoder)
}
}
})
})
this.peer.on('close', () => {
this.connected = false
this.closed = true
webrtcConns.delete(this.remotePeerId)
webrtcRooms.forEach(room => {
room.peers.delete(this)
room.webrtcConns.delete(this.remotePeerId)
checkIsSynced(room)
})
this.peer.destroy()
log('closed connection to ', logging.BOLD, remotePeerId)
})
@ -190,71 +176,128 @@ export class WebrtcConn {
this.peer.on('data', data => {
const answer = readPeerMessage(this, data)
if (answer !== null) {
send(this, answer)
sendWebrtcConn(this, answer)
}
})
}
}
export class WebrtcRoom {
export class Room {
/**
* @param {Y.Doc} doc
* @param {WebrtcProvider} provider
* @param {string} name
* @param {CryptoKey|null} key
*/
constructor (doc, provider, name) {
constructor (doc, provider, name, key) {
/**
* @type {Set<WebrtcConn>}
* Do not assume that peerId is unique. This is only meant for sending signaling messages.
*
* @type {string}
*/
this.peers = new Set()
this.peerId = random.uuidv4()
this.doc = doc
this.provider = provider
this.synced = false
this.name = name
this.key = key
/**
* @type {Map<string, WebrtcConn>}
*/
this.webrtcConns = new Map()
}
}
export class SignallingConn extends ws.WebsocketClient {
/**
* @param {Y.Doc} doc
* @param {WebrtcProvider} provider
* @param {string} name
* @param {CryptoKey|null} key
* @return {Room}
*/
const openRoom = (doc, provider, name, key) => {
// there must only be one room
if (rooms.has(name)) {
throw error.create('A Yjs Doc connected to that room already exists!')
}
const room = new Room(doc, provider, name, key)
rooms.set(name, /** @type {Room} */ (room))
// signal through all available signaling connections
signalingConns.forEach(conn => {
// only subcribe if connection is established, otherwise the conn automatically subscribes to all rooms
if (conn.connected) {
conn.send({ type: 'subscribe', topics: [name] })
publishSignalingMessage(conn, room, { type: 'announce', from: room.peerId })
}
})
return room
}
/**
* @param {SignalingConn} conn
* @param {Room} room
* @param {any} data
*/
const publishSignalingMessage = (conn, room, data) => {
if (room.key) {
cryptoutils.encrypt(data, room.key).then(data => {
conn.send({ type: 'publish', topic: room.name, data })
})
} else {
conn.send({ type: 'publish', topic: room.name, data })
}
}
export class SignalingConn extends ws.WebsocketClient {
constructor (url) {
super(url)
/**
* @type {Set<WebrtcProvider>}
*/
this.providers = new Set()
this.afterOpen.push(() => ({ type: 'subscribe', topics: Array.from(webrtcRooms.keys()) }))
this.afterOpen.push(() => ({ type: 'publish', messageType: 'announce', topics: Array.from(webrtcRooms.keys()), from: peerId }))
this.on('connect', () => {
const topics = Array.from(rooms.keys())
this.send({ type: 'subscribe', topics })
rooms.forEach(room =>
publishSignalingMessage(this, room, { type: 'announce', from: room.peerId })
)
})
this.on('message', m => {
if (m.from === peerId || (m.to !== undefined && m.to !== peerId)) {
return
}
switch (m.type) {
case 'publish': {
switch (m.messageType) {
const roomName = m.topic
const room = rooms.get(roomName)
if (room == null || typeof roomName !== 'string') {
return
}
const execMessage = data => {
const webrtcConns = room.webrtcConns
const peerId = room.peerId
if (data == null || data.from === peerId || (data.to !== undefined && data.to !== peerId)) {
return
}
switch (data.type) {
case 'announce':
map.setIfUndefined(webrtcConns, m.from, () => new WebrtcConn(this, true, m.from, m.topics))
map.setIfUndefined(webrtcConns, data.from, () => new WebrtcConn(this, true, data.from, room))
break
case 'signal':
if (m.to === peerId) {
map.setIfUndefined(webrtcConns, m.from, () => new WebrtcConn(this, false, m.from, m.topics)).peer.signal(m.data)
if (data.to === peerId) {
map.setIfUndefined(webrtcConns, data.from, () => new WebrtcConn(this, false, data.from, room)).peer.signal(data.signal)
}
break
}
}
if (room.key) {
cryptoutils.decrypt(m.data, room.key).then(execMessage)
} else {
execMessage(m.data)
}
}
}
})
this.on('connect', () => log(`connected (${url})`))
this.on('disconnect', () => log(`disconnect (${url})`))
}
/**
* @param {Array<string>} rooms
*/
subscribe (rooms) {
// only subcribe if connection is established, otherwise the conn automatically subscribes to all webrtcRooms
if (this.connected) {
this.send({ type: 'subscribe', topics: rooms })
this.send({ type: 'publish', messageType: 'announce', topics: Array.from(webrtcRooms.keys()), from: peerId })
}
}
}
/**
@ -262,27 +305,33 @@ export class SignallingConn extends ws.WebsocketClient {
*/
export class WebrtcProvider extends Observable {
/**
* @param {string} room
* @param {string} roomName
* @param {Y.Doc} doc
* @param {Object} [opts]
* @param {Array<string>} [opts.signalling]
* @param {Array<string>} [opts.signaling]
* @param {string?} [opts.password]
*/
constructor (room, doc, { signalling = ['wss://signalling.yjs.dev', 'wss://y-webrtc-hrxsloqrim.now.sh', 'wss://y-webrtc-signalling-eu.herokuapp.com', 'wss://y-webrtc-signalling-us.herokuapp.com'] } = {}) {
constructor (roomName, doc, { signaling = ['wss://signaling.yjs.dev', 'wss://y-webrtc-uchplqjsol.now.sh', 'wss://y-webrtc-signaling-eu.herokuapp.com', 'wss://y-webrtc-signaling-us.herokuapp.com'], password = null } = {}) {
super()
this.room = room
this.roomName = roomName
this.doc = doc
this.signallingConns = []
signalling.forEach(url => {
const signallingConn = map.setIfUndefined(signallingConns, url, () => new SignallingConn(url))
this.signallingConns.push(signallingConn)
signallingConn.providers.add(this)
signallingConn.subscribe([this.room])
this.signalingConns = []
/**
* @type {PromiseLike<CryptoKey | null>}
*/
this.key = password ? cryptoutils.deriveKey(password, roomName) : /** @type {PromiseLike<null>} */ (promise.resolve(null))
signaling.forEach(url => {
const signalingConn = map.setIfUndefined(signalingConns, url, () => new SignalingConn(url))
this.signalingConns.push(signalingConn)
signalingConn.providers.add(this)
})
/**
* @type {Room|null}
*/
let room = null
this.key.then(key => {
room = openRoom(doc, this, roomName, key)
})
if (webrtcRooms.has(room)) {
throw error.create('A Yjs Doc connected to that room already exists!')
}
const webrtcRoom = new WebrtcRoom(doc, this, room)
webrtcRooms.set(room, webrtcRoom)
/**
* @type {awarenessProtocol.Awareness}
*/
@ -294,12 +343,11 @@ export class WebrtcProvider extends Observable {
* @param {any} origin
*/
this._docUpdateHandler = (update, origin) => {
if (origin !== this || origin === null) {
if (room !== null && (origin !== this || origin === null)) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
encoding.writeVarString(encoder, room)
syncProtocol.writeUpdate(encoder, update)
broadcast(webrtcRoom, encoder)
broadcastWebrtcConn(room, encoder)
}
}
/**
@ -309,12 +357,13 @@ export class WebrtcProvider extends Observable {
* @param {any} origin
*/
this._awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
if (room !== null) {
const changedClients = added.concat(updated).concat(removed)
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarString(encoder, room)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients))
broadcast(webrtcRoom, encoder)
broadcastWebrtcConn(room, encoder)
}
}
this.doc.on('update', this._docUpdateHandler)
this.awareness.on('change', this._awarenessUpdateHandler)
@ -323,16 +372,20 @@ export class WebrtcProvider extends Observable {
})
}
destroy () {
this.signallingConns.forEach(conn => {
super.destroy()
this.signalingConns.forEach(conn => {
conn.providers.delete(this)
if (conn.providers.size === 0) {
conn.destroy()
signallingConns.delete(this.room)
signalingConns.delete(this.roomName)
} else {
conn.send({ type: 'unsubscribe', topics: [this.room] })
conn.send({ type: 'unsubscribe', topics: [this.roomName] })
}
})
webrtcRooms.delete(this.room)
// need to wait for key before deleting room
this.key.then(() => {
rooms.delete(this.roomName)
})
this.doc.off('update', this._docUpdateHandler)
this.awareness.off('change', this._awarenessUpdateHandler)
super.destroy()

33
test/crypto.test.js Normal file
View file

@ -0,0 +1,33 @@
import * as cryptutils from '../src/crypto.js'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
/**
* @param {t.TestCase} tc
*/
export const testReapeatEncryption = async tc => {
const secret = prng.word(tc.prng)
const roomName = prng.word(tc.prng)
const data = {
content: 'just a test',
number: 4
}
/**
* @type {any}
*/
let encrypted, decrypted, key
await t.measureTime('Key generation', async () => {
key = await cryptutils.deriveKey(secret, roomName)
})
await t.measureTime('Encryption', async () => {
encrypted = await cryptutils.encrypt(data, key)
})
t.info(`stringified len: ${JSON.stringify(data).length}b`)
t.info(`Encrypted len: ${encrypted.length}b`)
await t.measureTime('Decryption', async () => {
decrypted = await cryptutils.decrypt(encrypted, key)
})
t.compare(data, decrypted)
}

18
test/index.js Normal file
View file

@ -0,0 +1,18 @@
import * as crypto from './crypto.test.js'
import { runTests } from 'lib0/testing.js'
import { isBrowser, isNode } from 'lib0/environment.js'
import * as log from 'lib0/logging.js'
if (isBrowser) {
log.createVConsole(document.body)
}
runTests({
crypto
}).then(success => {
/* istanbul ignore next */
if (isNode) {
process.exit(success ? 0 : 1)
}
})