allow encryption and authorization over untrusted signaling server
This commit is contained in:
parent
fcc52ff25c
commit
b6466fb9ba
10 changed files with 3122 additions and 158 deletions
15
README.md
15
README.md
|
@ -3,7 +3,8 @@
|
||||||
It propagates document updates directly to all users via WebRTC.
|
It propagates document updates directly to all users via WebRTC.
|
||||||
|
|
||||||
* Fast message propagation
|
* 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
|
* Very little server load
|
||||||
* Not suited for a large amount of collaborators on a single document (each peer is connected to each other)
|
* 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()
|
const ydoc = new Y.Doc()
|
||||||
// clients connected to the same room-name share document updates
|
// 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)
|
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
|
```sh
|
||||||
# start signalling server
|
# start signaling server
|
||||||
PORT=4444 node ./bin/server.js
|
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
|
```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
|
### Logging
|
||||||
|
|
|
@ -106,15 +106,13 @@ const onconnection = conn => {
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'publish':
|
case 'publish':
|
||||||
if (message.topics) {
|
if (message.topic) {
|
||||||
/**
|
const receivers = topics.get(message.topic)
|
||||||
* @type {Set<any>}
|
if (receivers) {
|
||||||
*/
|
receivers.forEach(receiver =>
|
||||||
const receivers = new Set()
|
send(receiver, message)
|
||||||
message.topics.forEach(/** @param {string} topicName */ topicName => {
|
)
|
||||||
(topics.get(topicName) || []).forEach(sub => receivers.add(sub))
|
}
|
||||||
})
|
|
||||||
receivers.forEach(receiver => send(receiver, message))
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'ping':
|
case 'ping':
|
||||||
|
@ -138,4 +136,4 @@ server.on('upgrade', (request, socket, head) => {
|
||||||
|
|
||||||
server.listen(port)
|
server.listen(port)
|
||||||
|
|
||||||
console.log('Signalling server running on localhost:', port)
|
console.log('Signaling server running on localhost:', port)
|
||||||
|
|
9
index.html
Normal file
9
index.html
Normal 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
2751
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
@ -7,12 +7,14 @@
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/server.js",
|
"start": "node ./bin/server.js",
|
||||||
|
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
|
||||||
"dist": "rm -rf dist && rollup -c",
|
"dist": "rm -rf dist && rollup -c",
|
||||||
|
"watch": "rollup -wc",
|
||||||
"lint": "standard",
|
"lint": "standard",
|
||||||
"preversion": "npm run lint && npm run dist"
|
"preversion": "npm run lint && npm run dist"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"y-webrtc-signalling": "./bin/server.js"
|
"y-webrtc-signaling": "./bin/server.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/*",
|
"dist/*",
|
||||||
|
@ -40,9 +42,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"simple-peer": "^9.6.2",
|
"simple-peer": "^9.6.2",
|
||||||
"lib0": "^0.1.5"
|
"lib0": "^0.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"concurrently": "^5.0.0",
|
||||||
|
"live-server": "^1.2.1",
|
||||||
"rollup": "^1.27.8",
|
"rollup": "^1.27.8",
|
||||||
"rollup-cli": "^1.0.9",
|
"rollup-cli": "^1.0.9",
|
||||||
"rollup-plugin-commonjs": "^10.1.0",
|
"rollup-plugin-commonjs": "^10.1.0",
|
||||||
|
@ -50,8 +54,7 @@
|
||||||
"rollup-plugin-terser": "^5.1.2",
|
"rollup-plugin-terser": "^5.1.2",
|
||||||
"standard": "^12.0.1",
|
"standard": "^12.0.1",
|
||||||
"y-protocols": "^0.1.0",
|
"y-protocols": "^0.1.0",
|
||||||
"lib0": "^0.1.5",
|
"yjs": "^13.0.0-102"
|
||||||
"yjs": "13.0.0-102"
|
|
||||||
},
|
},
|
||||||
"peerDependenies": {
|
"peerDependenies": {
|
||||||
"yjs": ">=13.0.0-102"
|
"yjs": ">=13.0.0-102"
|
||||||
|
|
|
@ -79,6 +79,15 @@ export default [
|
||||||
sourcemap: true
|
sourcemap: true
|
||||||
}],
|
}],
|
||||||
plugins
|
plugins
|
||||||
|
}, {
|
||||||
|
input: './test/index.js',
|
||||||
|
output: [{
|
||||||
|
name: 'test',
|
||||||
|
file: 'dist/test.js',
|
||||||
|
format: 'iife',
|
||||||
|
sourcemap: true
|
||||||
|
}],
|
||||||
|
plugins
|
||||||
}, {
|
}, {
|
||||||
input: './src/y-webrtc.js',
|
input: './src/y-webrtc.js',
|
||||||
external: id => /^(lib0|yjs|y-protocols|simple-peer)/.test(id),
|
external: id => /^(lib0|yjs|y-protocols|simple-peer)/.test(id),
|
||||||
|
|
95
src/crypto.js
Normal file
95
src/crypto.js
Normal 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)))
|
||||||
|
)
|
||||||
|
}
|
273
src/y-webrtc.js
273
src/y-webrtc.js
|
@ -5,13 +5,16 @@ import * as random from 'lib0/random.js'
|
||||||
import * as encoding from 'lib0/encoding.js'
|
import * as encoding from 'lib0/encoding.js'
|
||||||
import * as decoding from 'lib0/decoding.js'
|
import * as decoding from 'lib0/decoding.js'
|
||||||
import { Observable } from 'lib0/observable.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 * as Y from 'yjs' // eslint-disable-line
|
||||||
import Peer from 'simple-peer/simplepeer.min.js'
|
import Peer from 'simple-peer/simplepeer.min.js'
|
||||||
|
|
||||||
import * as syncProtocol from 'y-protocols/sync.js'
|
import * as syncProtocol from 'y-protocols/sync.js'
|
||||||
import * as awarenessProtocol from 'y-protocols/awareness.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')
|
const log = logging.createModuleLogger('y-webrtc')
|
||||||
|
|
||||||
|
@ -19,36 +22,30 @@ const messageSync = 0
|
||||||
const messageQueryAwareness = 3
|
const messageQueryAwareness = 3
|
||||||
const messageAwareness = 1
|
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()
|
const rooms = new Map()
|
||||||
/**
|
|
||||||
* @type {Map<string, WebrtcConn>}
|
|
||||||
*/
|
|
||||||
const webrtcConns = new Map()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {Map<string,WebrtcRoom>}
|
* @param {Room} room
|
||||||
*/
|
*/
|
||||||
const webrtcRooms = new Map()
|
const checkIsSynced = room => {
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {WebrtcRoom} webrtcRoom
|
|
||||||
*/
|
|
||||||
const checkIsSynced = webrtcRoom => {
|
|
||||||
let synced = true
|
let synced = true
|
||||||
webrtcRoom.peers.forEach(peer => {
|
room.webrtcConns.forEach(peer => {
|
||||||
if (!peer.syncedRooms.has(webrtcRoom.name)) {
|
if (!peer.synced) {
|
||||||
synced = false
|
synced = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if ((!synced && webrtcRoom.synced) || (synced && !webrtcRoom.synced)) {
|
if ((!synced && room.synced) || (synced && !room.synced)) {
|
||||||
webrtcRoom.synced = synced
|
room.synced = synced
|
||||||
webrtcRoom.provider.emit('synced', [{ synced }])
|
room.provider.emit('synced', [{ synced }])
|
||||||
log('synced ', logging.BOLD, webrtcRoom.name, logging.UNBOLD, ' with all peers')
|
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 decoder = decoding.createDecoder(buf)
|
||||||
const encoder = encoding.createEncoder()
|
const encoder = encoding.createEncoder()
|
||||||
const messageType = decoding.readVarUint(decoder)
|
const messageType = decoding.readVarUint(decoder)
|
||||||
const roomName = decoding.readVarString(decoder)
|
const room = peerConn.room
|
||||||
const webrtcRoom = webrtcRooms.get(roomName)
|
if (room === undefined) {
|
||||||
if (webrtcRoom === undefined) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const provider = webrtcRoom.provider
|
const provider = room.provider
|
||||||
const doc = webrtcRoom.doc
|
const doc = room.doc
|
||||||
let sendReply = false
|
let sendReply = false
|
||||||
switch (messageType) {
|
switch (messageType) {
|
||||||
case messageSync:
|
case messageSync:
|
||||||
encoding.writeVarUint(encoder, messageSync)
|
encoding.writeVarUint(encoder, messageSync)
|
||||||
encoding.writeVarString(encoder, roomName)
|
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, doc, room.provider)
|
||||||
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, doc, webrtcRoom.provider)
|
if (syncMessageType === syncProtocol.messageYjsSyncStep2 && !room.synced) {
|
||||||
if (syncMessageType === syncProtocol.messageYjsSyncStep2 && !webrtcRoom.synced) {
|
peerConn.synced = true
|
||||||
peerConn.syncedRooms.add(roomName)
|
log('synced ', logging.BOLD, room.name, logging.UNBOLD, ' with ', logging.BOLD, peerConn.remotePeerId)
|
||||||
log('synced ', logging.BOLD, roomName, logging.UNBOLD, ' with ', logging.BOLD, peerConn.remotePeerId)
|
checkIsSynced(room)
|
||||||
checkIsSynced(webrtcRoom)
|
|
||||||
}
|
}
|
||||||
if (syncMessageType === syncProtocol.messageYjsSyncStep1) {
|
if (syncMessageType === syncProtocol.messageYjsSyncStep1) {
|
||||||
sendReply = true
|
sendReply = true
|
||||||
|
@ -85,7 +80,6 @@ const readPeerMessage = (peerConn, buf) => {
|
||||||
break
|
break
|
||||||
case messageQueryAwareness:
|
case messageQueryAwareness:
|
||||||
encoding.writeVarUint(encoder, messageAwareness)
|
encoding.writeVarUint(encoder, messageAwareness)
|
||||||
encoding.writeVarString(encoder, roomName)
|
|
||||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(provider.awareness, Array.from(provider.awareness.getStates().keys())))
|
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(provider.awareness, Array.from(provider.awareness.getStates().keys())))
|
||||||
sendReply = true
|
sendReply = true
|
||||||
break
|
break
|
||||||
|
@ -107,78 +101,70 @@ const readPeerMessage = (peerConn, buf) => {
|
||||||
* @param {WebrtcConn} webrtcConn
|
* @param {WebrtcConn} webrtcConn
|
||||||
* @param {encoding.Encoder} encoder
|
* @param {encoding.Encoder} encoder
|
||||||
*/
|
*/
|
||||||
const send = (webrtcConn, encoder) => {
|
const sendWebrtcConn = (webrtcConn, encoder) => {
|
||||||
|
if (webrtcConn.connected) {
|
||||||
webrtcConn.peer.send(encoding.toUint8Array(encoder))
|
webrtcConn.peer.send(encoding.toUint8Array(encoder))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {WebrtcRoom} webrtcRoom
|
* @param {Room} room
|
||||||
* @param {encoding.Encoder} encoder
|
* @param {encoding.Encoder} encoder
|
||||||
*/
|
*/
|
||||||
const broadcast = (webrtcRoom, encoder) => {
|
const broadcastWebrtcConn = (room, encoder) => {
|
||||||
const m = encoding.toUint8Array(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 {
|
export class WebrtcConn {
|
||||||
/**
|
/**
|
||||||
* @param {SignallingConn} signalingConn
|
* @param {SignalingConn} signalingConn
|
||||||
* @param {boolean} initiator
|
* @param {boolean} initiator
|
||||||
* @param {string} remotePeerId
|
* @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)
|
log('establishing connection to ', logging.BOLD, remotePeerId)
|
||||||
|
this.room = room
|
||||||
this.remotePeerId = remotePeerId
|
this.remotePeerId = remotePeerId
|
||||||
this.closed = false
|
this.closed = false
|
||||||
this.connected = false
|
this.connected = false
|
||||||
/**
|
this.synced = false
|
||||||
* @type {Set<string>}
|
|
||||||
*/
|
|
||||||
this.syncedRooms = new Set()
|
|
||||||
/**
|
/**
|
||||||
* @type {any}
|
* @type {any}
|
||||||
*/
|
*/
|
||||||
this.peer = new Peer({ initiator })
|
this.peer = new Peer({ initiator })
|
||||||
this.peer.on('signal', data => {
|
this.peer.on('signal', signal => {
|
||||||
signalingConn.send({ type: 'publish', topics: announcedTopics, to: remotePeerId, from: peerId, messageType: 'signal', data })
|
publishSignalingMessage(signalingConn, room, { to: remotePeerId, from: room.peerId, type: 'signal', signal })
|
||||||
})
|
})
|
||||||
this.peer.on('connect', () => {
|
this.peer.on('connect', () => {
|
||||||
log('connected to ', logging.BOLD, remotePeerId)
|
log('connected to ', logging.BOLD, remotePeerId)
|
||||||
this.connected = true
|
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
|
// send sync step 1
|
||||||
const provider = room.provider
|
const provider = room.provider
|
||||||
const doc = provider.doc
|
const doc = provider.doc
|
||||||
const awareness = provider.awareness
|
const awareness = provider.awareness
|
||||||
const encoder = encoding.createEncoder()
|
const encoder = encoding.createEncoder()
|
||||||
encoding.writeVarUint(encoder, messageSync)
|
encoding.writeVarUint(encoder, messageSync)
|
||||||
encoding.writeVarString(encoder, room.name)
|
|
||||||
syncProtocol.writeSyncStep1(encoder, doc)
|
syncProtocol.writeSyncStep1(encoder, doc)
|
||||||
send(this, encoder)
|
sendWebrtcConn(this, encoder)
|
||||||
const awarenessStates = awareness.getStates()
|
const awarenessStates = awareness.getStates()
|
||||||
if (awarenessStates.size > 0) {
|
if (awarenessStates.size > 0) {
|
||||||
const encoder = encoding.createEncoder()
|
const encoder = encoding.createEncoder()
|
||||||
encoding.writeVarUint(encoder, messageAwareness)
|
encoding.writeVarUint(encoder, messageAwareness)
|
||||||
encoding.writeVarString(encoder, room.name)
|
|
||||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(awarenessStates.keys())))
|
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(awarenessStates.keys())))
|
||||||
send(this, encoder)
|
sendWebrtcConn(this, encoder)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
this.peer.on('close', () => {
|
this.peer.on('close', () => {
|
||||||
this.connected = false
|
this.connected = false
|
||||||
this.closed = true
|
this.closed = true
|
||||||
webrtcConns.delete(this.remotePeerId)
|
room.webrtcConns.delete(this.remotePeerId)
|
||||||
webrtcRooms.forEach(room => {
|
|
||||||
room.peers.delete(this)
|
|
||||||
checkIsSynced(room)
|
checkIsSynced(room)
|
||||||
})
|
|
||||||
this.peer.destroy()
|
this.peer.destroy()
|
||||||
log('closed connection to ', logging.BOLD, remotePeerId)
|
log('closed connection to ', logging.BOLD, remotePeerId)
|
||||||
})
|
})
|
||||||
|
@ -190,71 +176,128 @@ export class WebrtcConn {
|
||||||
this.peer.on('data', data => {
|
this.peer.on('data', data => {
|
||||||
const answer = readPeerMessage(this, data)
|
const answer = readPeerMessage(this, data)
|
||||||
if (answer !== null) {
|
if (answer !== null) {
|
||||||
send(this, answer)
|
sendWebrtcConn(this, answer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebrtcRoom {
|
export class Room {
|
||||||
/**
|
/**
|
||||||
* @param {Y.Doc} doc
|
* @param {Y.Doc} doc
|
||||||
* @param {WebrtcProvider} provider
|
* @param {WebrtcProvider} provider
|
||||||
* @param {string} name
|
* @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.doc = doc
|
||||||
this.provider = provider
|
this.provider = provider
|
||||||
this.synced = false
|
this.synced = false
|
||||||
this.name = name
|
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) {
|
constructor (url) {
|
||||||
super(url)
|
super(url)
|
||||||
/**
|
/**
|
||||||
* @type {Set<WebrtcProvider>}
|
* @type {Set<WebrtcProvider>}
|
||||||
*/
|
*/
|
||||||
this.providers = new Set()
|
this.providers = new Set()
|
||||||
this.afterOpen.push(() => ({ type: 'subscribe', topics: Array.from(webrtcRooms.keys()) }))
|
this.on('connect', () => {
|
||||||
this.afterOpen.push(() => ({ type: 'publish', messageType: 'announce', topics: Array.from(webrtcRooms.keys()), from: peerId }))
|
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 => {
|
this.on('message', m => {
|
||||||
if (m.from === peerId || (m.to !== undefined && m.to !== peerId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch (m.type) {
|
switch (m.type) {
|
||||||
case 'publish': {
|
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':
|
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
|
break
|
||||||
case 'signal':
|
case 'signal':
|
||||||
if (m.to === peerId) {
|
if (data.to === peerId) {
|
||||||
map.setIfUndefined(webrtcConns, m.from, () => new WebrtcConn(this, false, m.from, m.topics)).peer.signal(m.data)
|
map.setIfUndefined(webrtcConns, data.from, () => new WebrtcConn(this, false, data.from, room)).peer.signal(data.signal)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (room.key) {
|
||||||
|
cryptoutils.decrypt(m.data, room.key).then(execMessage)
|
||||||
|
} else {
|
||||||
|
execMessage(m.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.on('connect', () => log(`connected (${url})`))
|
this.on('connect', () => log(`connected (${url})`))
|
||||||
this.on('disconnect', () => log(`disconnect (${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 {
|
export class WebrtcProvider extends Observable {
|
||||||
/**
|
/**
|
||||||
* @param {string} room
|
* @param {string} roomName
|
||||||
* @param {Y.Doc} doc
|
* @param {Y.Doc} doc
|
||||||
* @param {Object} [opts]
|
* @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()
|
super()
|
||||||
this.room = room
|
this.roomName = roomName
|
||||||
this.doc = doc
|
this.doc = doc
|
||||||
this.signallingConns = []
|
this.signalingConns = []
|
||||||
signalling.forEach(url => {
|
/**
|
||||||
const signallingConn = map.setIfUndefined(signallingConns, url, () => new SignallingConn(url))
|
* @type {PromiseLike<CryptoKey | null>}
|
||||||
this.signallingConns.push(signallingConn)
|
*/
|
||||||
signallingConn.providers.add(this)
|
this.key = password ? cryptoutils.deriveKey(password, roomName) : /** @type {PromiseLike<null>} */ (promise.resolve(null))
|
||||||
signallingConn.subscribe([this.room])
|
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}
|
* @type {awarenessProtocol.Awareness}
|
||||||
*/
|
*/
|
||||||
|
@ -294,12 +343,11 @@ export class WebrtcProvider extends Observable {
|
||||||
* @param {any} origin
|
* @param {any} origin
|
||||||
*/
|
*/
|
||||||
this._docUpdateHandler = (update, origin) => {
|
this._docUpdateHandler = (update, origin) => {
|
||||||
if (origin !== this || origin === null) {
|
if (room !== null && (origin !== this || origin === null)) {
|
||||||
const encoder = encoding.createEncoder()
|
const encoder = encoding.createEncoder()
|
||||||
encoding.writeVarUint(encoder, messageSync)
|
encoding.writeVarUint(encoder, messageSync)
|
||||||
encoding.writeVarString(encoder, room)
|
|
||||||
syncProtocol.writeUpdate(encoder, update)
|
syncProtocol.writeUpdate(encoder, update)
|
||||||
broadcast(webrtcRoom, encoder)
|
broadcastWebrtcConn(room, encoder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
@ -309,12 +357,13 @@ export class WebrtcProvider extends Observable {
|
||||||
* @param {any} origin
|
* @param {any} origin
|
||||||
*/
|
*/
|
||||||
this._awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
|
this._awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
|
||||||
|
if (room !== null) {
|
||||||
const changedClients = added.concat(updated).concat(removed)
|
const changedClients = added.concat(updated).concat(removed)
|
||||||
const encoder = encoding.createEncoder()
|
const encoder = encoding.createEncoder()
|
||||||
encoding.writeVarUint(encoder, messageAwareness)
|
encoding.writeVarUint(encoder, messageAwareness)
|
||||||
encoding.writeVarString(encoder, room)
|
|
||||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients))
|
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients))
|
||||||
broadcast(webrtcRoom, encoder)
|
broadcastWebrtcConn(room, encoder)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.doc.on('update', this._docUpdateHandler)
|
this.doc.on('update', this._docUpdateHandler)
|
||||||
this.awareness.on('change', this._awarenessUpdateHandler)
|
this.awareness.on('change', this._awarenessUpdateHandler)
|
||||||
|
@ -323,16 +372,20 @@ export class WebrtcProvider extends Observable {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
destroy () {
|
destroy () {
|
||||||
this.signallingConns.forEach(conn => {
|
super.destroy()
|
||||||
|
this.signalingConns.forEach(conn => {
|
||||||
conn.providers.delete(this)
|
conn.providers.delete(this)
|
||||||
if (conn.providers.size === 0) {
|
if (conn.providers.size === 0) {
|
||||||
conn.destroy()
|
conn.destroy()
|
||||||
signallingConns.delete(this.room)
|
signalingConns.delete(this.roomName)
|
||||||
} else {
|
} 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.doc.off('update', this._docUpdateHandler)
|
||||||
this.awareness.off('change', this._awarenessUpdateHandler)
|
this.awareness.off('change', this._awarenessUpdateHandler)
|
||||||
super.destroy()
|
super.destroy()
|
||||||
|
|
33
test/crypto.test.js
Normal file
33
test/crypto.test.js
Normal 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
18
test/index.js
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
Loading…
Reference in a new issue