UNPKG

@saltyrtc/task-webrtc

Version:
607 lines (601 loc) 23.1 kB
/** * saltyrtc-task-webrtc v0.15.0 * A SaltyRTC WebRTC task v1 implementation. * https://github.com/saltyrtc/saltyrtc-task-webrtc-js#readme * * Copyright (C) 2016-2022 Threema GmbH * * This software may be modified and distributed under the terms * of the MIT license: * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ 'use strict'; class DataChannelNonce { constructor(cookie, channelId, overflow, sequenceNumber) { this.cookie = cookie; this.overflow = overflow; this.sequenceNumber = sequenceNumber; this.channelId = channelId; } get combinedSequenceNumber() { return (this.overflow * (Math.pow(2, 32))) + this.sequenceNumber; } static fromUint8Array(data) { if (data.byteLength !== this.TOTAL_LENGTH) { throw new saltyrtcClient.exceptions.ValidationError('Bad packet length'); } const view = new DataView(data.buffer, data.byteOffset, this.TOTAL_LENGTH); const slice = new Uint8Array(data.buffer, data.byteOffset, saltyrtcClient.Cookie.COOKIE_LENGTH); const cookie = new saltyrtcClient.Cookie(slice); const channelId = view.getUint16(16); const overflow = view.getUint16(18); const sequenceNumber = view.getUint32(20); return new DataChannelNonce(cookie, channelId, overflow, sequenceNumber); } toUint8Array() { const buffer = new Uint8Array(DataChannelNonce.TOTAL_LENGTH); buffer.set(this.cookie.bytes); const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); view.setUint16(16, this.channelId); view.setUint16(18, this.overflow); view.setUint32(20, this.sequenceNumber); return buffer; } } DataChannelNonce.TOTAL_LENGTH = 24; class DataChannelCryptoContext { constructor(channelId, signaling) { this.lastIncomingCsn = null; this.channelId = channelId; this.signaling = signaling; this.cookiePair = new saltyrtcClient.CookiePair(); this.csnPair = new saltyrtcClient.CombinedSequencePair(); } encrypt(data) { const csn = this.csnPair.ours.next(); const nonce = new DataChannelNonce(this.cookiePair.ours, this.channelId, csn.overflow, csn.sequenceNumber); return this.signaling.encryptForPeer(data, nonce.toUint8Array()); } decrypt(box) { let nonce; try { nonce = DataChannelNonce.fromUint8Array(box.nonce); } catch (error) { throw new saltyrtcClient.exceptions.ValidationError(`Unable to create nonce, reason: ${error}`); } if (nonce.cookie.equals(this.cookiePair.ours)) { throw new saltyrtcClient.exceptions.ValidationError('Local and remote cookie are equal'); } if (this.cookiePair.theirs === null || this.cookiePair.theirs === undefined) { this.cookiePair.theirs = nonce.cookie; } else if (!nonce.cookie.equals(this.cookiePair.theirs)) { throw new saltyrtcClient.exceptions.ValidationError('Remote cookie changed'); } if (this.lastIncomingCsn !== null && nonce.combinedSequenceNumber === this.lastIncomingCsn) { throw new saltyrtcClient.exceptions.ValidationError('CSN reuse detected'); } if (nonce.channelId !== this.channelId) { const error = 'Data channel id in nonce does not match'; throw new saltyrtcClient.exceptions.ValidationError(error); } this.lastIncomingCsn = nonce.combinedSequenceNumber; return this.signaling.decryptFromPeer(box); } } DataChannelCryptoContext.OVERHEAD_LENGTH = 40; DataChannelCryptoContext.NONCE_LENGTH = DataChannelNonce.TOTAL_LENGTH; class SignalingTransportLink { constructor(id, protocol) { this.label = 'saltyrtc-signaling'; this.id = id; this.protocol = protocol; this.untie(); } untie() { this.closed = () => { throw new Error('closed: Not tied to a SignalingTransport'); }; this.receive = () => { throw new Error('receive: Not tied to a SignalingTransport'); }; } tie(transport) { this.closed = transport.closed.bind(transport); this.receive = transport.receiveChunk.bind(transport); } } class SignalingTransport { constructor(link, handler, task, signaling, crypto, logLevel, maxChunkLength) { this.logTag = '[SaltyRTC.WebRTC.SignalingTransport]'; this.messageId = 0; this.log = new saltyrtcClient.Log(logLevel); this.link = link; this.handler = handler; this.task = task; this.signaling = signaling; this.crypto = crypto; this.chunkLength = Math.min(this.handler.maxMessageSize, maxChunkLength); this.chunkBuffer = new ArrayBuffer(this.chunkLength); this.messageQueue = this.signaling.handoverState.peer ? null : []; this.unchunker = new chunkedDc.UnreliableUnorderedUnchunker(); this.unchunker.onMessage = this.receiveMessage.bind(this); this.link.tie(this); this.log.info(this.logTag, 'Signaling transport created'); } closed() { this.log.info('Closed (remote)'); this.unbind(); if (this.signaling.handoverState.any) { this.signaling.setState('closed'); } } receiveChunk(chunk) { this.log.debug(this.logTag, 'Received chunk'); try { this.unchunker.add(chunk); } catch (error) { this.log.error(this.logTag, 'Invalid chunk:', error); return this.die(); } } receiveMessage(message) { this.log.debug(this.logTag, 'Received message'); const box = saltyrtcClient.Box.fromUint8Array(message, DataChannelCryptoContext.NONCE_LENGTH); try { message = this.crypto.decrypt(box); } catch (error) { this.log.error(this.logTag, 'Invalid nonce:', error); return this.die(); } if (!this.signaling.handoverState.peer) { this.messageQueue.push(message); return; } this.signaling.onSignalingPeerMessage(message); } flushMessageQueue() { if (!this.signaling.handoverState.peer) { throw new Error('Remote did not request handover'); } for (const message of this.messageQueue) { this.signaling.onSignalingPeerMessage(message); } this.messageQueue = null; } send(message) { this.log.debug(this.logTag, 'Sending message'); const box = this.crypto.encrypt(message); message = box.toUint8Array(); const chunker = new chunkedDc.UnreliableUnorderedChunker(this.messageId++, message, this.chunkLength, this.chunkBuffer); for (let chunk of chunker) { this.log.debug(this.logTag, 'Sending chunk'); try { this.handler.send(chunk); } catch (error) { this.log.error(this.logTag, 'Unable to send chunk:', error); return this.die(); } } } close() { try { this.handler.close(); } catch (error) { this.log.error(this.logTag, 'Unable to close data channel:', error); } this.log.info('Closed (local)'); this.unbind(); } die() { this.log.warn(this.logTag, 'Closing task due to an error'); this.task.close(saltyrtcClient.CloseCode.ProtocolError); } unbind() { this.link.untie(); this.unchunker.onMessage = undefined; } } class WebRTCTaskBuilder { constructor() { this.version = 'v1'; this.logLevel = 'none'; this.handover = true; this.maxChunkLength = 262144; } withLoggingLevel(level) { this.logLevel = level; return this; } withVersion(version) { this.version = version; return this; } withHandover(on) { this.handover = on; return this; } withMaxChunkLength(length) { if (length <= chunkedDc.UNRELIABLE_UNORDERED_HEADER_LENGTH) { throw new Error('Maximum chunk length must be greater than chunking overhead'); } this.maxChunkLength = length; return this; } build() { return new WebRTCTask(this.version, this.logLevel, this.handover, this.maxChunkLength); } } class WebRTCTask { constructor(version, logLevel, handover, maxChunkLength) { this.logTag = '[SaltyRTC.WebRTC]'; this.initialized = false; this.exclude = new Set(); this.link = null; this.transport = null; this.eventRegistry = new saltyrtcClient.EventRegistry(); this.candidates = []; this.sendCandidatesTimeout = null; this.version = version; this.log = new saltyrtcClient.Log(logLevel); this.doHandover = handover; this.maxChunkLength = maxChunkLength; } set signaling(signaling) { this._signaling = signaling; this.logTag = '[SaltyRTC.WebRTC.' + signaling.role + ']'; } get signaling() { return this._signaling; } init(signaling, data) { this.processExcludeList(data[WebRTCTask.FIELD_EXCLUDE]); this.processHandover(data[WebRTCTask.FIELD_HANDOVER]); if (this.version === 'v0') { this.processMaxPacketSize(data[WebRTCTask.FIELD_MAX_PACKET_SIZE]); } this.signaling = signaling; this.initialized = true; } processExcludeList(ids) { for (const id of ids) { this.exclude.add(id); } for (let i = 0; i < 65535; i++) { if (!this.exclude.has(i)) { this.channelId = i; break; } } if (this.channelId === undefined && this.doHandover) { throw new Error('No free data channel id can be found'); } } processHandover(handover) { if (handover === false) { this.doHandover = false; } } processMaxPacketSize(remoteMaxPacketSize) { const localMaxPacketSize = this.maxChunkLength; if (!Number.isInteger(remoteMaxPacketSize)) { throw new RangeError(WebRTCTask.FIELD_MAX_PACKET_SIZE + ' field must be an integer'); } if (remoteMaxPacketSize < 0) { throw new RangeError(WebRTCTask.FIELD_MAX_PACKET_SIZE + ' field must be positive'); } else if (remoteMaxPacketSize > 0) { this.maxChunkLength = Math.min(localMaxPacketSize, remoteMaxPacketSize); } this.log.debug(this.logTag, `Max packet size: Local requested ${localMaxPacketSize}` + ` bytes, remote requested ${remoteMaxPacketSize} bytes. Using ${this.maxChunkLength}.`); } onPeerHandshakeDone() { } onDisconnected(id) { this.emit({ type: 'disconnected', data: id }); } onTaskMessage(message) { this.log.debug(this.logTag, 'New task message arrived: ' + message.type); switch (message.type) { case 'offer': if (this.validateOffer(message) !== true) return; this.emit({ type: 'offer', data: message['offer'] }); break; case 'answer': if (this.validateAnswer(message) !== true) return; this.emit({ type: 'answer', data: message['answer'] }); break; case 'candidates': if (this.validateCandidates(message) !== true) return; this.emit({ type: 'candidates', data: message['candidates'] }); break; case 'handover': if (!this.doHandover) { this.log.error(this.logTag, 'Received unexpected handover message from peer'); this.signaling.resetConnection(saltyrtcClient.CloseCode.ProtocolError); break; } if (this.signaling.handoverState.peer) { this.log.warn(this.logTag, 'Handover already received'); break; } this.signaling.handoverState.peer = true; if (this.transport !== null) { this.transport.flushMessageQueue(); } if (this.signaling.handoverState.both) { this.log.info(this.logTag, 'Handover to data channel finished'); } break; default: this.log.error(this.logTag, 'Received message with unknown type:', message.type); } } validateOffer(message) { if (message['offer'] === undefined) { this.log.warn(this.logTag, 'Offer message does not contain offer'); return false; } if (message['offer']['sdp'] === undefined) { this.log.warn(this.logTag, 'Offer message does not contain offer sdp'); return false; } return true; } validateAnswer(message) { if (message['answer'] === undefined) { this.log.warn(this.logTag, 'Answer message does not contain answer'); return false; } if (message['answer']['sdp'] === undefined) { this.log.warn(this.logTag, 'Answer message does not contain answer sdp'); return false; } return true; } validateCandidates(message) { if (message['candidates'] === undefined) { this.log.warn(this.logTag, 'Candidates message does not contain candidates'); return false; } if (message['candidates'].length < 1) { this.log.warn(this.logTag, 'Candidates message contains empty candidate list'); return false; } for (let candidate of message['candidates']) { if (candidate !== null) { if (typeof candidate['candidate'] !== 'string' && !(candidate['candidate'] instanceof String)) { this.log.warn(this.logTag, 'Candidates message contains invalid candidate (candidate field)'); return false; } if (typeof candidate['sdpMid'] !== 'string' && !(candidate['sdpMid'] instanceof String) && candidate['sdpMid'] !== null) { this.log.warn(this.logTag, 'Candidates message contains invalid candidate (sdpMid field)'); return false; } if (candidate['sdpMLineIndex'] !== null && !Number.isInteger(candidate['sdpMLineIndex'])) { this.log.warn(this.logTag, 'Candidates message contains invalid candidate (sdpMLineIndex field)'); return false; } } } return true; } sendSignalingMessage(payload) { if (this.signaling.getState() != 'task') { throw new saltyrtcClient.SignalingError(saltyrtcClient.CloseCode.ProtocolError, "Could not send signaling message: Signaling state is not 'task'."); } if (!this.signaling.handoverState.local) { throw new saltyrtcClient.SignalingError(saltyrtcClient.CloseCode.ProtocolError, "Could not send signaling message: Handover hasn't happened yet."); } if (this.transport === null) { throw new saltyrtcClient.SignalingError(saltyrtcClient.CloseCode.ProtocolError, 'Could not send signaling message: Data channel is not established, yet.'); } this.transport.send(payload); } getName() { return `${this.version}.webrtc.tasks.saltyrtc.org`; } getSupportedMessageTypes() { return ['offer', 'answer', 'candidates', 'handover']; } getData() { const data = {}; data[WebRTCTask.FIELD_EXCLUDE] = Array.from(this.exclude.values()); data[WebRTCTask.FIELD_HANDOVER] = this.doHandover; if (this.version === 'v0') { data[WebRTCTask.FIELD_MAX_PACKET_SIZE] = this.maxChunkLength; } return data; } sendOffer(offer) { this.log.debug(this.logTag, 'Sending offer'); try { this.signaling.sendTaskMessage({ 'type': 'offer', 'offer': { 'type': offer.type, 'sdp': offer.sdp, } }); } catch (e) { if (e.name === 'SignalingError') { this.log.error(this.logTag, 'Could not send offer:', e.message); this.signaling.resetConnection(e.closeCode); } } } sendAnswer(answer) { this.log.debug(this.logTag, 'Sending answer'); try { this.signaling.sendTaskMessage({ 'type': 'answer', 'answer': { 'type': answer.type, 'sdp': answer.sdp, } }); } catch (e) { if (e.name === 'SignalingError') { this.log.error(this.logTag, 'Could not send answer:', e.message); this.signaling.resetConnection(e.closeCode); } } } sendCandidate(candidate) { this.sendCandidates([candidate]); } sendCandidates(candidates) { this.log.debug(this.logTag, 'Buffering', candidates.length, 'candidate(s)'); this.candidates.push(...candidates); const sendFunc = () => { try { this.log.debug(this.logTag, 'Sending', this.candidates.length, 'candidate(s)'); this.signaling.sendTaskMessage({ 'type': 'candidates', 'candidates': this.candidates }); } catch (e) { if (e.name === 'SignalingError') { this.log.error(this.logTag, 'Could not send candidates:', e.message); this.signaling.resetConnection(e.closeCode); } } finally { this.candidates = []; this.sendCandidatesTimeout = null; } }; if (this.sendCandidatesTimeout === null) { this.sendCandidatesTimeout = self.setTimeout(sendFunc, WebRTCTask.CANDIDATE_BUFFERING_MS); } } getTransportLink() { this.log.debug(this.logTag, 'Create signalling transport link'); if (!this.doHandover) { throw new Error('Handover has not been negotiated'); } if (this.channelId === undefined) { const error = 'Data channel id not set'; throw new Error(error); } if (this.link === null) { this.link = new SignalingTransportLink(this.channelId, this.getName()); } return this.link; } handover(handler) { this.log.debug(this.logTag, 'Initiate handover'); if (!this.doHandover) { throw new Error('Handover has not been negotiated'); } if (this.signaling.handoverState.local || this.transport !== null) { throw new Error('Handover already requested'); } const crypto = this.createCryptoContext(this.channelId); this.transport = new SignalingTransport(this.link, handler, this, this.signaling, crypto, this.log.level, this.maxChunkLength); this.sendHandover(); } sendHandover() { this.log.debug(this.logTag, 'Sending handover'); try { this.signaling.sendTaskMessage({ 'type': 'handover' }); } catch (e) { if (e.name === 'SignalingError') { this.log.error(this.logTag, 'Could not send handover message', e.message); this.signaling.resetConnection(e.closeCode); } } this.signaling.handoverState.local = true; if (this.signaling.handoverState.both) { this.log.info(this.logTag, 'Handover to data channel finished'); } } createCryptoContext(channelId) { return new DataChannelCryptoContext(channelId, this.signaling); } close(reason) { this.log.debug(this.logTag, 'Closing signaling data channel:', saltyrtcClient.explainCloseCode(reason)); if (this.transport !== null) { this.transport.close(); } this.transport = null; } on(event, handler) { this.eventRegistry.register(event, handler); } once(event, handler) { const onceHandler = (ev) => { try { handler(ev); } catch (e) { this.off(ev.type, onceHandler); throw e; } this.off(ev.type, onceHandler); }; this.eventRegistry.register(event, onceHandler); } off(event, handler) { if (event === undefined) { this.eventRegistry.unregisterAll(); } else { this.eventRegistry.unregister(event, handler); } } emit(event) { this.log.debug(this.logTag, 'New event:', event.type); const handlers = this.eventRegistry.get(event.type); for (let handler of handlers) { try { this.callHandler(handler, event); } catch (e) { this.log.error(this.logTag, 'Unhandled exception in', event.type, 'handler:', e); } } } callHandler(handler, event) { const response = handler(event); if (response === false) { this.eventRegistry.unregister(event.type, handler); } } } WebRTCTask.FIELD_EXCLUDE = 'exclude'; WebRTCTask.FIELD_HANDOVER = 'handover'; WebRTCTask.FIELD_MAX_PACKET_SIZE = 'max_packet_size'; WebRTCTask.CANDIDATE_BUFFERING_MS = 5; export { DataChannelCryptoContext, WebRTCTaskBuilder }; //# sourceMappingURL=saltyrtc-task-webrtc.es2015.js.map