├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .gitmodules ├── .nvmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── RELEASING.md ├── dist ├── saltyrtc-client.es2015.js ├── saltyrtc-client.es5.js └── saltyrtc-client.es5.min.js ├── docs ├── .gitignore ├── .travis.yml ├── docs │ ├── about.md │ ├── img │ │ └── favicon.ico │ ├── index.md │ ├── installing.md │ └── usage.md ├── mkdocs.yml └── requirements.txt ├── karma.conf.js ├── package-lock.json ├── package.json ├── rollup ├── es2015.js ├── es5.js ├── es5.min.js ├── performance.js └── testing.js ├── saltyrtc-client.d.ts ├── src ├── client.ts ├── closecode.ts ├── cookie.ts ├── csn.ts ├── eventregistry.ts ├── exceptions.ts ├── keystore.ts ├── log.ts ├── main.es5.ts ├── main.ts ├── nonce.ts ├── peers.ts ├── signaling.ts ├── signaling │ ├── common.ts │ ├── handoverstate.ts │ ├── helpers.ts │ ├── initiator.ts │ └── responder.ts └── utils.ts ├── tests ├── client.spec.ts ├── config.ts ├── cookie.spec.ts ├── csn.spec.ts ├── eventregistry.spec.ts ├── handoverstate.spec.ts ├── integration.spec.ts ├── jasmine.d.ts ├── keystore.spec.ts ├── main.ts ├── nonce.spec.ts ├── performance.html ├── performance.ts ├── performance │ ├── crypto.spec.ts │ ├── crypto.worker.js │ └── utils.ts ├── testsuite.html ├── testtasks.ts ├── utils.spec.ts └── utils.ts ├── tsconfig.json └── tslint.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | 4 | shared: 5 | lint: &lint-config 6 | steps: 7 | - checkout 8 | 9 | # Install dependencies 10 | - run: npm install 11 | 12 | # Run linter 13 | - run: 14 | name: Run linter 15 | command: npm run lint 16 | 17 | test: &test-config 18 | steps: 19 | - checkout 20 | 21 | # Install dependencies 22 | - run: npm install 23 | 24 | # Start SaltyRTC server 25 | - run: saltyrtc-server-launcher > /saltyrtc/server.pid && sleep 2 26 | 27 | # Show browser version 28 | - run: if which firefox >/dev/null; then firefox --version; fi 29 | - run: if which chrome >/dev/null; then chrome --version; fi 30 | - run: if which chromium >/dev/null; then chromium --version; fi 31 | 32 | # Run tests 33 | - run: 34 | name: Run tests 35 | command: npm run rollup_tests && npm test -- --browsers $BROWSER 36 | - run: 37 | name: Run type checks 38 | command: node_modules/.bin/tsc --noEmit 39 | 40 | # Stop SaltyRTC server 41 | - run: kill -INT $(cat /saltyrtc/server.pid) 42 | 43 | 44 | jobs: 45 | lint: 46 | <<: *lint-config 47 | docker: 48 | - image: circleci/node:16-browsers 49 | 50 | test-chromium-latest: 51 | <<: *test-config 52 | docker: 53 | - image: saltyrtc/circleci-image-js:chromium-latest 54 | environment: 55 | - BROWSER: ChromiumHeadless 56 | 57 | test-firefox-stable: 58 | <<: *test-config 59 | docker: 60 | - image: saltyrtc/circleci-image-js:firefox-97 61 | environment: 62 | BROWSER: Firefox_circle_ci 63 | FIREFOX_BIN: xvfb-firefox 64 | 65 | test-firefox-esr: 66 | <<: *test-config 67 | docker: 68 | - image: saltyrtc/circleci-image-js:firefox-91esr 69 | environment: 70 | BROWSER: Firefox_circle_ci 71 | FIREFOX_BIN: xvfb-firefox 72 | 73 | 74 | workflows: 75 | version: 2 76 | build: 77 | jobs: 78 | - lint 79 | - test-chromium-latest 80 | - test-firefox-stable 81 | - test-firefox-esr 82 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | [*.{js,ts,json,scss,sh}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.yml] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | node_modules/ 3 | npm-debug.log 4 | tests/testsuite.js* 5 | tests/performance.js* 6 | apidocs/ 7 | saltyrtc.crt 8 | saltyrtc.csr 9 | saltyrtc.key 10 | saltyrtc-server-python/ 11 | .idea/ 12 | .rgignore 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saltyrtc/saltyrtc-client-js/7b8413b2e053172f2db379dab6c895e5327cc001/.gitmodules -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project follows semantic versioning. 4 | 5 | Possible log types: 6 | 7 | - `[added]` for new features. 8 | - `[changed]` for changes in existing functionality. 9 | - `[deprecated]` for once-stable features removed in upcoming releases. 10 | - `[removed]` for deprecated features removed in this release. 11 | - `[fixed]` for any bug fixes. 12 | - `[security]` to invite users to upgrade in case of vulnerabilities. 13 | 14 | 15 | ### v0.15.1 (2022-03-22) 16 | 17 | - [fixed] Change exported CloseCode from const enum to class (#138) 18 | 19 | ### v0.15.0 (2022-02-11) 20 | 21 | - [fixed] Prevent client reuse (#123) 22 | - [fixed] Validate length of server public key hexstring (#122) 23 | - [removed] Removed the polyfilled ES5 bundle from the distribution files 24 | 25 | ### v0.14.4 (2019-06-12) 26 | 27 | - [fixed] Avoid protocol errors when handling 'new-initiator' and 28 | 'new-responder' messages 29 | 30 | ### v0.14.3 (2019-02-28) 31 | 32 | - [fixed] Signature of `KeyStore`'s constructor was incorrect 33 | 34 | ### v0.14.2 (2019-02-25) 35 | 36 | - [fixed] Actually expose all exception classes 37 | 38 | ### v0.14.1 (2019-02-25) 39 | 40 | - [added] Expose all exception classes 41 | 42 | ### v0.14.0 (2019-02-18) 43 | 44 | - [changed] Use `Uint8Array` instead of `ArrayBuffer` in the public API 45 | - [removed] Removed obsolete methods `Cookie.asArrayBuffer` and 46 | `Cookie.fromArrayBuffer` 47 | 48 | ### v0.13.2 (2018-10-04) 49 | 50 | - [fixed] Exposed `Log.level` attribute 51 | 52 | ### v0.13.1 (2018-10-04) 53 | 54 | - [fixed] Exposed `Log` class 55 | 56 | ### v0.13.0 (2018-09-27) 57 | 58 | - [added] Add possibility to unbind all events when disconnecting 59 | - [added] Introduce log level to builder 60 | 61 | ### v0.12.4 (2018-08-21) 62 | 63 | - [fixed] Updated type declarations 64 | 65 | ### v0.12.3 (2018-08-21) 66 | 67 | - [added] Allow clearing all event handlers at once (#106) 68 | 69 | ### v0.12.2 (2018-07-31) 70 | 71 | - [added] Expose encrypt/decrypt methods on signaling instance (#105) 72 | 73 | ### v0.12.1 (2018-07-26) 74 | 75 | - [security] Fix bug in CSN calculation (#103) 76 | - [fixed] Add `SaltyRTC.getCurrentPeerCsn` to type declarations 77 | 78 | **Security Fix** 79 | 80 | [#103](https://github.com/saltyrtc/saltyrtc-client-js/pull/103) 81 | 82 | Apparently JavaScript treats all operands in bitwise operations as 32 bit 83 | signed integers. This results in `(1 << 32)` being equal to `1`. This means 84 | that previously the calculation of the combined sequence number would be 85 | incorrect if the overflow number is larger than 0. 86 | 87 | In theory this is a security issue, however it may only be a problem in the 88 | real world if you send more than 4'294'967'295 messages with the same 89 | connection, which is quite unlikely. However, we definitely recommend upgrading 90 | to the latest version of `@saltyrtc/client`. 91 | 92 | ### v0.12.0 (2018-07-25) 93 | 94 | - [added] Introduce method to extract current CSN 95 | - [changed] Replace thrown strings with exceptions (#97) 96 | - [changed] Crypto performance improvements (#99) 97 | - [changed] Upgrade npm dependencies (#100) 98 | 99 | ### v0.11.3 (2018-05-17) 100 | 101 | - [added] Emit 'no-shared-task' event when no shared task could be found (#93) 102 | 103 | ### v0.11.2 (2018-05-08) 104 | 105 | - [fixed] Handle disconnected messages during peer handshake 106 | 107 | ### v0.11.1 (2018-05-03) 108 | 109 | - [changed] 'disconnected' messages are now emitted as events to the user, 110 | not as callback to the task (#92) 111 | - [fixed] Fix processing of 'disconnected' messages 112 | - [fixed] Accept server messages during/after peer handshake 113 | - [fixed] If message nonce has an invalid source, discard it 114 | 115 | ### v0.11.0 (2018-03-13) 116 | 117 | - [fixed] SaltyRTC.authTokenHex: Add null checks 118 | - [added] Support for 'disconnected' messages (#89) 119 | - [changed] `Task` interface: Add `onDisconnected` method (#90) 120 | - [changed] Only pass task messages to task if supported 121 | - [changed] Add tslint to the codebase (#88) 122 | 123 | ### v0.10.1 (2018-02-28) 124 | 125 | - [changed] Upgrade TypeScript to 2.7, make some types more specific 126 | - [removed] Remove deprecated `InternalError` function 127 | 128 | ### v0.10.0 (2017-09-26) 129 | 130 | - [fixed] Fix type signature in SaltyRTC.asResponder 131 | - [changed] Upgrade tweetnacl to 1.0.0 132 | - [changed] Move npmjs.org package to organization (it's now called 133 | `@saltyrtc/client`, not `saltyrtc-client`) 134 | - [changed] Update docs 135 | 136 | ### v0.9.1 (2017-02-13) 137 | 138 | - [changed] Updated logging format 139 | 140 | ### v0.9.0 (2017-02-07) 141 | 142 | This release can be considered a release candidate for 1.0.0. 143 | 144 | - [changed] Change subprotocol to `v1.saltyrtc.org` (#59) 145 | - [changed] The `KeyStore` class constructor now only requires the private key, 146 | not both the public and private key (#73) 147 | - [added] Add new close code: 3007 Invalid Key (#58) 148 | - [added] Add support for multiple server permanent keys (#58) 149 | - [changed] Better error logs in the case of signaling errors (#78) 150 | 151 | ### v0.5.1 (2016-12-13) 152 | 153 | - [changed] Make tweetnacl / msgpack-lite peer dependencies 154 | 155 | ### v0.5.0 (2016-12-12) 156 | 157 | - [added] Implement dynamic server endpoints (#70) 158 | - [fixed] Never explicitly close WebSocket with 1002 (#75) 159 | - [fixed] Send close message on disconnect in task state (#68) 160 | - [fixed] Catch nonce validation errors 161 | - [fixed] Only re-throw top level exceptions if unhandled 162 | - [fixed] Don't use decryptFromPeer method in onSignalingMessage 163 | - [changed] Remove restart message handling (#69) 164 | 165 | ### v0.4.1 (2016-11-14) 166 | 167 | - [added] Implement support for application messages (#61) 168 | - [fixed] Set state to "closing" when starting disconnect 169 | - [fixed] Fix inverted condition when handling signaling errors 170 | 171 | ### v0.4.0 (2016-11-09) 172 | 173 | - [added] Support passing server public key to SaltyRTCBuilder (#59) 174 | - [added] Implement support for send-error messages (#14) 175 | - [added] Drop inactive responders (#55) 176 | - [fixed] Always emit connection-closed event on websocket close 177 | - [fixed] Properly handle protocol/signaling errors (#53) 178 | - [fixed] Don't allow calling both `.initiatorInfo` and `.asInitiator` on `SaltyRTCBuilder` 179 | 180 | ### v0.3.1 (2016-11-07) 181 | 182 | - [fixed] Send signaling messages to the task without encrypting (#58) 183 | - [fixed] Close websocket after handshake (#57) 184 | 185 | ### v0.3.0 (2016-11-02) 186 | 187 | - [added] The `KeyStore` and `SaltyRTCBuilder` interfaces now accept hex strings as keys 188 | - [added] The `SaltyRTCBuilder` now supports the `withPingInterval(...)` method 189 | - [added] Notify client on all disconnects 190 | - [changed] The connection-closed event now has the reason code as payload 191 | - [changed] Many refactorings 192 | 193 | ### v0.2.7 (2016-10-20) 194 | 195 | - [added] Add HandoverState helper class 196 | - [fixed] Check peer handover state when receiving ws message 197 | 198 | ### v0.2.6 (2016-10-19) 199 | 200 | - [fixed] Extend type declarations with missing static types 201 | - [changed] Change iife dist namespace to `saltyrtcClient` 202 | 203 | ### v0.2.5 (2016-10-19) 204 | 205 | - [fixed] Fix filename of polyfilled dist file 206 | 207 | ### v0.2.4 (2016-10-18) 208 | 209 | - [fixed] Use interface types for KeyStore and AuthToken 210 | - [fixed] Fix path to ES6 polyfill 211 | 212 | ### v0.2.3 (2016-10-18) 213 | 214 | - [fixed] Use interface types in SaltyRTCBuilder 215 | - [changed] Move type declarations to root directory 216 | 217 | ### v0.2.2 (2016-10-18) 218 | 219 | - [fixed] Fix sending of signaling messages after handshake 220 | - [added] Expose close codes and exceptions 221 | 222 | ### v0.2.1 (2016-10-17) 223 | 224 | - [added] Make saltyrtc.messages.TaskMessage an open interface 225 | 226 | ### v0.2.0 (2016-10-17) 227 | 228 | - [changed] Rename saltyrtc/ directory to src/ 229 | 230 | ### v0.1.9 (2016-10-17) 231 | 232 | - [added] Add "typings" field to package.json 233 | 234 | ### v0.1.8 (2016-10-17) 235 | 236 | - [changed] Make polyfills in ES5 distribution optional 237 | 238 | ### v0.1.7 (2016-10-13) 239 | 240 | - [changed] Build ES2015 version as ES module, not as IIFE 241 | 242 | ### v0.1.6 (2016-10-13) 243 | 244 | - [changed] Improved packaging 245 | 246 | ### v0.1.5 (2016-10-13) 247 | 248 | - [changed] Internal cleanup 249 | 250 | ### v0.1.4 (2016-10-13) 251 | 252 | - [fixed] Fix exposed classes in `main.ts` 253 | 254 | ### v0.1.3 (2016-10-13) 255 | 256 | - [added] Create `CombinedSequencePair` class 257 | 258 | ### v0.1.2 (2016-10-13) 259 | 260 | - [added] Expose `Cookie` and `CookiePair` classes 261 | - [added] Expose `CombinedSequence` class 262 | 263 | ### v0.1.1 (2016-10-12) 264 | 265 | - [added] Expose `EventRegistry` class 266 | 267 | ### v0.1.0 (2016-10-12) 268 | 269 | - Initial release 270 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors Guideline 2 | 3 | Thanks a lot for any contribution! 4 | 5 | To keep code quality high and maintenance work low, please adhere to the 6 | following guidelines when creating a pull request: 7 | 8 | ## Style Guide 9 | 10 | Try to write clean code and to adhere to the already used coding style. 11 | 12 | Lines should be kept below 120 characters. 13 | 14 | ## Documentation 15 | 16 | All new code should be properly documented (see `docs/` directory). 17 | 18 | ## Tests 19 | 20 | If possible, new code should be covered by automated tests. 21 | 22 | ## Commit messages 23 | 24 | Use meaningful commit messages: Please follow the advice in [this 25 | blogpost](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 26 | First line of your commit message should be a very short summary (ideally 50 27 | characters or less) in the imperative mood. After the first line of the commit 28 | message, add a blank line and then a more detailed explanation (when relevant). 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2022 Threema GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaltyRTC JavaScript Client 2 | 3 | [![CircleCI](https://circleci.com/gh/saltyrtc/saltyrtc-client-js/tree/master.svg?style=shield)](https://circleci.com/gh/saltyrtc/saltyrtc-client-js/tree/master) 4 | [![Supported ES Standard](https://img.shields.io/badge/javascript-ES5%20%2F%20ES2015-yellow.svg)](https://github.com/saltyrtc/saltyrtc-client-js) 5 | [![npm Version](https://img.shields.io/npm/v/@saltyrtc/client.svg?maxAge=2592000)](https://www.npmjs.com/package/@saltyrtc/client) 6 | [![npm Downloads](https://img.shields.io/npm/dt/@saltyrtc/client.svg?maxAge=3600)](https://www.npmjs.com/package/@saltyrtc/client) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/saltyrtc/saltyrtc-client-js) 8 | [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/536/badge)](https://bestpractices.coreinfrastructure.org/projects/536) 9 | [![Chat on Gitter](https://badges.gitter.im/saltyrtc/Lobby.svg)](https://gitter.im/saltyrtc/Lobby) 10 | 11 | This is a [SaltyRTC](https://github.com/saltyrtc/saltyrtc-meta) v1 12 | implementation for JavaScript (ES5+) written in TypeScript. 13 | 14 | > :warning: **Note:** The SaltyRTC client libraries are in maintenance mode. 15 | > They will still receive bugfixes and regular maintenance, but if you want to 16 | > start using these libraries, be prepared that you will need to take over 17 | > maintenance at some point in time. (If you are interested in maintaining the 18 | > libraries, please let us know, our e-mails are in the README, section 19 | > "Security".) 20 | 21 | The library has been tested with Firefox 45+ and Chromium 49+. 22 | 23 | - [Docs](https://saltyrtc.github.io/saltyrtc-client-js/docs/) 24 | - [API Docs](https://saltyrtc.github.io/saltyrtc-client-js/apidocs/) 25 | 26 | ## Installing 27 | 28 | ### Via npm 29 | 30 | You can install this library via `npm`: 31 | 32 | npm install --save @saltyrtc/client msgpack-lite tweetnacl 33 | 34 | ### Manually 35 | 36 | Alternatively, copy one of the following files to your project directly: 37 | 38 | - ES2015: `dist/saltyrtc-client.es2015.js` 39 | - ES5: `dist/saltyrtc-client.es5.js` 40 | - ES5 minified: `dist/saltyrtc-client.es5.min.js` 41 | 42 | Make sure to manually add the following external dependencies to your project: 43 | 44 | - [tweetnacl](https://github.com/dchest/tweetnacl-js) 45 | - [msgpack-lite](https://github.com/kawanet/msgpack-lite) 46 | 47 | ## Usage 48 | 49 | See [Docs](https://saltyrtc.github.io/saltyrtc-client-js/docs/). 50 | 51 | ## Development 52 | 53 | Install dependencies: 54 | 55 | $ npm install 56 | 57 | To compile the TypeScript sources to a single JavaScript (ES5 / Minified ES5 / ES2015) file: 58 | 59 | $ npm run dist 60 | 61 | The resulting files will be located in `dist/`. 62 | 63 | ## Testing 64 | 65 | ### 1. Preparing the Server 66 | 67 | First, clone the `saltyrtc-server-python` repository. 68 | 69 | git clone https://github.com/saltyrtc/saltyrtc-server-python 70 | cd saltyrtc-server-python 71 | 72 | Then create a test certificate for localhost, valid for 5 years. 73 | 74 | openssl req \ 75 | -newkey rsa:1024 \ 76 | -x509 \ 77 | -nodes \ 78 | -keyout saltyrtc.key \ 79 | -new \ 80 | -out saltyrtc.crt \ 81 | -subj /CN=localhost \ 82 | -reqexts SAN \ 83 | -extensions SAN \ 84 | -config <(cat /etc/ssl/openssl.cnf \ 85 | <(printf '[SAN]\nsubjectAltName=DNS:localhost')) \ 86 | -sha256 \ 87 | -days 1825 88 | 89 | You can import this file into your browser certificate store. For Chrome/Chromium, use this command: 90 | 91 | certutil -d sql:$HOME/.pki/nssdb -A -t "P,," -n saltyrtc-test-ca -i saltyrtc.crt 92 | 93 | Additionally, you need to open `chrome://flags/#allow-insecure-localhost` and 94 | enable it. 95 | 96 | In Firefox the easiest way to add your certificate to the browser is to start 97 | the SaltyRTC server (e.g. on `localhost` port 8765), then to visit the 98 | corresponding URL via https (e.g. `https://localhost:8765`). Then, in the 99 | certificate warning dialog that pops up, choose "Advanced" and add a permanent 100 | exception. 101 | 102 | Create a Python virtualenv with dependencies: 103 | 104 | python3 -m virtualenv venv 105 | venv/bin/pip install .[logging] 106 | 107 | Finally, start the server with the following test permanent key: 108 | 109 | export SALTYRTC_SERVER_PERMANENT_KEY=0919b266ce1855419e4066fc076b39855e728768e3afa773105edd2e37037c20 # Public: 09a59a5fa6b45cb07638a3a6e347ce563a948b756fd22f9527465f7c79c2a864 110 | venv/bin/saltyrtc-server -v 5 serve -p 8765 \ 111 | -sc saltyrtc.crt -sk saltyrtc.key \ 112 | -k $SALTYRTC_SERVER_PERMANENT_KEY 113 | 114 | 115 | ### 2. Running Tests 116 | 117 | To compile the test sources, run: 118 | 119 | $ npm run rollup_tests 120 | 121 | Then simply open `tests/testsuite.html` in your browser! 122 | 123 | Alternatively, run the tests automatically in Firefox and Chrome: 124 | 125 | $ npm test 126 | 127 | 128 | ### 3. Linting 129 | 130 | To run linting checks: 131 | 132 | npm run lint 133 | 134 | You can also install a pre-push hook to do the linting: 135 | 136 | echo -e '#!/bin/sh\nnpm run lint' > .git/hooks/pre-push 137 | chmod +x .git/hooks/pre-push 138 | 139 | 140 | ## Security 141 | 142 | ### Responsible Disclosure / Reporting Security Issues 143 | 144 | Please report security issues directly to one or both of the following contacts: 145 | 146 | - Danilo Bargen 147 | - Email: mail@dbrgn.ch 148 | - Threema: EBEP4UCA 149 | - GPG: [EA456E8BAF0109429583EED83578F667F2F3A5FA][keybase-dbrgn] 150 | - Lennart Grahl 151 | - Email: lennart.grahl@gmail.com 152 | - Threema: MSFVEW6C 153 | - GPG: [3FDB14868A2B36D638F3C495F98FBED10482ABA6][keybase-lgrahl] 154 | 155 | [keybase-dbrgn]: https://keybase.io/dbrgn 156 | [keybase-lgrahl]: https://keybase.io/lgrahl 157 | 158 | ## Coding Guidelines 159 | 160 | - Write clean ES2015 161 | - Favor `const` over `let` 162 | 163 | ## License 164 | 165 | MIT, see `LICENSE.md`. 166 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | First of all, have you updated the type declarations? 4 | 5 | Set variables: 6 | 7 | $ export VERSION=X.Y.Z 8 | $ export GPG_KEY=E7ADD9914E260E8B35DFB50665FDE935573ACDA6 9 | 10 | Update version numbers: 11 | 12 | $ vim -p package.json CHANGELOG.md 13 | $ npm install 14 | 15 | Build dist files: 16 | 17 | $ npm run dist 18 | 19 | Commit & tag: 20 | 21 | $ git commit -S${GPG_KEY} -m "Release v${VERSION}" 22 | $ git tag -s -u ${GPG_KEY} v${VERSION} -m "Version ${VERSION}" 23 | 24 | Push & publish: 25 | 26 | $ git push && git push --tags 27 | $ npm publish 28 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | site/ 3 | venv/ 4 | VENV/ 5 | -------------------------------------------------------------------------------- /docs/.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: java 4 | matrix: 5 | include: 6 | #- jdk: openjdk7 # disable until https://github.com/travis-ci/travis-ci/issues/5227 is fixed 7 | - jdk: oraclejdk7 8 | env: 9 | EXECUTE_BUILD_DOCS=true 10 | - jdk: oraclejdk8 11 | before_cache: 12 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 13 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 14 | cache: 15 | directories: 16 | - $HOME/.gradle/caches/ 17 | - $HOME/.gradle/wrapper/ 18 | before_script: 19 | # Show OS 20 | - cat /etc/os-release 21 | - python3 --version 22 | # Libsodium 23 | - sudo add-apt-repository -y ppa:chris-lea/libsodium 24 | - sudo apt-get update && sudo apt-get install -y libsodium-dev 25 | # Create test certificate for localhost 26 | - openssl req -new -newkey rsa:1024 -nodes -sha256 -out saltyrtc.csr -keyout saltyrtc.key -subj '/C=CH/O=SaltyRTC/CN=localhost/' 27 | - openssl x509 -req -days 365 -in saltyrtc.csr -signkey saltyrtc.key -out saltyrtc.crt 28 | - keytool -import -trustcacerts -alias root -file saltyrtc.crt -keystore saltyrtc.jks -storetype JKS -storepass saltyrtc -noprompt 29 | # Start SaltyRTC server 30 | - git clone https://github.com/saltyrtc/saltyrtc-server-python -b master 31 | - export SALTYRTC_SERVER_PERMANENT_KEY=0919b266ce1855419e4066fc076b39855e728768e3afa773105edd2e37037c20 # Public: 09a59a5fa6b45cb07638a3a6e347ce563a948b756fd22f9527465f7c79c2a864 32 | - | 33 | cd saltyrtc-server-python 34 | pyvenv venv 35 | venv/bin/pip install .[logging] 36 | venv/bin/saltyrtc-server -v 5 serve -sc ../saltyrtc.crt -sk ../saltyrtc.key -p 8765 -k $SALTYRTC_SERVER_PERMANENT_KEY > serverlog.txt 2>&1 & 37 | export SALTYRTC_SERVER_PID=$! 38 | sleep 2 39 | cd .. 40 | # Enable debug in integration tests 41 | - sed -i 's/DEBUG = false/DEBUG = true/' src/test/java/org/saltyrtc/client/tests/integration/ConnectionTest.java 42 | after_script: 43 | # Stop SaltyRTC server 44 | - kill -INT $SALTYRTC_SERVER_PID 45 | # Print server log 46 | - | 47 | echo "---------- Server Log ----------\n" 48 | cat saltyrtc-server-python/serverlog.txt 49 | echo -e "\n---------- End Server Log ----------" 50 | after_success: 51 | - test $TRAVIS_PULL_REQUEST == "false" && test $TRAVIS_BRANCH == "master" && test $EXECUTE_BUILD_DOCS == "true" && bash docs/deploy-docs.sh 52 | env: 53 | global: 54 | # Generated with "travis encrypt -r saltyrtc/saltyrtc-client-java GH_TOKEN=" 55 | secure: ApN+XHzAGhxgq2Owbtsu/H6596FZaGb0/WXY3kS3Hmp5KVvRl6pTZAH/1cNoYqXWLoRykKR646mghBO1daE7I9rINMKoiVGB40e3qymxA4mt4syyobACmPlDlSn4MM+XhQyvnpsKigPGRoFO9OEjjVZAZZTcamuH7QXFOGCPP+Bk8C52MH2JCa5bFTgjvSfIBpZO/Z13g/0klJLWYUxFZyw1vTCua2Se7wdN5TXfRMOA5V3F8rqIUEt82OyEjNZG0MOG3Q0jhEh/Vele7YqfX1UkQ5hjQBpq55dZMpoWqInTh2eepE3Z/JBgqO4Qw6cvRx9Hf8jPTQQ9MHjqxwHFDeuHNXPk4GukpvrUoRC/kX/BvzFpqZ2ptIvPjezcAkHvCgOLlqJSXoPdp1IO4JQsQNTTZI6NqwSt/XHEA+oLib4chJGEDVCSBrba7K1aAodmcLdrLOk6/ewWfqAxHdCvTOwLy2S7lxSMcD6k99TKUdkfa7/tiSWajIPt5YriutrGLrk9yyUc9LHdVhbFUI0K/HqMcoIfujiAMem1JDmHiV1garMn5k23LDS33C5WgypDEM6Ro53gVB9coy1h4K14f5v9uVW0zsGM/NRr8frBkZ5ySgN8xy0ZJ9e21KqKeswpoQmpJJiDa3Ji8Pg9pweUvGToh15FGAnRQ/8fkMHATpg= 56 | -------------------------------------------------------------------------------- /docs/docs/about.md: -------------------------------------------------------------------------------- 1 | # About SaltyRTC 2 | 3 | For more information about the project, please visit 4 | [saltyrtc.org](http://saltyrtc.org). 5 | -------------------------------------------------------------------------------- /docs/docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saltyrtc/saltyrtc-client-js/7b8413b2e053172f2db379dab6c895e5327cc001/docs/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # SaltyRTC Client for JavaScript 2 | 3 | This is a SaltyRTC implementation for JavaScript / TypeScript. 4 | 5 | !!! warning 6 | 7 | **Note:** The SaltyRTC client libraries are in maintenance mode. 8 | They will still receive bugfixes and regular maintenance, but if you want to 9 | start using these libraries, be prepared that you will need to take over 10 | maintenance at some point in time. (If you are interested in maintaining the 11 | libraries, please let us know, our e-mails are in the README, section 12 | "Security".) 13 | 14 | **Contents** 15 | 16 | * [Installing](installing.md) 17 | * [Usage](usage.md) 18 | * [About](about.md) 19 | -------------------------------------------------------------------------------- /docs/docs/installing.md: -------------------------------------------------------------------------------- 1 | # Installing 2 | 3 | ## Via NPM 4 | 5 | You can install this library and its peer dependencies via `npm`: 6 | 7 | npm install @saltyrtc/client msgpack-lite tweetnacl 8 | 9 | ## Manually 10 | 11 | Alternatively, copy one of the following files to your project directly: 12 | 13 | - ES2015: `dist/saltyrtc-client.es2015.js` 14 | - ES5: `dist/saltyrtc-client.es5.js` 15 | - ES5 minified: `dist/saltyrtc-client.es5.min.js` 16 | 17 | Make sure to manually add the following external dependencies to your project: 18 | 19 | - [tweetnacl](https://github.com/dchest/tweetnacl-js) 20 | - [msgpack-lite](https://github.com/kawanet/msgpack-lite) 21 | -------------------------------------------------------------------------------- /docs/docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | This chapter gives a short introduction on how to use the SaltyRTC JavaScript 4 | client. 5 | 6 | To see a more practical example, you may also want to take a look at our [demo 7 | application](https://github.com/saltyrtc/saltyrtc-demo). 8 | 9 | ## The SaltyRTCBuilder 10 | 11 | To initialize a SaltyRTC client instance, you can use the `SaltyRTCBuilder`. 12 | 13 | ```javascript 14 | const builder = new saltyrtcClient.SaltyRTCBuilder(); 15 | ``` 16 | 17 | ### Connection Info 18 | 19 | Then you need to provide connection info: 20 | 21 | ```javascript 22 | const host = 'server.saltyrtc.org'; 23 | const port = 9287; 24 | builder.connectTo(host, port); 25 | ``` 26 | 27 | For testing, you can use [our test server](https://saltyrtc.org/pages/getting-started.html). 28 | 29 | ### Key Store 30 | 31 | The client needs to have its own public/private keypair. Create a new keypair 32 | with the `KeyStore` class: 33 | 34 | ```javascript 35 | const keyStore = new saltyrtcClient.KeyStore(); 36 | builder.withKeyStore(keyStore); 37 | ``` 38 | 39 | ### Server Key Pinning 40 | 41 | If you want to use server key pinning, specify the server public permanent key: 42 | 43 | ```javascript 44 | const serverPublicPermanentKey = '424280166304526b4a2874a2270d091071fcc5c98959f7d4718715626df26204'; 45 | builder.withServerKey(serverPublicPermanentKey); 46 | ``` 47 | 48 | The public key can either be passed in as `Uint8Array` or as hex-encoded string. 49 | 50 | ### Websocket Ping Interval 51 | 52 | Optionally, you can specify a Websocket ping interval in seconds: 53 | 54 | ```javascript 55 | builder.withPingInterval(30); 56 | ``` 57 | 58 | ### Task Configuration 59 | 60 | You must initialize SaltyRTC with a task (TODO: Link to tasks documentation) 61 | that takes over after the handshake is done. 62 | 63 | For example, when using the [WebRTC 64 | task](https://github.com/saltyrtc/saltyrtc-task-webrtc-js): 65 | 66 | ```javascript 67 | const doHandover = true; 68 | const maxPacketSize = 16384; 69 | const webrtcTask = new saltyrtcTaskWebrtc.WebRTCTask(doHandover, maxPacketSize); 70 | builder.usingTasks([webrtcTask]); 71 | ``` 72 | 73 | ### Connecting as Initiator 74 | 75 | If you want to connect to the server as initiator, you can use the 76 | `.asInitiator()` method: 77 | 78 | ```javascript 79 | const client = builder.asInitiator(); 80 | ``` 81 | 82 | ### Connecting as Responder 83 | 84 | If you want to connect as responder, you need to provide the initiator 85 | information first that you have obtained from the initiator. 86 | 87 | ```javascript 88 | builder.initiatorInfo(initiatorPublicPermanentKey, initiatorAuthToken); 89 | const client = builder.asResponder(); 90 | ``` 91 | 92 | Both the initiator public permanent key as well as the initiator auth token can 93 | be either `Uint8Array` instances or hex-encoded strings. 94 | 95 | ## Full Example 96 | 97 | All methods on the `SaltyRTCBuilder` support chaining. Here's a full example of 98 | an initiator configuration: 99 | 100 | ```javascript 101 | const config = { 102 | SALTYRTC_HOST: 'server.saltyrtc.org', 103 | SALTYRTC_PORT: 9287, 104 | SALTYRTC_SERVER_PUBLIC_KEY: '424280166304526b4a2874a2270d091071fcc5c98959f7d4718715626df26204', 105 | }; 106 | const client = new saltyrtcClient.SaltyRTCBuilder() 107 | .connectTo(config.SALTYRTC_HOST, config.SALTYRTC_PORT) 108 | .withServerKey(config.SALTYRTC_SERVER_PUBLIC_KEY) 109 | .withKeyStore(new saltyrtcClient.KeyStore()) 110 | .usingTasks([new saltyrtcTaskWebrtc.WebRTCTask(true, 16384)]) 111 | .withPingInterval(30) 112 | .asInitiator(); 113 | ``` 114 | 115 | To see a more practical example, you may also want to take a look at our [demo 116 | application](https://github.com/saltyrtc/saltyrtc-demo). 117 | 118 | ## Events 119 | 120 | You can register callbacks for certain events: 121 | 122 | initiator.on('handover', () => console.log('Handover is done')); 123 | responder.on('state-change', (newState) => console.log('New signaling state:', newState)); 124 | 125 | The following events are available: 126 | 127 | - `state-change(saltyrtcClient.SignalingState)`: The signaling state changed. 128 | - `state-change:(void)`: The signaling state change event, filtered by state. 129 | - `new-responder(responderId)`: A responder has connected. This event is only dispatched for the initiator. 130 | - `application(data)`: An application message has arrived. 131 | - `peer-disconnected(peerId)`: A previously authenticated peer has disconnected from the server. 132 | - `handover(void)`: The handover to the data channel is done. 133 | - `signaling-connection-lost(responderId)`: The signaling connection to the specified peer was lost. 134 | - `no-shared-task(taskInfo)`: No shared task was found. This event is emitted by the initiator. 135 | - `connection-closed(closeCode)`: The connection was closed. 136 | - `connection-error(ErrorEvent)`: A websocket connection error occured. 137 | 138 | ## Trusted Keys 139 | 140 | In order to reconnect to a session using a trusted key, you first need to 141 | restore your `KeyStore` with the private permanent key originally used to 142 | establish the trusted session: 143 | 144 | ```javascript 145 | const keyStore = new saltyrtcClient.KeyStore(ourPrivatePermanentKey); 146 | ``` 147 | 148 | The private key can be passed in either as `Uint8Array` or as hex-encoded string. 149 | 150 | Then, on the `SaltyRTCBuilder` instance, set the trusted peer key: 151 | 152 | ```javascript 153 | builder.withTrustedPeerKey(peerPublicPermanentKey); 154 | ``` 155 | 156 | The public key can be passed in either as `Uint8Array` or as hex-encoded string. 157 | 158 | ## Dynamically Determine Server Connection Info 159 | 160 | Instead of specifying the SaltyRTC server host and port directly, you can 161 | instead provide an implementation of a `ServerInfoFactory` that can dynamically 162 | determine the connection info based on the public key of the initiator. 163 | 164 | The signature of the function must look like this: 165 | 166 | ```typescript 167 | (initiatorPublicKey: string) => { host: string, port: number } 168 | ``` 169 | 170 | Example: 171 | 172 | ```javascript 173 | builder.connectWith((initiatorPublicKey) => { 174 | let host; 175 | if (initiatorPublicKey.startsWith('a')) { 176 | host = 'a.example.org'; 177 | } else { 178 | host = 'other.example.org'; 179 | } 180 | return { 181 | host: host, 182 | port: 8765, 183 | } 184 | }); 185 | ``` 186 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SaltyRTC Client for JavaScript 2 | site_description: Documentation for saltyrtc-client-js. 3 | site_author: Threema GmbH 4 | copyright: © 2016-2022 Threema GmbH 5 | theme: readthedocs 6 | repo_url: https://github.com/saltyrtc/saltyrtc-client-js 7 | edit_uri: edit/master/docs/docs/ 8 | markdown_extensions: 9 | - smarty 10 | - admonition 11 | - toc: 12 | permalink: False 13 | pages: 14 | - Home: index.md 15 | - Guide: 16 | - Installing: installing.md 17 | - Usage: usage.md 18 | - About: about.md 19 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs>=1.2.3,<1.3 2 | Pygments>=2.11.2,2.12 3 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | 3 | const configuration = { 4 | frameworks: ['jasmine'], 5 | files: [ 6 | 'node_modules/tweetnacl/nacl-fast.min.js', 7 | 'node_modules/msgpack-lite/dist/msgpack.min.js', 8 | 'tests/testsuite.js' 9 | ], 10 | customLaunchers: { 11 | Firefox_circle_ci: { 12 | base: 'Firefox', 13 | profile: '/home/ci/.mozilla/firefox/saltyrtc', 14 | } 15 | } 16 | }; 17 | 18 | if (process.env.CIRCLECI) { 19 | configuration.browsers = ['ChromiumHeadless', 'Firefox_circle_ci']; 20 | } else { 21 | configuration.browsers = ['Chromium', 'Firefox']; 22 | } 23 | 24 | config.set(configuration); 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saltyrtc/client", 3 | "version": "0.15.1", 4 | "description": "SaltyRTC JavaScript implementation", 5 | "main": "dist/saltyrtc-client.es5.min.js", 6 | "module": "dist/saltyrtc-client.es2015.js", 7 | "jsnext:main": "dist/saltyrtc-client.es2015.js", 8 | "types": "saltyrtc-client.d.ts", 9 | "scripts": { 10 | "test": "karma start --single-run --log-level=debug --colors", 11 | "dist": "npm run dist_es5 && npm run dist_es5_min && npm run dist_es2015", 12 | "dist_es5": "rollup -c rollup/es5.js", 13 | "dist_es5_min": "rollup -c rollup/es5.min.js", 14 | "dist_es2015": "rollup -c rollup/es2015.js", 15 | "rollup_tests": "rollup -c rollup/testing.js && rollup -c rollup/performance.js", 16 | "validate": "tsc --noEmit", 17 | "lint": "tslint -c tslint.json --project tsconfig.json", 18 | "clean": "rm -rf src/*.js tests/testsuite.js* tests/performance.js*" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/saltyrtc/saltyrtc-client-js.git" 23 | }, 24 | "keywords": [ 25 | "saltyrtc", 26 | "webrtc", 27 | "ortc", 28 | "rtc", 29 | "nacl" 30 | ], 31 | "author": "Threema GmbH", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/saltyrtc/saltyrtc-client-js/issues" 35 | }, 36 | "homepage": "https://github.com/saltyrtc/saltyrtc-client-js", 37 | "devDependencies": { 38 | "@babel/core": "^7.17.2", 39 | "@rollup/plugin-babel": "^5.3.0", 40 | "@rollup/plugin-typescript": "^8.3.0", 41 | "@types/msgpack-lite": "^0.1.7", 42 | "jasmine-core": "^4.0.0", 43 | "karma": "^6.3.16", 44 | "karma-chrome-launcher": "^3.1.0", 45 | "karma-firefox-launcher": "^2.1.2", 46 | "karma-jasmine": "^4.0.1", 47 | "msgpack-lite": "^0.1.x", 48 | "rollup": "^2.67.2", 49 | "rollup-plugin-terser": "^7.0.2", 50 | "tslint": "^6", 51 | "tweetnacl": "^1.0.3", 52 | "typescript": "^4.5.5" 53 | }, 54 | "peerDependencies": { 55 | "msgpack-lite": "^0.1.x", 56 | "tweetnacl": "^1.0.0" 57 | }, 58 | "files": [ 59 | "dist", 60 | "saltyrtc-client.d.ts", 61 | "README.md", 62 | "LICENSE.md", 63 | "CHANGELOG.md", 64 | "package.json", 65 | "package-lock.json" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /rollup/es2015.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import fs from 'fs'; 3 | 4 | let p = JSON.parse(fs.readFileSync('package.json')); 5 | 6 | export default { 7 | input: 'src/main.ts', 8 | treeshake: true, 9 | plugins: [ 10 | typescript({ 11 | typescript: require('typescript') 12 | }) 13 | ], 14 | external: ['msgpack-lite', 'tweetnacl'], 15 | output: { 16 | file: 'dist/saltyrtc-client.es2015.js', 17 | sourcemap: false, 18 | format: 'es', 19 | banner: "/**\n" + 20 | " * saltyrtc-client-js v" + p.version + "\n" + 21 | " * " + p.description + "\n" + 22 | " * " + p.homepage + "\n" + 23 | " *\n" + 24 | " * Copyright (C) 2016-2022 " + p.author + "\n" + 25 | " *\n" + 26 | " * This software may be modified and distributed under the terms\n" + 27 | " * of the MIT license:\n" + 28 | " * \n" + 29 | " * Permission is hereby granted, free of charge, to any person obtaining a copy\n" + 30 | " * of this software and associated documentation files (the \"Software\"), to deal\n" + 31 | " * in the Software without restriction, including without limitation the rights\n" + 32 | " * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" + 33 | " * copies of the Software, and to permit persons to whom the Software is\n" + 34 | " * furnished to do so, subject to the following conditions:\n" + 35 | " * \n" + 36 | " * The above copyright notice and this permission notice shall be included in all\n" + 37 | " * copies or substantial portions of the Software.\n" + 38 | " * \n" + 39 | " * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" + 40 | " * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" + 41 | " * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" + 42 | " * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" + 43 | " * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" + 44 | " * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n" + 45 | " * SOFTWARE.\n" + 46 | " */\n" + 47 | "'use strict';\n", 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /rollup/es5.js: -------------------------------------------------------------------------------- 1 | import config from './es2015.js'; 2 | import babel from '@rollup/plugin-babel'; 3 | 4 | config.output.file = 'dist/saltyrtc-client.es5.js'; 5 | config.output.name = 'saltyrtcClient'; 6 | config.output.format = 'iife'; 7 | config.output.globals = { 8 | 'msgpack-lite': 'msgpack', 9 | 'tweetnacl': 'nacl' 10 | }; 11 | config.output.strict = true; 12 | config.plugins.push( 13 | babel({ 14 | babelrc: false, 15 | exclude: 'node_modules/**', 16 | presets: [ 17 | // Use ES2015 but don't transpile modules since Rollup does that 18 | ['es2015', {modules: false}] 19 | ], 20 | babelHelpers: 'bundled', 21 | }) 22 | ); 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /rollup/es5.min.js: -------------------------------------------------------------------------------- 1 | import config from './es5.js'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | 4 | config.output.file = 'dist/saltyrtc-client.es5.min.js'; 5 | config.plugins.push( 6 | terser({ 7 | format: { 8 | comments: (node, comment) => { 9 | const text = comment.value; 10 | const type = comment.type; 11 | if (type == "comment2") { // multiline comment 12 | return /MIT license/.test(text); 13 | } 14 | 15 | } 16 | } 17 | }) 18 | ); 19 | 20 | export default config; 21 | -------------------------------------------------------------------------------- /rollup/performance.js: -------------------------------------------------------------------------------- 1 | import config from './es5.js'; 2 | 3 | config.input = 'tests/performance.ts'; 4 | config.output.file = 'tests/performance.js'; 5 | config.output.sourcemap = true; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /rollup/testing.js: -------------------------------------------------------------------------------- 1 | import config from './es5.js'; 2 | 3 | config.input = 'tests/main.ts'; 4 | config.output.file = 'tests/testsuite.js'; 5 | config.output.sourcemap = true; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /saltyrtc-client.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | declare namespace saltyrtc { 9 | type CryptoErrorCode = 'bad-message-length' | 'bad-token-length' | 'decryption-failed' 10 | 11 | interface SignalingError extends Error { 12 | readonly closeCode: number; 13 | } 14 | 15 | interface ProtocolError extends SignalingError {} 16 | 17 | interface ConnectionError extends Error {} 18 | 19 | interface ValidationError extends Error { 20 | readonly critical: boolean; 21 | } 22 | 23 | interface CryptoError extends Error { 24 | readonly code: CryptoErrorCode; 25 | } 26 | 27 | interface Box { 28 | readonly length: number; 29 | readonly data: Uint8Array; 30 | readonly nonce: Uint8Array; 31 | toUint8Array(): Uint8Array; 32 | } 33 | 34 | interface KeyStore { 35 | readonly publicKeyHex: string; 36 | readonly publicKeyBytes: Uint8Array; 37 | readonly secretKeyHex: string; 38 | readonly secretKeyBytes: Uint8Array; 39 | getSharedKeyStore(publicKey: Uint8Array | string): SharedKeyStore; 40 | encryptRaw(bytes: Uint8Array, nonce: Uint8Array, otherKey: Uint8Array): Uint8Array; 41 | encrypt(bytes: Uint8Array, nonce: Uint8Array, otherKey: Uint8Array): Box; 42 | decryptRaw(bytes: Uint8Array, nonce: Uint8Array, otherKey: Uint8Array): Uint8Array; 43 | decrypt(box: Box, otherKey: Uint8Array): Uint8Array; 44 | } 45 | 46 | interface SharedKeyStore { 47 | readonly localSecretKeyHex: string; 48 | readonly localSecretKeyBytes: Uint8Array 49 | readonly remotePublicKeyHex: string; 50 | readonly remotePublicKeyBytes: Uint8Array 51 | encryptRaw(bytes: Uint8Array, nonce: Uint8Array): Uint8Array; 52 | encrypt(bytes: Uint8Array, nonce: Uint8Array): Box; 53 | decryptRaw(bytes: Uint8Array, nonce: Uint8Array): Uint8Array; 54 | decrypt(box: Box): Uint8Array; 55 | } 56 | 57 | interface AuthToken { 58 | readonly keyBytes: Uint8Array; 59 | readonly keyHex: string; 60 | encrypt(bytes: Uint8Array, nonce: Uint8Array): Box; 61 | decrypt(box: Box): Uint8Array; 62 | } 63 | 64 | interface Message { 65 | type: string; 66 | } 67 | 68 | interface SignalingMessage extends Message { 69 | type: messages.MessageType; 70 | } 71 | 72 | type SignalingState = 'new' | 'ws-connecting' | 'server-handshake' | 'peer-handshake' | 'task' | 'closing' | 'closed'; 73 | 74 | interface HandoverState { 75 | local: boolean; 76 | peer: boolean; 77 | readonly any: boolean; 78 | readonly both: boolean; 79 | onBoth: () => void; 80 | reset(): void; 81 | } 82 | 83 | type SignalingRole = 'initiator' | 'responder'; 84 | 85 | interface SaltyRTCEvent { 86 | type: string; 87 | data?: any; 88 | } 89 | type SaltyRTCEventHandler = (event: SaltyRTCEvent) => boolean | void; 90 | 91 | type TaskData = { [index:string] : any }; 92 | 93 | interface Signaling { 94 | handoverState: HandoverState; 95 | role: SignalingRole; 96 | 97 | getState(): SignalingState; 98 | setState(state: SignalingState): void; 99 | 100 | /** 101 | * Send a task message through the websocket or - if handover has 102 | * already happened - through the task channel. 103 | * 104 | * @throws SignalingError if message could not be sent. 105 | */ 106 | sendTaskMessage(msg: messages.TaskMessage): void; 107 | 108 | /** 109 | * Encrypt data for the peer. 110 | * 111 | * @param data The bytes to be encrypted. 112 | * @param nonce The bytes to be used as NaCl nonce. 113 | */ 114 | encryptForPeer(data: Uint8Array, nonce: Uint8Array): Box; 115 | 116 | /** 117 | * Decrypt data from the peer. 118 | * 119 | * @param box The encrypted box. 120 | */ 121 | decryptFromPeer(box: Box): Uint8Array; 122 | 123 | /** 124 | * Handle incoming signaling messages from the peer. 125 | * 126 | * This method can be used by tasks to pass in messages that arrived through their signaling channel. 127 | * 128 | * @param decryptedBytes The decrypted message bytes. 129 | * @throws SignalingError if the message is invalid. 130 | */ 131 | onSignalingPeerMessage(decryptedBytes: Uint8Array): void; 132 | 133 | /** 134 | * Send a close message to the peer. 135 | * 136 | * This method may only be called once the client-to-client handshakes has been completed. 137 | * 138 | * Note that sending a close message does not reset the connection. To do that, 139 | * `resetConnection` needs to be called explicitly. 140 | * 141 | * @param reason The close code. 142 | */ 143 | sendClose(reason: number): void; 144 | 145 | /** 146 | * Close and reset the connection with the specified close code. 147 | * 148 | * If no reason is passed in, this will be treated as a quiet 149 | * reset - no listeners will be notified. 150 | * 151 | * @param reason The close code to use. 152 | */ 153 | resetConnection(reason?: number): void; 154 | } 155 | 156 | interface Task { 157 | /** 158 | * Initialize the task with the task data from the peer. 159 | * 160 | * The task should keep track internally whether it has been initialized or not. 161 | * 162 | * @param signaling The signaling instance. 163 | * @param data The data sent by the peer in the 'auth' message. 164 | * @throws ValidationError if task data is invalid. 165 | */ 166 | init(signaling: Signaling, data: TaskData): void; 167 | 168 | /** 169 | * Used by the signaling class to notify task that the peer handshake is over. 170 | * 171 | * This is the point where the task can take over. 172 | */ 173 | onPeerHandshakeDone(): void; 174 | 175 | /** 176 | * This method is called by SaltyRTC when a task related message 177 | * arrives through the WebSocket. 178 | * 179 | * @param message The deserialized MessagePack message. 180 | */ 181 | onTaskMessage(message: messages.TaskMessage): void; 182 | 183 | /** 184 | * Send a signaling message through the task signaling channel. 185 | * 186 | * This method should only be called after the handover. 187 | * 188 | * @param payload The *unencrypted* message bytes. Message will be encrypted by the task. 189 | * @throws SignalingError if something goes wrong. 190 | */ 191 | sendSignalingMessage(payload: Uint8Array): void; 192 | 193 | /** 194 | * Return the task protocol name. 195 | */ 196 | getName(): string; 197 | 198 | /** 199 | * Return the list of supported message types. 200 | * 201 | * Incoming mssages with this type will be passed to the task. 202 | */ 203 | getSupportedMessageTypes(): string[]; 204 | 205 | /** 206 | * Return the task data used for negotiation in the `auth` message. 207 | */ 208 | getData(): TaskData; 209 | 210 | /** 211 | * Close any task connections that may be open. 212 | * 213 | * This method is called by the signaling class in two cases: 214 | * 215 | * - When sending and receiving 'close' messages 216 | * - When the user explicitly requests to close the connection 217 | */ 218 | close(reason: number): void; 219 | } 220 | 221 | type ServerInfoFactory = (initiatorPublicKey: string) => {host: string, port: number}; 222 | 223 | interface SaltyRTCBuilder { 224 | connectTo(host: string, port: number): SaltyRTCBuilder; 225 | connectWith(serverInfo: ServerInfoFactory): SaltyRTCBuilder; 226 | withKeyStore(keyStore: KeyStore): SaltyRTCBuilder; 227 | withTrustedPeerKey(peerTrustedKey: Uint8Array | string): SaltyRTCBuilder; 228 | withServerKey(serverKey: Uint8Array | string): SaltyRTCBuilder; 229 | withLoggingLevel(level: saltyrtc.LogLevel): SaltyRTCBuilder; 230 | initiatorInfo(initiatorPublicKey: Uint8Array | string, authToken: Uint8Array | string): SaltyRTCBuilder; 231 | usingTasks(tasks: Task[]): SaltyRTCBuilder; 232 | withPingInterval(interval: number): SaltyRTCBuilder; 233 | 234 | asInitiator(): SaltyRTC; 235 | asResponder(): SaltyRTC; 236 | } 237 | 238 | interface SaltyRTC { 239 | readonly log: saltyrtc.Log; 240 | state: SignalingState; 241 | 242 | keyStore: KeyStore; 243 | permanentKeyBytes: Uint8Array; 244 | permanentKeyHex: string; 245 | authTokenBytes: Uint8Array; 246 | authTokenHex: string; 247 | peerPermanentKeyBytes: Uint8Array; 248 | peerPermanentKeyHex: string; 249 | 250 | getTask(): Task; 251 | getCurrentPeerCsn(): {incoming: number, outgoing: number}; 252 | encryptForPeer(data: Uint8Array, nonce: Uint8Array): Box; 253 | decryptFromPeer(box: Box): Uint8Array; 254 | 255 | connect(): void; 256 | disconnect(unbind?: boolean): void; 257 | 258 | sendApplicationMessage(data: any): void; 259 | 260 | // Event handling 261 | on(event: string | string[], handler: SaltyRTCEventHandler): void; 262 | once(event: string | string[], handler: SaltyRTCEventHandler): void; 263 | off(event?: string | string[], handler?: SaltyRTCEventHandler): void; 264 | emit(event: SaltyRTCEvent): void; 265 | } 266 | 267 | interface EventRegistry { 268 | /** 269 | * Register an event handler for the specified event(s). 270 | */ 271 | register(eventType: string | string[], handler: SaltyRTCEventHandler): void; 272 | 273 | /** 274 | * Unregister an event handler for the specified event(s). 275 | * If no handler is specified, all handlers for the specified event(s) are removed. 276 | */ 277 | unregister(eventType: string | string[], handler?: SaltyRTCEventHandler): void; 278 | 279 | /** 280 | * Clear all event handlers. 281 | */ 282 | unregisterAll(): void; 283 | 284 | /** 285 | * Return all event handlers for the specified event(s). 286 | * 287 | * The return value is always an array. If the event does not exist, the 288 | * array will be empty. 289 | * 290 | * Even if a handler is registered for multiple events, it is only returned once. 291 | */ 292 | get(eventType: string | string[]): SaltyRTCEventHandler[]; 293 | } 294 | 295 | interface Cookie { 296 | bytes: Uint8Array; 297 | equals(otherCookie: Cookie): boolean; 298 | } 299 | 300 | interface CookiePair { 301 | ours: Cookie; 302 | theirs: Cookie; 303 | } 304 | 305 | type NextCombinedSequence = { sequenceNumber: number, overflow: number }; 306 | 307 | interface CombinedSequence { 308 | next(): NextCombinedSequence; 309 | } 310 | 311 | interface CombinedSequencePair { 312 | ours: CombinedSequence; 313 | theirs: number; 314 | } 315 | 316 | type LogLevel = 'none' | 'debug' | 'info' | 'warn' | 'error'; 317 | 318 | interface Log { 319 | level: saltyrtc.LogLevel; 320 | debug(message?: any, ...optionalParams: any[]): void; 321 | trace(message?: any, ...optionalParams: any[]): void; 322 | info(message?: any, ...optionalParams: any[]): void; 323 | warn(message?: any, ...optionalParams: any[]): void; 324 | error(message?: any, ...optionalParams: any[]): void; 325 | assert(condition?: boolean, message?: string, ...data: any[]): void; 326 | } 327 | } 328 | 329 | declare namespace saltyrtc.messages { 330 | type MessageType = 'server-hello' | 'client-hello' | 'client-auth' 331 | | 'server-auth' | 'new-initiator' | 'new-responder' 332 | | 'drop-responder' | 'send-error' | 'token' | 'key' 333 | | 'auth' | 'restart' | 'close' | 'disconnected' | 'application'; 334 | 335 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#server-hello 336 | interface ServerHello extends SignalingMessage { 337 | type: 'server-hello'; 338 | key: ArrayBuffer; 339 | } 340 | 341 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#client-hello 342 | interface ClientHello extends SignalingMessage { 343 | type: 'client-hello'; 344 | key: ArrayBuffer; 345 | } 346 | 347 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#client-auth 348 | interface ClientAuth extends SignalingMessage { 349 | type: 'client-auth'; 350 | your_cookie: ArrayBuffer; 351 | your_key?: ArrayBuffer | null; 352 | subprotocols: string[]; 353 | ping_interval: number; 354 | } 355 | 356 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#server-auth 357 | interface ServerAuth extends SignalingMessage { 358 | type: 'server-auth'; 359 | your_cookie: ArrayBuffer; 360 | signed_keys?: ArrayBuffer; 361 | initiator_connected?: boolean; 362 | responders?: number[]; 363 | } 364 | 365 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#new-initiator 366 | interface NewInitiator extends SignalingMessage { 367 | type: 'new-initiator'; 368 | } 369 | 370 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#new-responder 371 | interface NewResponder extends SignalingMessage { 372 | type: 'new-responder'; 373 | id: number; 374 | } 375 | 376 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#drop-responder 377 | interface DropResponder extends SignalingMessage { 378 | type: 'drop-responder'; 379 | id: number; 380 | reason?: number; 381 | } 382 | 383 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#send-error 384 | interface SendError extends SignalingMessage { 385 | type: 'send-error'; 386 | id: ArrayBuffer; 387 | } 388 | 389 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#token-message 390 | interface Token extends SignalingMessage { 391 | type: 'token'; 392 | key: ArrayBuffer; 393 | } 394 | 395 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#key-message 396 | interface Key extends SignalingMessage { 397 | type: 'key'; 398 | key: ArrayBuffer; 399 | } 400 | 401 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#auth-message 402 | interface Auth extends SignalingMessage { 403 | type: 'auth'; 404 | your_cookie: ArrayBuffer; 405 | data: { [index:string] : any }; 406 | } 407 | interface InitiatorAuth extends Auth { 408 | task: string; 409 | } 410 | interface ResponderAuth extends Auth { 411 | tasks: string[]; 412 | } 413 | 414 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#close-message 415 | interface Close extends SignalingMessage { 416 | type: 'close'; 417 | reason: number; 418 | } 419 | 420 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#disconnected-message 421 | interface Disconnected extends SignalingMessage { 422 | type: 'disconnected'; 423 | id: number; 424 | } 425 | 426 | // https://github.com/saltyrtc/saltyrtc-meta/blob/master/Protocol.md#application-message 427 | interface Application extends Message { 428 | type: 'application'; 429 | data: any; 430 | } 431 | 432 | /** 433 | * A task message must include the type. It may contain arbitrary other data. 434 | */ 435 | interface TaskMessage extends Message { 436 | type: string; 437 | [others: string]: any; // Make this an open interface 438 | } 439 | } 440 | 441 | declare namespace saltyrtc.static { 442 | interface SignalingError { 443 | new(closeCode: number, message: string): saltyrtc.SignalingError; 444 | } 445 | 446 | interface ProtocolError { 447 | new(message: string): saltyrtc.ProtocolError; 448 | } 449 | 450 | interface ConnectionError { 451 | new(message: string): saltyrtc.ConnectionError; 452 | } 453 | 454 | interface ValidationError { 455 | new(message: string, critical?: boolean): saltyrtc.ValidationError; 456 | } 457 | 458 | interface CryptoError { 459 | new(code: CryptoErrorCode, message: string): saltyrtc.CryptoError; 460 | } 461 | 462 | interface SaltyRTCBuilder { 463 | new(): saltyrtc.SaltyRTCBuilder; 464 | } 465 | 466 | interface KeyStore { 467 | new(secretKey?: Uint8Array | string, log?: saltyrtc.Log): saltyrtc.KeyStore; 468 | } 469 | 470 | interface Box { 471 | new(nonce: Uint8Array, data: Uint8Array, nonceLength: number): saltyrtc.Box; 472 | fromUint8Array(array: Uint8Array, nonceLength: number): saltyrtc.Box; 473 | } 474 | 475 | interface Cookie { 476 | COOKIE_LENGTH: number; 477 | new(bytes?: Uint8Array): saltyrtc.Cookie; 478 | } 479 | 480 | interface CookiePair { 481 | new(ours?: saltyrtc.Cookie, theirs?: saltyrtc.Cookie): saltyrtc.CookiePair; 482 | } 483 | 484 | interface CombinedSequence { 485 | SEQUENCE_NUMBER_MAX: number; 486 | OVERFLOW_MAX: number; 487 | new(): saltyrtc.CombinedSequence; 488 | } 489 | 490 | interface CombinedSequencePair { 491 | new(ours?: saltyrtc.CombinedSequence, theirs?: number): saltyrtc.CombinedSequencePair; 492 | } 493 | 494 | interface EventRegistry { 495 | new(): saltyrtc.EventRegistry; 496 | } 497 | 498 | interface Log { 499 | new(level: saltyrtc.LogLevel): saltyrtc.Log; 500 | } 501 | 502 | /** 503 | * Static list of close codes. 504 | */ 505 | interface CloseCode { 506 | ClosingNormal: number; 507 | GoingAway: number; 508 | NoSharedSubprotocol: number; 509 | PathFull: number; 510 | ProtocolError: number; 511 | InternalError: number; 512 | Handover: number; 513 | DroppedByInitiator: number; 514 | InitiatorCouldNotDecrypt: number; 515 | NoSharedTask: number; 516 | InvalidKey: number; 517 | } 518 | } 519 | 520 | declare var saltyrtcClient: { 521 | exceptions: { 522 | SignalingError: saltyrtc.static.SignalingError, 523 | ProtocolError: saltyrtc.static.ProtocolError, 524 | ConnectionError: saltyrtc.static.ConnectionError, 525 | ValidationError: saltyrtc.static.ValidationError, 526 | CryptoError: saltyrtc.static.CryptoError, 527 | }, 528 | SaltyRTCBuilder: saltyrtc.static.SaltyRTCBuilder; 529 | KeyStore: saltyrtc.static.KeyStore; 530 | Box: saltyrtc.static.Box; 531 | Cookie: saltyrtc.static.Cookie; 532 | CookiePair: saltyrtc.static.CookiePair; 533 | CombinedSequence: saltyrtc.static.CombinedSequence; 534 | CombinedSequencePair: saltyrtc.static.CombinedSequencePair; 535 | EventRegistry: saltyrtc.static.EventRegistry; 536 | CloseCode: saltyrtc.static.CloseCode; 537 | explainCloseCode: (code: number) => string; 538 | SignalingError: saltyrtc.static.SignalingError; 539 | ConnectionError: saltyrtc.static.ConnectionError; 540 | Log: saltyrtc.static.Log; 541 | }; 542 | -------------------------------------------------------------------------------- /src/closecode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | export class CloseCode { 9 | // tslint:disable:variable-name 10 | public static readonly ClosingNormal = 1000; 11 | public static readonly GoingAway = 1001; 12 | public static readonly NoSharedSubprotocol = 1002; 13 | public static readonly PathFull = 3000; 14 | public static readonly ProtocolError = 3001; 15 | public static readonly InternalError = 3002; 16 | public static readonly Handover = 3003; 17 | public static readonly DroppedByInitiator = 3004; 18 | public static readonly InitiatorCouldNotDecrypt = 3005; 19 | public static readonly NoSharedTask = 3006; 20 | public static readonly InvalidKey = 3007; 21 | public static readonly Timeout = 3008; 22 | // tslint:enable:variable-name 23 | } 24 | 25 | export function explainCloseCode(code: CloseCode): string { 26 | switch (code) { 27 | case CloseCode.ClosingNormal: 28 | return 'Normal closing'; 29 | case CloseCode.GoingAway: 30 | return 'The endpoint is going away'; 31 | case CloseCode.NoSharedSubprotocol: 32 | return 'No shared subprotocol could be found'; 33 | case CloseCode.PathFull: 34 | return 'No free responder byte'; 35 | case CloseCode.ProtocolError: 36 | return 'Protocol error'; 37 | case CloseCode.InternalError: 38 | return 'Internal error'; 39 | case CloseCode.Handover: 40 | return 'Handover finished'; 41 | case CloseCode.DroppedByInitiator: 42 | return 'Dropped by initiator'; 43 | case CloseCode.InitiatorCouldNotDecrypt: 44 | return 'Initiator could not decrypt a message'; 45 | case CloseCode.NoSharedTask: 46 | return 'No shared task was found'; 47 | case CloseCode.InvalidKey: 48 | return 'Invalid key'; 49 | case CloseCode.Timeout: 50 | return 'Timeout'; 51 | default: 52 | return 'Unknown'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cookie.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import * as nacl from 'tweetnacl'; 9 | import { ProtocolError, ValidationError } from './exceptions'; 10 | 11 | export class Cookie implements saltyrtc.Cookie { 12 | public static COOKIE_LENGTH = 16; 13 | 14 | public bytes: Uint8Array; 15 | 16 | /** 17 | * Create a new cookie. 18 | * 19 | * If no bytes are provided, generate a random cookie. 20 | */ 21 | constructor(bytes?: Uint8Array) { 22 | if (bytes !== undefined) { 23 | if (bytes.length !== 16) { 24 | throw new ValidationError('Bad cookie length'); 25 | } 26 | this.bytes = bytes; 27 | } else { 28 | this.bytes = nacl.randomBytes(Cookie.COOKIE_LENGTH); 29 | } 30 | } 31 | 32 | /** 33 | * Return whether or not the two cookies are equal. 34 | */ 35 | public equals(otherCookie: Cookie) { 36 | if (otherCookie.bytes === this.bytes) { 37 | return true; 38 | } 39 | if (otherCookie.bytes.byteLength !== Cookie.COOKIE_LENGTH) { 40 | return false; 41 | } 42 | for (let i = 0; i < this.bytes.byteLength; i++) { 43 | if (otherCookie.bytes[i] !== this.bytes[i]) { 44 | return false; 45 | } 46 | } 47 | return true; 48 | } 49 | } 50 | 51 | /** 52 | * A cookie pair. 53 | * 54 | * The implementation ensures that the two cookies cannot be equal. 55 | */ 56 | export class CookiePair implements saltyrtc.CookiePair { 57 | private _ours: Cookie = null; 58 | private _theirs: Cookie = null; 59 | 60 | /** 61 | * Create a new cookie pair with a predefined peer cookie. 62 | */ 63 | public static fromTheirs(theirs: Cookie): saltyrtc.CookiePair { 64 | let ours: Cookie; 65 | do { 66 | ours = new Cookie(); 67 | } while (ours.equals(theirs)); 68 | return new CookiePair(ours, theirs); 69 | } 70 | 71 | /** 72 | * Create a new cookie pair. Either both or no cookies must be specified. 73 | * 74 | * If you want to create a cookie pair from a predefined peer cookie, 75 | * use the static `fromTheirs` method instead. 76 | * 77 | * @throws SignalingError if both cookies are defined and equal. 78 | */ 79 | constructor(ours?: Cookie, theirs?: Cookie) { 80 | if (typeof ours !== 'undefined' && typeof theirs !== 'undefined') { 81 | if (theirs.equals(ours)) { 82 | throw new ProtocolError('Their cookie matches our cookie'); 83 | } 84 | this._ours = ours; 85 | this._theirs = theirs; 86 | } else if (typeof ours === 'undefined' && typeof theirs === 'undefined') { 87 | this._ours = new Cookie(); 88 | } else { 89 | throw new Error('Either both or no cookies must be specified'); 90 | } 91 | } 92 | 93 | /** 94 | * Get our own cookie. 95 | */ 96 | public get ours(): saltyrtc.Cookie { 97 | return this._ours; 98 | } 99 | 100 | /** 101 | * Get the peer cookie. 102 | */ 103 | public get theirs(): saltyrtc.Cookie { 104 | return this._theirs; 105 | } 106 | 107 | /** 108 | * Set the peer cookie. 109 | * 110 | * @throws SignalingError if cookie matches our cookie. 111 | */ 112 | public set theirs(cookie: saltyrtc.Cookie) { 113 | if (cookie.equals(this._ours)) { 114 | throw new ProtocolError('Their cookie matches our cookie'); 115 | } 116 | this._theirs = cookie; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/csn.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import { randomUint32 } from './utils'; 9 | 10 | export class CombinedSequence implements saltyrtc.CombinedSequence { 11 | private static SEQUENCE_NUMBER_MAX = 0xFFFFFFFF; // 1<<32 - 1 12 | private static OVERFLOW_MAX = 0xFFFFF; // 1<<16 - 1 13 | 14 | private sequenceNumber: number; 15 | private overflow: number; 16 | 17 | constructor() { 18 | this.sequenceNumber = randomUint32(); 19 | this.overflow = 0; 20 | } 21 | 22 | /** 23 | * Return next sequence number and overflow. 24 | * 25 | * May throw an error if overflow number overflows. This is extremely 26 | * unlikely and must be treated as a protocol error. 27 | */ 28 | public next(): saltyrtc.NextCombinedSequence { 29 | if (this.sequenceNumber >= CombinedSequence.SEQUENCE_NUMBER_MAX) { 30 | // Sequence number overflow 31 | this.sequenceNumber = 0; 32 | this.overflow += 1; 33 | if (this.overflow >= CombinedSequence.OVERFLOW_MAX) { 34 | // Overflow overflow 35 | throw new Error('overflow-overflow'); 36 | } 37 | } else { 38 | this.sequenceNumber += 1; 39 | } 40 | return { 41 | sequenceNumber: this.sequenceNumber, 42 | overflow: this.overflow, 43 | }; 44 | } 45 | 46 | /** 47 | * Return a snapshot of the current CSN as an integer, without changing the 48 | * internal state. 49 | * 50 | * Warning: Do not use this for the SaltyRTC protocol itself! 51 | */ 52 | public asNumber(): number { 53 | return (this.overflow * (2 ** 32)) + this.sequenceNumber; 54 | } 55 | 56 | } 57 | 58 | /** 59 | * A combined sequence pair. 60 | */ 61 | export class CombinedSequencePair implements saltyrtc.CombinedSequencePair { 62 | public ours: CombinedSequence = null; 63 | public theirs: number = null; 64 | 65 | constructor(ours?: CombinedSequence, theirs?: number) { 66 | if (typeof ours !== 'undefined' && typeof theirs !== 'undefined') { 67 | this.ours = ours; 68 | this.theirs = theirs; 69 | } else if (typeof ours === 'undefined' && typeof theirs === 'undefined') { 70 | this.ours = new CombinedSequence(); 71 | } else { 72 | throw new Error('Either both or no combined sequences must be specified'); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/eventregistry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | export class EventRegistry { 9 | protected map: Map; 10 | 11 | constructor() { 12 | this.map = new Map(); 13 | } 14 | 15 | /** 16 | * Register an event handler for the specified event(s). 17 | */ 18 | public register(eventType: string | string[], handler: saltyrtc.SaltyRTCEventHandler): void { 19 | if (typeof eventType === 'string') { 20 | this.set(eventType, handler); 21 | } else { 22 | for (const et of eventType) { 23 | this.set(et, handler); 24 | } 25 | } 26 | } 27 | 28 | /** 29 | * Unregister an event handler for the specified event(s). 30 | * If no handler is specified, all handlers for the specified event(s) are removed. 31 | */ 32 | public unregister(eventType: string | string[], handler?: saltyrtc.SaltyRTCEventHandler): void { 33 | if (typeof eventType === 'string') { 34 | // If the event does not exist, return 35 | if (!this.map.has(eventType)) { 36 | return; 37 | } 38 | // If no handler is specified, remove all corresponding events 39 | if (typeof handler === 'undefined') { 40 | this.map.delete(eventType); 41 | // Otherwise, remove the handler from the list if present 42 | } else { 43 | const list = this.map.get(eventType); 44 | const index = list.indexOf(handler); 45 | if (index !== -1) { 46 | list.splice(index, 1); 47 | } 48 | } 49 | } else { 50 | for (const et of eventType) { 51 | this.unregister(et, handler); 52 | } 53 | } 54 | } 55 | 56 | /** 57 | * Clear all event handlers. 58 | */ 59 | public unregisterAll(): void { 60 | this.map.clear(); 61 | } 62 | 63 | /** 64 | * Store a single event handler in the map. 65 | */ 66 | private set(key: string, value: saltyrtc.SaltyRTCEventHandler) { 67 | if (this.map.has(key)) { 68 | const list = this.map.get(key); 69 | if (list.indexOf(value) === -1) { 70 | list.push(value); 71 | } 72 | } else { 73 | this.map.set(key, [value]); 74 | } 75 | } 76 | 77 | /** 78 | * Return all event handlers for the specified event(s). 79 | * 80 | * The return value is always an array. If the event does not exist, the 81 | * array will be empty. 82 | * 83 | * Even if a handler is registered for multiple events, it is only returned once. 84 | */ 85 | public get(eventType: string | string[]): saltyrtc.SaltyRTCEventHandler[] { 86 | const handlers: saltyrtc.SaltyRTCEventHandler[] = []; 87 | if (typeof eventType === 'string') { 88 | if (this.map.has(eventType)) { 89 | handlers.push.apply(handlers, this.map.get(eventType)); 90 | } 91 | } else { 92 | for (const et of eventType) { 93 | for (const handler of this.get(et)) { 94 | if (handlers.indexOf(handler) === -1) { 95 | handlers.push(handler); 96 | } 97 | } 98 | } 99 | } 100 | return handlers; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/exceptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import { CloseCode } from './closecode'; 9 | 10 | /** 11 | * A SaltyRTC signaling error. 12 | * 13 | * It will result in the connection closing with the specified error code. 14 | */ 15 | export class SignalingError extends Error implements saltyrtc.SignalingError { 16 | public readonly closeCode: number; 17 | constructor(closeCode: number, message: string) { 18 | super(message); 19 | this.message = message; 20 | this.closeCode = closeCode; 21 | this.name = 'SignalingError'; 22 | } 23 | } 24 | 25 | /** 26 | * A signaling error with the close code hardcoded to ProtocolError. 27 | */ 28 | export class ProtocolError extends SignalingError implements saltyrtc.ProtocolError { 29 | constructor(message: string) { 30 | super(CloseCode.ProtocolError, message); 31 | } 32 | } 33 | 34 | /** 35 | * Errors related to the network connection state. 36 | */ 37 | export class ConnectionError extends Error implements saltyrtc.ConnectionError { 38 | constructor(message: string) { 39 | super(message); 40 | this.message = message; 41 | this.name = 'ConnectionError'; 42 | } 43 | } 44 | 45 | /** 46 | * Errors related to validation. 47 | */ 48 | export class ValidationError extends Error implements saltyrtc.ValidationError { 49 | // If this flag is set, then the validation error 50 | // will be converted to a protocol error. 51 | public readonly critical: boolean; 52 | 53 | constructor(message: string, critical: boolean = true) { 54 | super(message); 55 | this.message = message; 56 | this.name = 'ValidationError'; 57 | this.critical = critical; 58 | } 59 | } 60 | 61 | /** 62 | * Crypto related errors. 63 | */ 64 | export class CryptoError extends Error implements saltyrtc.CryptoError { 65 | // A short string used to identify the exception 66 | // independently from the error message. 67 | public readonly code: saltyrtc.CryptoErrorCode; 68 | 69 | constructor(code: saltyrtc.CryptoErrorCode, message: string) { 70 | super(message); 71 | this.name = 'CryptoError'; 72 | this.message = message; 73 | this.code = code; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/keystore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import * as nacl from 'tweetnacl'; 9 | import { CryptoError } from './exceptions'; 10 | import { Log } from './log'; 11 | import { u8aToHex, validateKey } from './utils'; 12 | 13 | /** 14 | * A `Box` contains a nonce and encrypted data. 15 | */ 16 | export class Box implements saltyrtc.Box { 17 | 18 | private _nonce: Uint8Array; 19 | private _nonceLength: number; 20 | private _data: Uint8Array; 21 | 22 | constructor(nonce: Uint8Array, data: Uint8Array, nonceLength: number) { 23 | this._nonce = nonce; 24 | this._nonceLength = nonceLength; 25 | this._data = data; 26 | } 27 | 28 | public get length(): number { 29 | return this._nonce.length + this._data.length; 30 | } 31 | 32 | public get data() { 33 | return this._data; 34 | } 35 | 36 | public get nonce(): Uint8Array { 37 | return this._nonce; 38 | } 39 | 40 | /** 41 | * Parse an Uint8Array, create a Box wrapping the data. 42 | * 43 | * May throw CryptoError instances with the following codes: 44 | * 45 | * - bad-message-length: Message is shorter than the nonce 46 | */ 47 | public static fromUint8Array(array: Uint8Array, nonceLength: number) { 48 | // Validate nonceLength parameter 49 | if (nonceLength === undefined) { 50 | throw new Error('nonceLength parameter not specified'); 51 | } 52 | 53 | // Validate message length 54 | if (array.byteLength <= nonceLength) { 55 | throw new CryptoError('bad-message-length', 'Message is shorter than nonce'); 56 | } 57 | 58 | // Unpack nonce 59 | const nonce = array.slice(0, nonceLength); 60 | 61 | // Unpack data 62 | const data = array.slice(nonceLength); 63 | 64 | // Return box 65 | return new Box(nonce, data, nonceLength); 66 | } 67 | 68 | public toUint8Array(): Uint8Array { 69 | // Return both the nonce and the encrypted data 70 | const box = new Uint8Array(this.length); 71 | box.set(this._nonce); 72 | box.set(this._data, this._nonceLength); 73 | return box; 74 | } 75 | 76 | } 77 | 78 | /** 79 | * A KeyStore holds public and private keys and can handle encryption and 80 | * decryption. 81 | */ 82 | export class KeyStore implements saltyrtc.KeyStore { 83 | // The NaCl key pair 84 | private _keyPair: nacl.BoxKeyPair; 85 | 86 | private logTag: string = '[SaltyRTC.KeyStore]'; 87 | 88 | constructor(secretKey?: Uint8Array | string, log?: saltyrtc.Log) { 89 | if (log === undefined) { 90 | log = new Log('none'); 91 | } 92 | 93 | // Validate argument count (bug prevention) 94 | if (arguments.length > 2) { 95 | throw new Error('Too many arguments in KeyStore constructor'); 96 | } 97 | 98 | // Create new key pair if necessary 99 | if (secretKey === undefined) { 100 | this._keyPair = nacl.box.keyPair(); 101 | log.debug(this.logTag, 'New public key:', u8aToHex(this._keyPair.publicKey)); 102 | } else { 103 | this._keyPair = nacl.box.keyPair.fromSecretKey(validateKey(secretKey, 'Private key')); 104 | log.debug(this.logTag, 'Restored public key:', u8aToHex(this._keyPair.publicKey)); 105 | } 106 | } 107 | 108 | /** 109 | * Create a SharedKeyStore from this instance and the public key of the 110 | * remote peer. 111 | */ 112 | public getSharedKeyStore(publicKey: Uint8Array | string): SharedKeyStore { 113 | return new SharedKeyStore(this.secretKeyBytes, publicKey); 114 | } 115 | 116 | /** 117 | * Return the public key as hex string. 118 | */ 119 | get publicKeyHex(): string { 120 | return u8aToHex(this._keyPair.publicKey); 121 | } 122 | 123 | /** 124 | * Return the public key as Uint8Array. 125 | */ 126 | get publicKeyBytes(): Uint8Array { 127 | return this._keyPair.publicKey; 128 | } 129 | 130 | /** 131 | * Return the secret key as hex string. 132 | */ 133 | get secretKeyHex(): string { 134 | return u8aToHex(this._keyPair.secretKey); 135 | } 136 | 137 | /** 138 | * Return the secret key as Uint8Array. 139 | */ 140 | get secretKeyBytes(): Uint8Array { 141 | return this._keyPair.secretKey; 142 | } 143 | 144 | /** 145 | * Return the full keypair. 146 | */ 147 | get keypair(): nacl.BoxKeyPair { 148 | return this._keyPair; 149 | } 150 | 151 | /** 152 | * Encrypt plain data for the remote peer and return encrypted data as 153 | * bytes. 154 | * 155 | * Note: Encrypting using a SharedKeyStore instance is more efficient when 156 | * encrypting with the same public key more than once. 157 | */ 158 | public encryptRaw(bytes: Uint8Array, nonce: Uint8Array, otherKey: Uint8Array): Uint8Array { 159 | return nacl.box(bytes, nonce, otherKey, this._keyPair.secretKey); 160 | } 161 | 162 | /** 163 | * Encrypt plain data for the remote peer and return encrypted data in a 164 | * box. 165 | * 166 | * Note: Encrypting using a SharedKeyStore instance is more efficient when 167 | * encrypting with the same public key more than once. 168 | */ 169 | public encrypt(bytes: Uint8Array, nonce: Uint8Array, otherKey: Uint8Array): saltyrtc.Box { 170 | const encrypted = this.encryptRaw(bytes, nonce, otherKey); 171 | return new Box(nonce, encrypted, nacl.box.nonceLength); 172 | } 173 | 174 | /** 175 | * Decrypt encrypted bytes from the remote peer and return plain data as 176 | * bytes. 177 | * 178 | * Note: Decrypting using a SharedKeyStore instance is more efficient when 179 | * decrypting with the same public key more than once. 180 | * 181 | * May throw CryptoError instances with the following codes: 182 | * 183 | * - decryption-failed: Data could not be decrypted 184 | */ 185 | public decryptRaw(bytes: Uint8Array, nonce: Uint8Array, otherKey: Uint8Array): Uint8Array { 186 | const data: Uint8Array | null = nacl.box.open(bytes, nonce, otherKey, this._keyPair.secretKey); 187 | if (!data) { 188 | throw new CryptoError('decryption-failed', 'Data could not be decrypted'); 189 | } 190 | return data; 191 | } 192 | 193 | /** 194 | * Decrypt encrypted boxed data from the remote peer and return plain data 195 | * as bytes. 196 | * 197 | * Note: Decrypting using a SharedKeyStore instance is more efficient when 198 | * decrypting with the same public key more than once. 199 | * 200 | * May throw CryptoError instances with the following codes: 201 | * 202 | * - decryption-failed: Data could not be decrypted 203 | */ 204 | public decrypt(box: saltyrtc.Box, otherKey: Uint8Array): Uint8Array { 205 | return this.decryptRaw(box.data, box.nonce, otherKey); 206 | } 207 | } 208 | 209 | /** 210 | * A SharedKeyStore holds the resulting shared key of the local peer's secret 211 | * key and the remote peer's public key. 212 | * 213 | * Note: Since the shared key is only calculated once, using the SharedKeyStore 214 | * should always be preferred over over using the KeyStore instance. 215 | */ 216 | export class SharedKeyStore implements saltyrtc.SharedKeyStore { 217 | // The local NaCl key pair 218 | private _localSecretKey: Uint8Array; 219 | // The remote public key 220 | private _remotePublicKey: Uint8Array; 221 | // The calculated shared key 222 | private _sharedKey: Uint8Array; 223 | 224 | constructor(localSecretKey: Uint8Array | string, remotePublicKey: Uint8Array | string) { 225 | this._localSecretKey = validateKey(localSecretKey, 'Local private key'); 226 | this._remotePublicKey = validateKey(remotePublicKey, 'Remote public key'); 227 | 228 | // Calculate the shared key 229 | this._sharedKey = nacl.box.before(this._remotePublicKey, this._localSecretKey); 230 | } 231 | 232 | /** 233 | * Return the local peer's secret key as hex string. 234 | */ 235 | get localSecretKeyHex(): string { 236 | return u8aToHex(this._localSecretKey); 237 | } 238 | 239 | /** 240 | * Return the local peer's secret key as Uint8Array. 241 | */ 242 | get localSecretKeyBytes(): Uint8Array { 243 | return this._localSecretKey; 244 | } 245 | 246 | /** 247 | * Return the remote peer's public key as a hex string. 248 | */ 249 | get remotePublicKeyHex(): string { 250 | return u8aToHex(this._remotePublicKey); 251 | } 252 | 253 | /** 254 | * Return the remote peer's public key as Uint8Array. 255 | */ 256 | get remotePublicKeyBytes(): Uint8Array { 257 | return this._remotePublicKey; 258 | } 259 | 260 | /** 261 | * Encrypt plain data for the remote peer and return encrypted data as 262 | * bytes. 263 | */ 264 | public encryptRaw(bytes: Uint8Array, nonce: Uint8Array): Uint8Array { 265 | return nacl.box.after(bytes, nonce, this._sharedKey); 266 | } 267 | 268 | /** 269 | * Encrypt plain data for the remote peer and return encrypted data in a 270 | * box. 271 | */ 272 | public encrypt(bytes: Uint8Array, nonce: Uint8Array): saltyrtc.Box { 273 | const encrypted = this.encryptRaw(bytes, nonce); 274 | return new Box(nonce, encrypted, nacl.box.nonceLength); 275 | } 276 | 277 | /** 278 | * Decrypt encrypted bytes from the remote peer and return plain data as 279 | * bytes. 280 | * 281 | * May throw CryptoError instances with the following codes: 282 | * 283 | * - decryption-failed: Data could not be decrypted 284 | */ 285 | public decryptRaw(bytes: Uint8Array, nonce: Uint8Array): Uint8Array { 286 | const data: Uint8Array | null = nacl.box.open.after(bytes, nonce, this._sharedKey); 287 | if (!data) { 288 | throw new CryptoError('decryption-failed', 'Data could not be decrypted'); 289 | } 290 | return data; 291 | } 292 | 293 | /** 294 | * Decrypt encrypted boxed data from the remote peer and return plain data 295 | * as bytes. 296 | * 297 | * May throw CryptoError instances with the following codes: 298 | * 299 | * - decryption-failed: Data could not be decrypted 300 | */ 301 | public decrypt(box: saltyrtc.Box): Uint8Array { 302 | return this.decryptRaw(box.data, box.nonce); 303 | } 304 | } 305 | 306 | export class AuthToken implements saltyrtc.AuthToken { 307 | 308 | private _authToken: Uint8Array = null; 309 | 310 | private logTag: string = '[SaltyRTC.AuthToken]'; 311 | 312 | /** 313 | * May throw CryptoError instances with the following codes: 314 | * 315 | * - bad-token-length 316 | */ 317 | constructor(bytes?: Uint8Array, log?: saltyrtc.Log) { 318 | if (log === undefined) { 319 | log = new Log('none'); 320 | } 321 | 322 | if (typeof bytes === 'undefined') { 323 | this._authToken = nacl.randomBytes(nacl.secretbox.keyLength); 324 | log.debug(this.logTag, 'Generated auth token'); 325 | } else { 326 | if (bytes.byteLength !== nacl.secretbox.keyLength) { 327 | const msg = 'Auth token must be ' + nacl.secretbox.keyLength + ' bytes long.'; 328 | log.error(this.logTag, msg); 329 | throw new CryptoError('bad-token-length', msg); 330 | } 331 | this._authToken = bytes; 332 | log.debug(this.logTag, 'Initialized auth token'); 333 | } 334 | } 335 | 336 | /** 337 | * Return the secret key as Uint8Array. 338 | */ 339 | get keyBytes() { 340 | return this._authToken; 341 | } 342 | 343 | /** 344 | * Return the secret key as hex string. 345 | */ 346 | get keyHex() { 347 | return u8aToHex(this._authToken); 348 | } 349 | 350 | /** 351 | * Encrypt data using the shared auth token. 352 | */ 353 | public encrypt(bytes: Uint8Array, nonce: Uint8Array): saltyrtc.Box { 354 | const encrypted = nacl.secretbox(bytes, nonce, this._authToken); 355 | return new Box(nonce, encrypted, nacl.secretbox.nonceLength); 356 | } 357 | 358 | /** 359 | * Decrypt data using the shared auth token. 360 | * 361 | * May throw CryptoError instances with the following codes: 362 | * 363 | * - decryption-failed: Data could not be decrypted 364 | */ 365 | public decrypt(box: saltyrtc.Box): Uint8Array { 366 | const data: Uint8Array | null = nacl.secretbox.open(box.data, box.nonce, this._authToken); 367 | if (!data) { 368 | throw new CryptoError('decryption-failed', 'Data could not be decrypted'); 369 | } 370 | return data; 371 | } 372 | 373 | } 374 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | /** 9 | * A console log wrapper obeying levels. 10 | */ 11 | export class Log implements saltyrtc.Log { 12 | private _level: saltyrtc.LogLevel; 13 | public debug: (message?: any, ...optionalParams: any[]) => void; 14 | public trace: (message?: any, ...optionalParams: any[]) => void; 15 | public info: (message?: any, ...optionalParams: any[]) => void; 16 | public warn: (message?: any, ...optionalParams: any[]) => void; 17 | public error: (message?: any, ...optionalParams: any[]) => void; 18 | public assert: (condition?: boolean, message?: string, ...data: any[]) => void; 19 | 20 | constructor(level: saltyrtc.LogLevel) { 21 | this.level = level; 22 | } 23 | 24 | public set level(level: saltyrtc.LogLevel) { 25 | // Set level 26 | this._level = level; 27 | 28 | // Reset all 29 | this.debug = this.noop; 30 | this.trace = this.noop; 31 | this.info = this.noop; 32 | this.warn = this.noop; 33 | this.error = this.noop; 34 | this.assert = this.noop; 35 | 36 | // Bind corresponding to level 37 | // noinspection FallThroughInSwitchStatementJS 38 | switch (level) { 39 | case 'debug': 40 | this.debug = console.debug; 41 | this.trace = console.trace; 42 | case 'info': 43 | this.info = console.info; 44 | case 'warn': 45 | this.warn = console.warn; 46 | case 'error': 47 | this.error = console.error; 48 | this.assert = console.assert; 49 | default: 50 | break; 51 | } 52 | } 53 | 54 | public get level(): saltyrtc.LogLevel { 55 | return this._level; 56 | } 57 | 58 | private noop(): void { 59 | // noop 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main.es5.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | export * from './main'; 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entry point for the library. The full public API should be re-exported here. 3 | * 4 | * Copyright (C) 2016-2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | 10 | // Exceptions 11 | import * as exceptions from './exceptions'; 12 | export { exceptions }; 13 | 14 | // Main API 15 | export { SaltyRTCBuilder } from './client'; 16 | export { KeyStore } from './keystore'; 17 | 18 | // API for tasks 19 | export { Box } from './keystore'; 20 | export { Cookie, CookiePair } from './cookie'; 21 | export { CombinedSequence, CombinedSequencePair } from './csn'; 22 | export { EventRegistry } from './eventregistry'; 23 | export { CloseCode, explainCloseCode } from './closecode'; 24 | export { SignalingError, ConnectionError } from './exceptions'; 25 | export { Log } from './log'; 26 | -------------------------------------------------------------------------------- /src/nonce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import { Cookie } from './cookie'; 9 | import { ValidationError } from './exceptions'; 10 | 11 | /** 12 | * A SaltyRTC signaling channel nonce. 13 | * 14 | * This is very similar to the regular nonce, but also contains a source and 15 | * destination byte. That reduces the length of the overflow number to 2 bytes. 16 | * 17 | * Nonce structure: 18 | * 19 | * |CCCCCCCCCCCCCCCC|S|D|OO|QQQQ| 20 | * 21 | * - C: Cookie (16 byte) 22 | * - S: Source byte (1 byte) 23 | * - D: Destination byte (1 byte) 24 | * - O: Overflow number (2 bytes) 25 | * - Q: Sequence number (4 bytes) 26 | */ 27 | export class Nonce { 28 | public static TOTAL_LENGTH = 24; 29 | 30 | private _cookie: Cookie; 31 | private _overflow: number; 32 | private _sequenceNumber: number; 33 | private _source: number; 34 | private _destination: number; 35 | 36 | constructor(cookie: Cookie, overflow: number, sequenceNumber: number, 37 | source: number, destination: number) { 38 | this._cookie = cookie; 39 | this._overflow = overflow; 40 | this._sequenceNumber = sequenceNumber; 41 | this._source = source; 42 | this._destination = destination; 43 | } 44 | 45 | get cookie() { 46 | return this._cookie; 47 | } 48 | get overflow() { 49 | return this._overflow; 50 | } 51 | get sequenceNumber() { 52 | return this._sequenceNumber; 53 | } 54 | get combinedSequenceNumber() { 55 | return (this._overflow * (2 ** 32)) + this._sequenceNumber; 56 | } 57 | get source() { 58 | return this._source; 59 | } 60 | get destination() { 61 | return this._destination; 62 | } 63 | 64 | /** 65 | * Create a signaling nonce from a Uint8Array. 66 | * 67 | * If packet is not exactly 24 bytes long, throw a `ValidationError`. 68 | */ 69 | public static fromUint8Array(packet: Uint8Array): Nonce { 70 | if (packet.byteLength !== this.TOTAL_LENGTH) { 71 | throw new ValidationError('bad-packet-length'); 72 | } 73 | 74 | // Get view to buffer 75 | const view = new DataView( 76 | packet.buffer, packet.byteOffset + Cookie.COOKIE_LENGTH, 8); 77 | 78 | // Parse and return nonce 79 | const cookie = new Cookie(packet.slice(0, Cookie.COOKIE_LENGTH)); 80 | const source = view.getUint8(0); 81 | const destination = view.getUint8(1); 82 | const overflow = view.getUint16(2); 83 | const sequenceNumber = view.getUint32(4); 84 | 85 | return new Nonce(cookie, overflow, sequenceNumber, source, destination); 86 | } 87 | 88 | /** 89 | * Return a Uint8Array containing the signaling nonce data. 90 | */ 91 | public toUint8Array(): Uint8Array { 92 | const buffer = new ArrayBuffer(Nonce.TOTAL_LENGTH); 93 | 94 | const array = new Uint8Array(buffer); 95 | array.set(this._cookie.bytes); 96 | 97 | const view = new DataView(buffer, Cookie.COOKIE_LENGTH, 8); 98 | view.setUint8(0, this._source); 99 | view.setUint8(1, this._destination); 100 | view.setUint16(2, this._overflow); 101 | view.setUint32(4, this._sequenceNumber); 102 | 103 | return array; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/peers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import { CookiePair } from './cookie'; 9 | import { CombinedSequencePair } from './csn'; 10 | import { byteToHex } from './utils'; 11 | 12 | /** 13 | * Base class for peers (initiator or responder). 14 | */ 15 | export abstract class Peer { 16 | protected _id: number; 17 | protected _csnPair = new CombinedSequencePair(); 18 | protected _cookiePair: saltyrtc.CookiePair; 19 | protected _permanentSharedKey: saltyrtc.SharedKeyStore | null = null; 20 | protected _sessionSharedKey: saltyrtc.SharedKeyStore | null = null; 21 | 22 | constructor(id: number, cookiePair?: saltyrtc.CookiePair) { 23 | this._id = id; 24 | if (cookiePair === undefined) { 25 | this._cookiePair = new CookiePair(); 26 | } else { 27 | this._cookiePair = cookiePair; 28 | } 29 | } 30 | 31 | public get id(): number { 32 | return this._id; 33 | } 34 | 35 | public get hexId(): string { 36 | return byteToHex(this._id); 37 | } 38 | 39 | public get csnPair(): CombinedSequencePair { 40 | return this._csnPair; 41 | } 42 | 43 | public get cookiePair(): saltyrtc.CookiePair { 44 | return this._cookiePair; 45 | } 46 | 47 | public get permanentSharedKey(): saltyrtc.SharedKeyStore | null { 48 | return this._permanentSharedKey; 49 | } 50 | 51 | public get sessionSharedKey(): saltyrtc.SharedKeyStore | null { 52 | return this._sessionSharedKey; 53 | } 54 | 55 | public abstract get name(): string; 56 | 57 | public setPermanentSharedKey(remotePermanentKey: Uint8Array, localPermanentKey: saltyrtc.KeyStore) { 58 | this._permanentSharedKey = localPermanentKey.getSharedKeyStore(remotePermanentKey); 59 | } 60 | 61 | public setSessionSharedKey(remoteSessionKey: Uint8Array, localSessionKey: saltyrtc.KeyStore) { 62 | this._sessionSharedKey = localSessionKey.getSharedKeyStore(remoteSessionKey); 63 | } 64 | } 65 | 66 | /** 67 | * Base class for initiator and responder. 68 | */ 69 | export abstract class Client extends Peer { 70 | protected _localSessionKey: saltyrtc.KeyStore | null = null; 71 | 72 | public get localSessionKey(): saltyrtc.KeyStore | null { 73 | return this._localSessionKey; 74 | } 75 | 76 | public setLocalSessionKey(localSessionKey: saltyrtc.KeyStore) { 77 | this._localSessionKey = localSessionKey; 78 | } 79 | 80 | public setSessionSharedKey(remoteSessionKey: Uint8Array, localSessionKey?: saltyrtc.KeyStore) { 81 | if (!localSessionKey) { 82 | localSessionKey = this._localSessionKey; 83 | } else { 84 | this._localSessionKey = localSessionKey; 85 | } 86 | super.setSessionSharedKey(remoteSessionKey, localSessionKey); 87 | } 88 | } 89 | 90 | /** 91 | * Information about the initiator. Used by responder during handshake. 92 | */ 93 | export class Initiator extends Client { 94 | public static ID = 0x01; 95 | 96 | public connected = false; 97 | public handshakeState: 'new' | 'token-sent' | 'key-sent' | 'key-received' 98 | | 'auth-sent' | 'auth-received' 99 | = 'new'; 100 | 101 | constructor(remotePermanentKey: Uint8Array, localPermanentKey: saltyrtc.KeyStore) { 102 | super(Initiator.ID); 103 | this.setPermanentSharedKey(remotePermanentKey, localPermanentKey); 104 | } 105 | 106 | public get name(): string { 107 | return 'Initiator'; 108 | } 109 | } 110 | 111 | /** 112 | * Information about a responder. Used by initiator during handshake. 113 | */ 114 | export class Responder extends Client { 115 | public handshakeState: 'new' | 'token-received' | 'key-received' 116 | | 'key-sent' | 'auth-received' | 'auth-sent' 117 | = 'new'; 118 | private _counter: number; 119 | 120 | constructor(id: number, counter: number) { 121 | super(id); 122 | this._counter = counter; 123 | } 124 | 125 | public get name(): string { 126 | return 'Responder ' + this.id; 127 | } 128 | 129 | get counter(): number { 130 | return this._counter; 131 | } 132 | } 133 | 134 | /** 135 | * Information about the server. 136 | */ 137 | export class Server extends Peer { 138 | public static ID = 0x00; 139 | 140 | public handshakeState: 'new' | 'hello-sent' | 'auth-sent' | 'done' = 'new'; 141 | 142 | constructor() { 143 | super(Server.ID); 144 | } 145 | 146 | public get name(): string { 147 | return 'Server'; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/signaling.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | export { Signaling } from './signaling/common'; 9 | export { InitiatorSignaling } from './signaling/initiator'; 10 | export { ResponderSignaling } from './signaling/responder'; 11 | -------------------------------------------------------------------------------- /src/signaling/handoverstate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | export class HandoverState { 9 | 10 | private _local: boolean; 11 | private _peer: boolean; 12 | 13 | constructor() { 14 | this.reset(); 15 | } 16 | 17 | public get local(): boolean { 18 | return this._local; 19 | } 20 | 21 | public set local(state: boolean) { 22 | const wasBoth = this.both; 23 | this._local = state; 24 | if (!wasBoth && this.both && this.onBoth !== undefined) { 25 | this.onBoth(); 26 | } 27 | } 28 | 29 | public get peer(): boolean { 30 | return this._peer; 31 | } 32 | 33 | public set peer(state: boolean) { 34 | const wasBoth = this.both; 35 | this._peer = state; 36 | if (!wasBoth && this.both && this.onBoth !== undefined) { 37 | this.onBoth(); 38 | } 39 | } 40 | 41 | /** 42 | * Return true if both peers have finished the handover. 43 | */ 44 | public get both(): boolean { 45 | return this._local === true && this._peer === true; 46 | } 47 | 48 | /** 49 | * Return true if any peer has finished the handover. 50 | */ 51 | public get any(): boolean { 52 | return this._local === true || this._peer === true; 53 | } 54 | 55 | /** 56 | * Reset handover state. 57 | */ 58 | public reset() { 59 | this._local = false; 60 | this._peer = false; 61 | } 62 | 63 | /** 64 | * Callback that is called when both local and peer have done the 65 | * handover. 66 | */ 67 | public onBoth: () => void; 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/signaling/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | /** 9 | * Return `true` if byte is a valid responder id (in the range 0x02-0xff). 10 | */ 11 | export function isResponderId(id: number): boolean { 12 | return id >= 0x02 && id <= 0xff; 13 | } 14 | -------------------------------------------------------------------------------- /src/signaling/responder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import { CloseCode } from '../closecode'; 9 | import { ProtocolError, SignalingError, ValidationError } from '../exceptions'; 10 | import { KeyStore } from '../keystore'; 11 | import { Nonce } from '../nonce'; 12 | import { Initiator, Server } from '../peers'; 13 | import { arrayToBuffer, byteToHex } from '../utils'; 14 | import { Signaling } from './common'; 15 | import { isResponderId } from './helpers'; 16 | 17 | export class ResponderSignaling extends Signaling { 18 | 19 | protected logTag: string = '[SaltyRTC.Responder]'; 20 | 21 | protected initiator: Initiator = null; 22 | 23 | /** 24 | * Create a new responder signaling instance. 25 | */ 26 | constructor(client: saltyrtc.SaltyRTC, host: string, port: number, serverKey: Uint8Array, 27 | tasks: saltyrtc.Task[], pingInterval: number, 28 | permanentKey: saltyrtc.KeyStore, initiatorPubKey: Uint8Array, authToken?: saltyrtc.AuthToken) { 29 | super(client, host, port, serverKey, tasks, pingInterval, 30 | permanentKey, authToken === undefined ? initiatorPubKey : undefined); 31 | this.role = 'responder'; 32 | this.initiator = new Initiator(initiatorPubKey, this.permanentKey); 33 | if (authToken !== undefined) { 34 | this.authToken = authToken; 35 | } else { 36 | // If we trust the initiator, don't send a token message 37 | this.initiator.handshakeState = 'token-sent'; 38 | } 39 | } 40 | 41 | /** 42 | * The responder needs to use the initiator public permanent key as connection path. 43 | */ 44 | protected getWebsocketPath(): string { 45 | return this.initiator.permanentSharedKey.remotePublicKeyHex; 46 | } 47 | 48 | /** 49 | * Encrypt data for the initiator. 50 | */ 51 | protected encryptHandshakeDataForPeer(receiver: number, messageType: string, 52 | payload: Uint8Array, nonceBytes: Uint8Array): saltyrtc.Box { 53 | // Validate receiver 54 | if (isResponderId(receiver)) { 55 | throw new ProtocolError('Responder may not encrypt messages for other responders: ' + receiver); 56 | } else if (receiver !== Signaling.SALTYRTC_ADDR_INITIATOR) { 57 | throw new ProtocolError('Bad receiver byte: ' + receiver); 58 | } 59 | 60 | switch (messageType) { 61 | case 'token': 62 | return this.authToken.encrypt(payload, nonceBytes); 63 | case 'key': 64 | return this.initiator.permanentSharedKey.encrypt(payload, nonceBytes); 65 | default: 66 | const sessionSharedKey = this.getPeer().sessionSharedKey; 67 | if (sessionSharedKey === null) { 68 | throw new ProtocolError('Trying to encrypt for peer using session key, but session key is null'); 69 | } 70 | return sessionSharedKey.encrypt(payload, nonceBytes); 71 | } 72 | } 73 | 74 | protected getPeer(): Initiator | null { 75 | if (this.initiator !== null) { 76 | return this.initiator; 77 | } 78 | return null; 79 | } 80 | 81 | /** 82 | * Get the responder instance with the specified id. 83 | */ 84 | protected getPeerWithId(id: number): Server | Initiator | null { 85 | if (id === Signaling.SALTYRTC_ADDR_SERVER) { 86 | return this.server; 87 | } else if (id === Signaling.SALTYRTC_ADDR_INITIATOR) { 88 | return this.initiator; 89 | } else { 90 | throw new ProtocolError('Invalid peer id: ' + id); 91 | } 92 | } 93 | 94 | /** 95 | * Handle signaling error during peer handshake. 96 | */ 97 | protected handlePeerHandshakeSignalingError(e: SignalingError, source: number | null): void { 98 | // Close the connection to the server 99 | this.resetConnection(e.closeCode); 100 | } 101 | 102 | protected onPeerHandshakeMessage(box: saltyrtc.Box, nonce: Nonce): void { 103 | // Validate nonce destination 104 | if (nonce.destination !== this.address) { 105 | throw new ProtocolError('Message destination does not match our address'); 106 | } 107 | 108 | let payload: Uint8Array; 109 | 110 | // Handle server messages 111 | if (nonce.source === Signaling.SALTYRTC_ADDR_SERVER) { 112 | // Nonce claims to come from server. 113 | // Try to decrypt data accordingly. 114 | try { 115 | payload = this.server.sessionSharedKey.decrypt(box); 116 | } catch (e) { 117 | if (e.name === 'CryptoError' && e.code === 'decryption-failed') { 118 | throw new SignalingError( 119 | CloseCode.ProtocolError, 'Could not decrypt server message.'); 120 | } else { 121 | throw e; 122 | } 123 | } 124 | 125 | const msg: saltyrtc.Message = this.decodeMessage(payload, 'server'); 126 | switch (msg.type) { 127 | case 'new-initiator': 128 | this.log.debug(this.logTag, 'Received new-initiator message'); 129 | this.handleNewInitiator(); 130 | break; 131 | case 'send-error': 132 | this.log.debug(this.logTag, 'Received send-error message'); 133 | this.handleSendError(msg as saltyrtc.messages.SendError); 134 | break; 135 | case 'disconnected': 136 | this.log.debug(this.logTag, 'Received disconnected message'); 137 | this.handleDisconnected(msg as saltyrtc.messages.Disconnected); 138 | break; 139 | default: 140 | throw new ProtocolError('Received unexpected server message: ' + msg.type); 141 | } 142 | 143 | // Handle peer messages 144 | } else if (nonce.source === Signaling.SALTYRTC_ADDR_INITIATOR) { 145 | payload = this.decryptInitiatorMessage(box); 146 | 147 | // Dispatch message 148 | let msg: saltyrtc.Message; 149 | switch (this.initiator.handshakeState) { 150 | case 'new': 151 | throw new ProtocolError('Unexpected peer handshake message'); 152 | case 'key-sent': 153 | // Expect key message 154 | msg = this.decodeMessage(payload, 'key', true); 155 | this.log.debug(this.logTag, 'Received key'); 156 | this.handleKey(msg as saltyrtc.messages.Key); 157 | this.sendAuth(nonce); 158 | break; 159 | case 'auth-sent': 160 | // Expect auth message 161 | msg = this.decodeMessage(payload, 'auth', true); 162 | this.log.debug(this.logTag, 'Received auth'); 163 | this.handleAuth(msg as saltyrtc.messages.InitiatorAuth, nonce); 164 | 165 | // We're connected! 166 | this.setState('task'); 167 | this.log.info(this.logTag, 'Peer handshake done'); 168 | 169 | break; 170 | default: 171 | throw new SignalingError(CloseCode.InternalError, 'Unknown initiator handshake state'); 172 | } 173 | 174 | // Handle unknown source 175 | } else { 176 | throw new SignalingError(CloseCode.InternalError, 177 | 'Message source is neither the server nor the initiator'); 178 | } 179 | } 180 | 181 | /** 182 | * Decrypt messages from the initiator. 183 | * 184 | * @param box encrypted box containing messag.e 185 | * @returns The decrypted message bytes. 186 | * @throws SignalingError 187 | */ 188 | private decryptInitiatorMessage(box: saltyrtc.Box): Uint8Array { 189 | switch (this.initiator.handshakeState) { 190 | case 'new': 191 | case 'token-sent': 192 | case 'key-received': 193 | throw new ProtocolError('Received message in ' + this.initiator.handshakeState + ' state.'); 194 | case 'key-sent': 195 | // Expect a key message, encrypted with the permanent keys 196 | try { 197 | return this.initiator.permanentSharedKey.decrypt(box); 198 | } catch (e) { 199 | if (e.name === 'CryptoError' && e.code === 'decryption-failed') { 200 | throw new SignalingError( 201 | CloseCode.ProtocolError, 'Could not decrypt key message.'); 202 | } else { 203 | throw e; 204 | } 205 | } 206 | case 'auth-sent': 207 | case 'auth-received': 208 | // Otherwise, it must be encrypted with the session key 209 | try { 210 | return this.initiator.sessionSharedKey.decrypt(box); 211 | } catch (e) { 212 | if (e.name === 'CryptoError' && e.code === 'decryption-failed') { 213 | throw new SignalingError( 214 | CloseCode.ProtocolError, 'Could not decrypt initiator session message.'); 215 | } else { 216 | throw e; 217 | } 218 | } 219 | default: 220 | throw new ProtocolError('Invalid handshake state: ' + this.initiator.handshakeState); 221 | } 222 | } 223 | 224 | /** 225 | * Close when a new initiator has connected. 226 | * 227 | * Note: This deviates from the intention of the specification to allow 228 | * for more than one connection towards an initiator over the same 229 | * WebSocket connection. 230 | */ 231 | protected onUnhandledSignalingServerMessage(msg: saltyrtc.Message): void { 232 | if (msg.type === 'new-initiator') { 233 | this.log.debug(this.logTag, 'Received new-initiator message after peer handshake completed, ' + 234 | 'closing'); 235 | this.resetConnection(CloseCode.ClosingNormal); 236 | } else { 237 | this.log.warn(this.logTag, 'Unexpected server message type:', msg.type); 238 | } 239 | } 240 | 241 | protected sendClientHello(): void { 242 | const message: saltyrtc.messages.ClientHello = { 243 | type: 'client-hello', 244 | key: arrayToBuffer(this.permanentKey.publicKeyBytes), 245 | }; 246 | const packet: Uint8Array = this.buildPacket(message, this.server, false); 247 | this.log.debug(this.logTag, 'Sending client-hello'); 248 | this.ws.send(packet); 249 | this.server.handshakeState = 'hello-sent'; 250 | } 251 | 252 | protected handleServerAuth(msg: saltyrtc.messages.ServerAuth, nonce: Nonce): void { 253 | if (nonce.destination > 0xff || nonce.destination < 0x02) { 254 | this.log.error(this.logTag, 'Invalid nonce destination:', nonce.destination); 255 | throw new ValidationError('Invalid nonce destination: ' + nonce.destination); 256 | } 257 | this.address = nonce.destination; 258 | this.log.debug(this.logTag, 'Server assigned address', byteToHex(this.address)); 259 | this.logTag = '[SaltyRTC.Responder.' + byteToHex(this.address) + ']'; 260 | 261 | // Validate repeated cookie 262 | this.validateRepeatedCookie(this.server, new Uint8Array(msg.your_cookie)); 263 | 264 | // Validate server public key 265 | if (this.serverPublicKey != null) { 266 | try { 267 | this.validateSignedKeys(new Uint8Array(msg.signed_keys), nonce, this.serverPublicKey); 268 | } catch (e) { 269 | if (e.name === 'ValidationError') { 270 | throw new ProtocolError('Verification of signed_keys failed: ' + e.message); 271 | } 272 | throw e; 273 | } 274 | } else if (msg.signed_keys !== null && msg.signed_keys !== undefined) { 275 | this.log.warn(this.logTag, "Server sent signed keys, but we're not verifying them."); 276 | } 277 | 278 | this.initiator.connected = msg.initiator_connected; 279 | this.log.debug(this.logTag, 'Initiator', this.initiator.connected ? '' : 'not', 'connected'); 280 | 281 | this.server.handshakeState = 'done'; 282 | } 283 | 284 | /** 285 | * Handle an incoming new-initiator message. 286 | */ 287 | private handleNewInitiator(): void { 288 | this.initiator = new Initiator( 289 | this.initiator.permanentSharedKey.remotePublicKeyBytes, this.permanentKey); 290 | this.initiator.connected = true; 291 | this.initPeerHandshake(); 292 | } 293 | 294 | /** 295 | * Init the peer handshake. 296 | * 297 | * If the initiator is already connected, send a token. 298 | * Otherwise, do nothing and wait for a new-initiator message. 299 | */ 300 | protected initPeerHandshake(): void { 301 | if (this.initiator.connected) { 302 | // Only send token if we don't trust the initiator. 303 | if (this.peerTrustedKey === null) { 304 | this.sendToken(); 305 | } 306 | this.sendKey(); 307 | } 308 | } 309 | 310 | /** 311 | * Send a 'token' message to the initiator. 312 | */ 313 | protected sendToken(): void { 314 | const message: saltyrtc.messages.Token = { 315 | type: 'token', 316 | key: arrayToBuffer(this.permanentKey.publicKeyBytes), 317 | }; 318 | const packet: Uint8Array = this.buildPacket(message, this.initiator); 319 | this.log.debug(this.logTag, 'Sending token'); 320 | this.ws.send(packet); 321 | this.initiator.handshakeState = 'token-sent'; 322 | } 323 | 324 | /** 325 | * Send our public session key to the initiator. 326 | */ 327 | private sendKey(): void { 328 | // Generate our own session key 329 | this.initiator.setLocalSessionKey(new KeyStore(undefined, this.log)); 330 | 331 | // Send public key to initiator 332 | const replyMessage: saltyrtc.messages.Key = { 333 | type: 'key', 334 | key: arrayToBuffer(this.initiator.localSessionKey.publicKeyBytes), 335 | }; 336 | const packet: Uint8Array = this.buildPacket(replyMessage, this.initiator); 337 | this.log.debug(this.logTag, 'Sending key'); 338 | this.ws.send(packet); 339 | this.initiator.handshakeState = 'key-sent'; 340 | } 341 | 342 | /** 343 | * The initiator sends his public session key. 344 | */ 345 | private handleKey(msg: saltyrtc.messages.Key): void { 346 | // Generate the shared session key 347 | this.initiator.setSessionSharedKey(new Uint8Array(msg.key)); 348 | this.initiator.handshakeState = 'key-received'; 349 | } 350 | 351 | /** 352 | * Repeat the initiator's cookie. 353 | */ 354 | private sendAuth(nonce: Nonce): void { 355 | // Ensure again that cookies are different 356 | if (nonce.cookie.equals(this.initiator.cookiePair.ours)) { 357 | throw new ProtocolError('Their cookie and our cookie are the same.'); 358 | } 359 | 360 | // Prepare task data 361 | const taskData: saltyrtc.TaskData = {}; 362 | for (const task of this.tasks) { 363 | taskData[task.getName()] = task.getData(); 364 | } 365 | const taskNames = this.tasks.map((task) => task.getName()); 366 | 367 | // Send auth 368 | const message: saltyrtc.messages.ResponderAuth = { 369 | type: 'auth', 370 | your_cookie: arrayToBuffer(nonce.cookie.bytes), 371 | tasks: taskNames, 372 | data: taskData, 373 | }; 374 | const packet: Uint8Array = this.buildPacket(message, this.initiator); 375 | this.log.debug(this.logTag, 'Sending auth'); 376 | this.ws.send(packet); 377 | this.initiator.handshakeState = 'auth-sent'; 378 | } 379 | 380 | /** 381 | * The initiator repeats our cookie and sends the chosen task. 382 | */ 383 | private handleAuth(msg: saltyrtc.messages.InitiatorAuth, nonce: Nonce): void { 384 | // Validate repeated cookie 385 | this.validateRepeatedCookie(this.initiator, new Uint8Array(msg.your_cookie)); 386 | 387 | // Validate task data 388 | try { 389 | ResponderSignaling.validateTaskInfo(msg.task, msg.data); 390 | } catch (e) { 391 | if (e.name === 'ValidationError') { 392 | throw new ProtocolError('Peer sent invalid task info: ' + e.message); 393 | } 394 | throw e; 395 | } 396 | 397 | // Find selected task 398 | let selectedTask: saltyrtc.Task = null; 399 | for (const task of this.tasks) { 400 | if (task.getName() === msg.task) { 401 | selectedTask = task; 402 | this.log.info(this.logTag, 'Task', msg.task, 'has been selected'); 403 | break; 404 | } 405 | } 406 | 407 | // Initialize task 408 | if (selectedTask === null) { 409 | throw new SignalingError(CloseCode.ProtocolError, 'Initiator selected unknown task'); 410 | } else { 411 | this.initTask(selectedTask, msg.data[selectedTask.getName()]); 412 | } 413 | 414 | // Ok! 415 | this.log.debug(this.logTag, 'Initiator authenticated'); 416 | this.initiator.cookiePair.theirs = nonce.cookie; 417 | this.initiator.handshakeState = 'auth-received'; 418 | } 419 | 420 | /** 421 | * Validate task info. Throw ValidationError if validation fails. 422 | * @param name Task name 423 | * @param data Task data 424 | * @throws ValidationError 425 | */ 426 | private static validateTaskInfo(name: string, data: object): void { 427 | if (name.length === 0) { 428 | throw new ValidationError('Task name must not be empty'); 429 | } 430 | if (Object.keys(data).length < 1) { 431 | throw new ValidationError('Task data must not be empty'); 432 | } 433 | if (Object.keys(data).length > 1) { 434 | throw new ValidationError('Task data must contain exactly 1 key'); 435 | } 436 | if (!data.hasOwnProperty(name)) { 437 | throw new ValidationError('Task data must contain an entry for the chosen task'); 438 | } 439 | } 440 | 441 | /** 442 | * Handle a send error. 443 | */ 444 | protected _handleSendError(receiver: number): void { 445 | // Validate receiver byte 446 | if (receiver !== Signaling.SALTYRTC_ADDR_INITIATOR) { 447 | throw new ProtocolError('Outgoing c2c messages must have been sent to the initiator'); 448 | } 449 | 450 | // Notify application 451 | this.client.emit({type: 'signaling-connection-lost', data: receiver}); 452 | 453 | // Reset connection 454 | this.resetConnection(CloseCode.ProtocolError); 455 | 456 | // TODO: Maybe keep ws connection open and wait for reconnect (#63) 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2016-2022 Threema GmbH 3 | * 4 | * This software may be modified and distributed under the terms 5 | * of the MIT license. See the `LICENSE.md` file for details. 6 | */ 7 | 8 | import { ValidationError } from './exceptions'; 9 | 10 | /** 11 | * Convert an Uint8Array to a hex string. 12 | * 13 | * Example: 14 | * 15 | * >>> u8aToHex(new Uint8Array([1, 255])) 16 | * "01ff" 17 | */ 18 | export function u8aToHex(array: Uint8Array): string { 19 | const results: string[] = []; 20 | for (const arrayByte of array) { 21 | results.push(arrayByte.toString(16).replace(/^([\da-f])$/, '0$1')); 22 | } 23 | return results.join(''); 24 | } 25 | 26 | /** 27 | * Convert a hexadecimal string to a Uint8Array. 28 | * 29 | * Example: 30 | * 31 | * >>> hexToU8a("01ff") 32 | * [1, 255] 33 | */ 34 | export function hexToU8a(hexstring: string): Uint8Array { 35 | let array; 36 | let i; 37 | let j = 0; 38 | let k; 39 | let ref; 40 | 41 | // If number of characters is odd, add padding 42 | if (hexstring.length % 2 === 1) { 43 | hexstring = '0' + hexstring; 44 | } 45 | 46 | array = new Uint8Array(hexstring.length / 2); 47 | for (i = k = 0, ref = hexstring.length; k <= ref; i = k += 2) { 48 | array[j++] = parseInt(hexstring.substr(i, 2), 16); 49 | } 50 | return array; 51 | } 52 | 53 | /** 54 | * Convert a byte to its hex string representation. 55 | */ 56 | export function byteToHex(value: number) { 57 | return '0x' + ('00' + value.toString(16)).substr(-2); 58 | } 59 | 60 | /** 61 | * Generate a NON CRYPTOGRAPHICALLY SECURE random string. 62 | * 63 | * Based on http://stackoverflow.com/a/1349426/284318. 64 | */ 65 | export function randomString( 66 | length = 32, 67 | chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', 68 | ): string { 69 | let str = ''; 70 | for (let i = 0; i < length; i++) { 71 | str += chars.charAt(Math.floor(Math.random() * chars.length)); 72 | } 73 | return str; 74 | } 75 | 76 | /** 77 | * Generate a random 32 bit unsigned integer. 78 | */ 79 | export function randomUint32(): number { 80 | const crypto = window.crypto || (window as any).msCrypto; 81 | return crypto.getRandomValues(new Uint32Array(1))[0]; 82 | } 83 | 84 | /** 85 | * Concatenate multiple Uint8Array objects. 86 | * 87 | * Based on http://www.2ality.com/2015/10/concatenating-typed-arrays.html 88 | */ 89 | export function concat(...arrays: Uint8Array[]): Uint8Array { 90 | let totalLength = 0; 91 | for (const arr of arrays) { 92 | totalLength += arr.length; 93 | } 94 | const result = new Uint8Array(totalLength); 95 | let offset = 0; 96 | for (const arr of arrays) { 97 | result.set(arr, offset); 98 | offset += arr.length; 99 | } 100 | return result; 101 | } 102 | 103 | /** 104 | * Wait for a condition. 105 | * 106 | * @param test a function that tests whether the condition has been met. 107 | * @param delayMs wait duration between retries. 108 | * @param retries number of times to retry. 109 | * @param success the success callback. 110 | * @param error the error callback. 111 | */ 112 | export function waitFor(test: () => boolean, delayMs: number, retries: number, success: () => any, error: () => any) { 113 | // If condition is not yet met, decrease number of retries and retry 114 | if (test() === false) { 115 | if (retries === 1) { // This is the last retry 116 | error(); 117 | } else { 118 | setTimeout(() => waitFor(test, delayMs, retries - 1, success, error), delayMs); 119 | } 120 | return; 121 | } 122 | 123 | // Otherwise, run success callback. 124 | success(); 125 | } 126 | 127 | /** 128 | * Determine whether a value is a string. 129 | */ 130 | export function isString(value: any): value is string { 131 | return typeof value === 'string' || value instanceof String; 132 | } 133 | 134 | /** 135 | * Validate a 32 byte key. Return the validated key as a Uint8Array instance. 136 | * 137 | * @param key Either an Uint8Array or a hex string. 138 | * @param name Name of the key for the exception. 139 | * @throws ValidationError if key is invalid. 140 | */ 141 | export function validateKey(key: Uint8Array | string, name = 'Key'): Uint8Array { 142 | // Validate type 143 | let out: Uint8Array; 144 | if (isString(key)) { 145 | if (key.length !== 64) { 146 | throw new ValidationError(name + ' must be 32 bytes long'); 147 | } 148 | out = hexToU8a(key); 149 | } else if (key instanceof Uint8Array) { 150 | out = key; 151 | } else { 152 | throw new ValidationError(name + ' must be an Uint8Array or a hex string'); 153 | } 154 | 155 | // Validate length 156 | if (out.byteLength !== 32) { 157 | throw new ValidationError(name + ' must be 32 bytes long'); 158 | } 159 | 160 | return out; 161 | } 162 | 163 | /** 164 | * Compare two Uint8Array instances. Return true if all elements are equal (compared using ===). 165 | */ 166 | export function arraysAreEqual(a1: Uint8Array, a2: Uint8Array): boolean { 167 | if (a1.length !== a2.length) { 168 | return false; 169 | } 170 | for (let i = 0; i < a1.length; i++) { 171 | if (a1[i] !== a2[i]) { 172 | return false; 173 | } 174 | } 175 | return true; 176 | } 177 | 178 | /** 179 | * Convert a TypedArray to an ArrayBuffer. 180 | * 181 | * **Important:** If the source array's data occupies the underlying buffer 182 | * completely, the underlying buffer will be returned directly. Thus, the 183 | * caller may not assume that the data has been copied. 184 | */ 185 | export function arrayToBuffer(array: ArrayBufferView): ArrayBuffer { 186 | if (array.byteOffset === 0 && array.byteLength === array.buffer.byteLength) { 187 | return array.buffer; 188 | } 189 | return array.buffer.slice(array.byteOffset, array.byteOffset + array.byteLength); 190 | } 191 | -------------------------------------------------------------------------------- /tests/client.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import * as nacl from 'tweetnacl'; 6 | 7 | import { SaltyRTCBuilder } from '../src/client'; 8 | import { ConnectionError } from '../src/exceptions'; 9 | import { Box, KeyStore } from '../src/keystore'; 10 | import { u8aToHex } from '../src/utils'; 11 | import { DummyTask } from './testtasks'; 12 | import { Runnable, sleep } from './utils'; 13 | 14 | export default () => { describe('client', function() { 15 | 16 | describe('SaltyRTCBuilder', function() { 17 | const dummyData = new Uint8Array(0); 18 | const dummyBox = new Box(dummyData, dummyData, 0); 19 | 20 | it('can construct an untrusted initiator', () => { 21 | const tasks = [new DummyTask()]; 22 | const salty = new SaltyRTCBuilder() 23 | .connectTo('localhost') 24 | .withKeyStore(new KeyStore()) 25 | .usingTasks(tasks) 26 | .asInitiator(); 27 | expect(((salty as any).signaling as any).role).toEqual('initiator'); 28 | expect(((salty as any).signaling as any).peerTrustedKey).toBeNull(); 29 | expect(((salty as any).signaling as any).tasks).toEqual(tasks); 30 | expect(((salty as any).signaling as any).pingInterval).toEqual(0); 31 | }); 32 | 33 | it('can construct a trusted initiator', () => { 34 | const tasks = [new DummyTask()]; 35 | const trustedKey = nacl.randomBytes(32); 36 | const salty = new SaltyRTCBuilder() 37 | .connectTo('localhost') 38 | .withKeyStore(new KeyStore()) 39 | .withTrustedPeerKey(trustedKey) 40 | .usingTasks(tasks) 41 | .asInitiator(); 42 | expect(((salty as any).signaling as any).role).toEqual('initiator'); 43 | expect(((salty as any).signaling as any).peerTrustedKey).toEqual(trustedKey); 44 | expect(((salty as any).signaling as any).tasks).toEqual(tasks); 45 | expect(((salty as any).signaling as any).pingInterval).toEqual(0); 46 | }); 47 | 48 | it('can construct an untrusted responder', () => { 49 | const tasks = [new DummyTask()]; 50 | const pubKey = nacl.randomBytes(32); 51 | const authToken = nacl.randomBytes(32); 52 | const salty = new SaltyRTCBuilder() 53 | .connectTo('localhost') 54 | .withKeyStore(new KeyStore()) 55 | .initiatorInfo(pubKey, authToken) 56 | .usingTasks(tasks) 57 | .asResponder(); 58 | expect(((salty as any).signaling as any).role).toEqual('responder'); 59 | expect(((salty as any).signaling as any).initiator.permanentSharedKey.remotePublicKeyBytes).toEqual(pubKey); 60 | expect(((salty as any).signaling as any).authToken.keyBytes).toEqual(authToken); 61 | expect(((salty as any).signaling as any).peerTrustedKey).toBeNull(); 62 | expect(((salty as any).signaling as any).tasks).toEqual(tasks); 63 | expect(((salty as any).signaling as any).pingInterval).toEqual(0); 64 | }); 65 | 66 | it('can construct a trusted responder', () => { 67 | const tasks = [new DummyTask()]; 68 | const trustedKey = nacl.randomBytes(32); 69 | const salty = new SaltyRTCBuilder() 70 | .connectTo('localhost') 71 | .withKeyStore(new KeyStore()) 72 | .withTrustedPeerKey(trustedKey) 73 | .usingTasks(tasks) 74 | .asResponder(); 75 | expect(((salty as any).signaling as any).role).toEqual('responder'); 76 | expect(((salty as any).signaling as any).peerTrustedKey).toEqual(trustedKey); 77 | expect(((salty as any).signaling as any).initiator.permanentSharedKey.remotePublicKeyBytes).toEqual(trustedKey); 78 | expect(((salty as any).signaling as any).authToken).toBeNull(); 79 | expect(((salty as any).signaling as any).tasks).toEqual(tasks); 80 | expect(((salty as any).signaling as any).pingInterval).toEqual(0); 81 | }); 82 | 83 | it('accepts hex strings as initiator pub key / auth token', () => { 84 | const pubKey = nacl.randomBytes(32); 85 | const authToken = nacl.randomBytes(32); 86 | const salty = new SaltyRTCBuilder() 87 | .connectTo('localhost') 88 | .withKeyStore(new KeyStore()) 89 | .initiatorInfo(u8aToHex(pubKey), u8aToHex(authToken)) 90 | .usingTasks([new DummyTask()]) 91 | .asResponder(); 92 | expect(((salty as any).signaling as any).initiator.permanentSharedKey.remotePublicKeyBytes).toEqual(pubKey); 93 | expect(((salty as any).signaling as any).authToken.keyBytes).toEqual(authToken); 94 | }); 95 | 96 | it('accepts hex strings as peer trusted key', () => { 97 | const trustedKey = nacl.randomBytes(32); 98 | const salty = new SaltyRTCBuilder() 99 | .connectTo('localhost') 100 | .withKeyStore(new KeyStore()) 101 | .withTrustedPeerKey(u8aToHex(trustedKey)) 102 | .usingTasks([new DummyTask()]) 103 | .asResponder(); 104 | expect(((salty as any).signaling as any).peerTrustedKey).toEqual(trustedKey); 105 | }); 106 | 107 | it('accepts websocket ping interval', () => { 108 | const salty = new SaltyRTCBuilder() 109 | .connectTo('localhost') 110 | .withKeyStore(new KeyStore()) 111 | .usingTasks([new DummyTask()]) 112 | .withPingInterval(10) 113 | .asInitiator(); 114 | expect(((salty as any).signaling as any).pingInterval).toEqual(10); 115 | }); 116 | 117 | it('validates websocket ping interval', () => { 118 | const builder = new SaltyRTCBuilder(); 119 | expect(() => builder.withPingInterval(-10)).toThrowError('Ping interval may not be negative'); 120 | }); 121 | 122 | it('cannot encrypt/decrypt before the remote peer is established', () => { 123 | const salty = new SaltyRTCBuilder() 124 | .connectTo('localhost') 125 | .withKeyStore(new KeyStore()) 126 | .usingTasks([new DummyTask()]) 127 | .withPingInterval(10) 128 | .asInitiator(); 129 | 130 | const encrypt = () => salty.encryptForPeer(dummyData, dummyData); 131 | const decrypt = () => salty.decryptFromPeer(dummyBox); 132 | 133 | expect(encrypt).toThrowError('Remote peer has not yet been established'); 134 | expect(decrypt).toThrowError('Remote peer has not yet been established'); 135 | }); 136 | 137 | it('cannot encrypt/decrypt before the session key is established', () => { 138 | const trustedKey = nacl.randomBytes(32); 139 | const salty = new SaltyRTCBuilder() 140 | .connectTo('localhost') 141 | .withKeyStore(new KeyStore()) 142 | .withTrustedPeerKey(trustedKey) 143 | .usingTasks([new DummyTask()]) 144 | .asResponder(); 145 | 146 | const encrypt = () => salty.encryptForPeer(dummyData, dummyData); 147 | const decrypt = () => salty.decryptFromPeer(dummyBox); 148 | 149 | expect(encrypt).toThrowError('Session key not yet established'); 150 | expect(decrypt).toThrowError('Session key not yet established'); 151 | }); 152 | 153 | }); 154 | 155 | describe('SaltyRTC', function() { 156 | 157 | describe('events', function() { 158 | 159 | let sc: saltyrtc.SaltyRTC; 160 | 161 | beforeEach(() => { 162 | sc = new SaltyRTCBuilder() 163 | .connectTo('localhost') 164 | .withKeyStore(new KeyStore()) 165 | .usingTasks([new DummyTask()]) 166 | .asInitiator(); 167 | }); 168 | 169 | it('can emit events', (done: any) => { 170 | sc.on('connected', () => { 171 | expect(true).toBe(true); 172 | done(); 173 | }); 174 | sc.emit({type: 'connected'}); 175 | }); 176 | 177 | it('only calls handlers for specified events', async () => { 178 | let counter = 0; 179 | sc.on(['connected', 'data'], () => { 180 | counter += 1; 181 | }); 182 | sc.emit({type: 'connected'}); 183 | sc.emit({type: 'data'}); 184 | sc.emit({type: 'connection-error'}); 185 | sc.emit({type: 'connected'}); 186 | await sleep(20); 187 | expect(counter).toEqual(3); 188 | }); 189 | 190 | it('only adds a handler once', async () => { 191 | let counter = 0; 192 | const handler: Runnable = () => { counter += 1; }; 193 | sc.on('data', handler); 194 | sc.on('data', handler); 195 | sc.emit({type: 'data'}); 196 | await sleep(20); 197 | expect(counter).toEqual(1); 198 | }); 199 | 200 | it('can call multiple handlers', async () => { 201 | let counter = 0; 202 | const handler1: Runnable = () => { counter += 1; }; 203 | const handler2: Runnable = () => { counter += 1; }; 204 | sc.on(['connected', 'data'], handler1); 205 | sc.on(['connected'], handler2); 206 | sc.emit({type: 'connected'}); 207 | sc.emit({type: 'data'}); 208 | await sleep(20); 209 | expect(counter).toEqual(3); 210 | }); 211 | 212 | it('can cancel handlers', async () => { 213 | let counter = 0; 214 | const handler: Runnable = () => { counter += 1; }; 215 | sc.on(['data', 'connected'], handler); 216 | sc.emit({type: 'connected'}); 217 | sc.emit({type: 'data'}); 218 | sc.off('data', handler); 219 | sc.emit({type: 'connected'}); 220 | sc.emit({type: 'data'}); 221 | await sleep(20); 222 | expect(counter).toEqual(3); 223 | }); 224 | 225 | it('can cancel handlers for multiple events', async () => { 226 | let counter = 0; 227 | const handler: Runnable = () => { counter += 1; }; 228 | sc.on(['data', 'connected'], handler); 229 | sc.emit({type: 'connected'}); 230 | sc.emit({type: 'data'}); 231 | sc.off(['data', 'connected'], handler); 232 | sc.emit({type: 'connected'}); 233 | sc.emit({type: 'data'}); 234 | await sleep(20); 235 | expect(counter).toEqual(2); 236 | }); 237 | 238 | it('can register one-time handlers', async () => { 239 | let counter = 0; 240 | const handler: Runnable = () => { counter += 1; }; 241 | sc.once('data', handler); 242 | sc.emit({type: 'data'}); 243 | sc.emit({type: 'data'}); 244 | await sleep(20); 245 | expect(counter).toEqual(1); 246 | }); 247 | 248 | it('can register one-time handlers that throw', async () => { 249 | let counter = 0; 250 | const handler: Runnable = () => { counter += 1; throw new Error('oh noes'); }; 251 | sc.once('data', handler); 252 | sc.emit({type: 'data'}); 253 | sc.emit({type: 'data'}); 254 | await sleep(20); 255 | expect(counter).toEqual(1); 256 | }); 257 | 258 | it('removes handlers that return false', async () => { 259 | let counter = 0; 260 | const handler: Runnable = () => { 261 | if (counter <= 4) { 262 | counter += 1; 263 | } else { 264 | return false; 265 | } 266 | }; 267 | sc.on('data', handler); 268 | for (let i = 0; i < 7; i++) { 269 | sc.emit({type: 'data'}); 270 | } 271 | await sleep(20); 272 | expect(counter).toEqual(5); 273 | }); 274 | 275 | }); 276 | 277 | describe('client', function() { 278 | it('cannot be reused', () => { 279 | const salty = new SaltyRTCBuilder() 280 | .connectTo('localhost') 281 | .withKeyStore(new KeyStore()) 282 | .usingTasks([new DummyTask()]) 283 | .asInitiator(); 284 | // First connection should be fine 285 | expect(() => salty.connect()).not.toThrowError(); 286 | // Second connection attempt should throw an error 287 | expect(() => salty.connect()) 288 | .toThrow(new ConnectionError( 289 | 'Signaling instance cannot be reused. Please create a new client instance.' 290 | )); 291 | }); 292 | }); 293 | 294 | describe('application messages', function() { 295 | 296 | it('can only send application messages after c2c handshake', () => { 297 | const salty = new SaltyRTCBuilder() 298 | .connectTo('localhost') 299 | .withKeyStore(new KeyStore()) 300 | .usingTasks([new DummyTask()]) 301 | .asInitiator(); 302 | 303 | const send = () => salty.sendApplicationMessage('hello'); 304 | (salty as any).signaling.state = 'peer-handshake'; 305 | expect(send).toThrowError('Cannot send application message in "peer-handshake" state'); 306 | (salty as any).signaling.state = 'closing'; 307 | expect(send).toThrowError('Cannot send application message in "closing" state'); 308 | }); 309 | 310 | }); 311 | 312 | }); 313 | 314 | }); } 315 | -------------------------------------------------------------------------------- /tests/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test config. 3 | * 4 | * Copyright (C) 2016-2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | 10 | export class Config { 11 | // Unit test configuration 12 | public static SALTYRTC_HOST = 'localhost'; 13 | public static SALTYRTC_PORT = 8765; 14 | public static SALTYRTC_SERVER_PUBLIC_KEY = '09a59a5fa6b45cb07638a3a6e347ce563a948b756fd22f9527465f7c79c2a864'; 15 | public static RUN_LOAD_TESTS = false; 16 | 17 | // Performance test configuration 18 | public static CRYPTO_ITERATIONS = 4096; 19 | } 20 | -------------------------------------------------------------------------------- /tests/cookie.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import { Cookie, CookiePair } from '../src/cookie'; 6 | import { ProtocolError } from '../src/exceptions'; 7 | 8 | export default () => { describe('cookie', function() { 9 | 10 | describe('Cookie', function() { 11 | 12 | it('generates a cookie of the correct length', () => { 13 | const c = new Cookie(); 14 | expect(Cookie.COOKIE_LENGTH).toEqual(16); 15 | expect(c.bytes.byteLength).toEqual(Cookie.COOKIE_LENGTH); 16 | }); 17 | 18 | it('can compare two cookies', () => { 19 | const c1 = new Cookie(); 20 | const c2 = new Cookie(); 21 | 22 | // Ensure cookies are different 23 | c1.bytes[0] = 1; 24 | c2.bytes[0] = 2; 25 | 26 | expect(c1.equals(c2)).toEqual(false); 27 | 28 | // Make cookies equal 29 | c2.bytes = c1.bytes; 30 | 31 | expect(c1.equals(c2)).toEqual(true); 32 | }); 33 | 34 | it('generates a random cookie', () => { 35 | const c1 = new Cookie(); 36 | const c2 = new Cookie(); 37 | const c3 = new Cookie(); 38 | const c4 = new Cookie(); 39 | expect(c1.equals(c2)).toBe(false); 40 | expect(c1.equals(c3)).toBe(false); 41 | expect(c1.equals(c4)).toBe(false); 42 | expect(c2.equals(c3)).toBe(false); 43 | expect(c2.equals(c4)).toBe(false); 44 | expect(c3.equals(c4)).toBe(false); 45 | }); 46 | 47 | }); 48 | 49 | describe('CookiePair', function() { 50 | it('cannot be instantiated from two equal cookies', () => { 51 | const c = new Cookie(); 52 | const construct = () => new CookiePair(c, c); 53 | expect(construct).toThrow(new ProtocolError('Their cookie matches our cookie')); 54 | }); 55 | 56 | it('cannot set their cookie to our cookie', () => { 57 | const pair = new CookiePair(); 58 | const setDifferent = () => pair.theirs = new Cookie(); 59 | const setSame = () => pair.theirs = pair.ours; 60 | expect(setDifferent).not.toThrow(); 61 | expect(setSame).toThrow(new ProtocolError('Their cookie matches our cookie')); 62 | }); 63 | }); 64 | 65 | }); }; 66 | -------------------------------------------------------------------------------- /tests/csn.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | /// 5 | 6 | import { CombinedSequence } from '../src/csn'; 7 | 8 | export default () => { describe('csn', function() { 9 | 10 | describe('CombinedSequence', function() { 11 | 12 | it('constructor', () => { 13 | for (let i = 0; i < 1000; i++) { 14 | const csn = new CombinedSequence(); 15 | expect(csn.asNumber()).toBeLessThan(2 ** 32); 16 | expect((csn as any).overflow).toEqual(0); 17 | } 18 | }); 19 | 20 | it('asNumber', () => { 21 | const csn = new CombinedSequence(); 22 | (csn as any).sequenceNumber = 1234; 23 | (csn as any).overflow = 7; 24 | // (7<<32) + 1234 25 | expect(csn.asNumber()).toEqual(30064772306); 26 | }); 27 | 28 | it('next (overflow=0)', () => { 29 | const csn = new CombinedSequence(); 30 | (csn as any).sequenceNumber = 1234; 31 | (csn as any).overflow = 0; 32 | const snapshot: saltyrtc.NextCombinedSequence = csn.next(); 33 | expect(snapshot.overflow).toEqual(0); 34 | expect(snapshot.sequenceNumber).toEqual(1235); 35 | expect((csn as any).overflow).toEqual(snapshot.overflow); 36 | expect((csn as any).sequenceNumber).toEqual(snapshot.sequenceNumber); 37 | }); 38 | 39 | it('next (overflow>0)', () => { 40 | const csn = new CombinedSequence(); 41 | (csn as any).sequenceNumber = 1234; 42 | (csn as any).overflow = 1337; 43 | const snapshot: saltyrtc.NextCombinedSequence = csn.next(); 44 | expect(snapshot.overflow).toEqual(1337); 45 | expect(snapshot.sequenceNumber).toEqual(1235); 46 | expect((csn as any).overflow).toEqual(snapshot.overflow); 47 | expect((csn as any).sequenceNumber).toEqual(snapshot.sequenceNumber); 48 | }); 49 | 50 | it('next (overflow=0->1)', () => { 51 | const csn = new CombinedSequence(); 52 | (csn as any).sequenceNumber = (2 ** 32) - 3; 53 | (csn as any).overflow = 0; 54 | expect((csn as any).overflow).toEqual(0); 55 | expect((csn as any).sequenceNumber).toEqual(4294967293); 56 | csn.next(); 57 | expect((csn as any).overflow).toEqual(0); 58 | expect((csn as any).sequenceNumber).toEqual(4294967294); 59 | csn.next(); 60 | expect((csn as any).overflow).toEqual(0); 61 | expect((csn as any).sequenceNumber).toEqual(4294967295); 62 | csn.next(); 63 | expect((csn as any).overflow).toEqual(1); 64 | expect((csn as any).sequenceNumber).toEqual(0); 65 | }); 66 | }); 67 | 68 | }); }; 69 | -------------------------------------------------------------------------------- /tests/eventregistry.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | /// 5 | 6 | import { EventRegistry } from '../src/eventregistry'; 7 | import { Runnable } from './utils'; 8 | 9 | /** 10 | * Wrapper around the EventRegistry that makes the `map` public. 11 | */ 12 | class TestEventRegistry extends EventRegistry { 13 | public map: Map; 14 | } 15 | 16 | export default () => { describe('eventregistry', function() { 17 | 18 | describe('EventRegistry', function() { 19 | 20 | let registry: TestEventRegistry; 21 | let handler1: Runnable; 22 | let handler2: Runnable; 23 | 24 | beforeEach(() => { 25 | registry = new TestEventRegistry(); 26 | handler1 = () => { console.log('Event 1 occurred'); }; 27 | handler2 = () => { console.log('Event 2 occurred'); }; 28 | }); 29 | 30 | it('can register a new event', () => { 31 | expect(registry.map.get('boo')).toBeUndefined(); 32 | registry.register('boo', handler1); 33 | const registered: saltyrtc.SaltyRTCEventHandler[] = registry.map.get('boo'); 34 | expect(registered.length).toEqual(1); 35 | expect(registered[0]).toBe(handler1); 36 | }); 37 | 38 | it('can register multiple handlers', () => { 39 | expect(registry.map.get('boo')).toBeUndefined(); 40 | registry.register('boo', handler1); 41 | registry.register('boo', handler2); 42 | const registered: saltyrtc.SaltyRTCEventHandler[] = registry.map.get('boo'); 43 | expect(registered.length).toEqual(2); 44 | expect(registered).toContain(handler1); 45 | expect(registered).toContain(handler2); 46 | }); 47 | 48 | it('can register multiple events', () => { 49 | expect(registry.map.get('boo')).toBeUndefined(); 50 | expect(registry.map.get('far')).toBeUndefined(); 51 | registry.register('boo', handler1); 52 | registry.register('boo', handler2); 53 | registry.register('far', handler1); 54 | expect(registry.map.get('boo').length).toEqual(2); 55 | expect(registry.map.get('far').length).toEqual(1); 56 | }); 57 | 58 | it('can retrieve handlers correctly', () => { 59 | registry.map.set('boo', [handler1]); 60 | registry.map.set('far', [handler1, handler2]); 61 | expect(registry.get('boo')).toEqual([handler1]); 62 | expect(registry.get('far')).toEqual([handler1, handler2]); 63 | expect(registry.get(['boo', 'far'])).toEqual([handler1, handler2]); 64 | expect(registry.get('baz')).toEqual([]); 65 | expect(registry.get(['boo', 'far', 'baz'])).toEqual([handler1, handler2]); 66 | }); 67 | 68 | it('can unregister handlers correctly', () => { 69 | registry.map.set('boo', [handler1]); 70 | registry.map.set('far', [handler1, handler2]); 71 | 72 | // Unknown handler 73 | registry.unregister('far', () => { /* do nothing */ }); 74 | expect(registry.get('far')).toEqual([handler1, handler2]); 75 | 76 | // Unknown event 77 | registry.unregister('baz', handler1); 78 | expect(registry.get('boo')).toEqual([handler1]); 79 | expect(registry.get('far')).toEqual([handler1, handler2]); 80 | 81 | // Success 82 | registry.unregister('boo', handler1); 83 | expect(registry.get('boo')).toEqual([]); 84 | registry.unregister('far', handler2); 85 | expect(registry.get('far')).toEqual([handler1]); 86 | 87 | // Clear 88 | registry.map.set('far', [handler1, handler2]); 89 | registry.unregister('far'); 90 | expect(registry.get('far')).toEqual([]); 91 | 92 | // Multiple events 93 | registry.map.set('boo', [handler1]); 94 | registry.map.set('far', [handler1, handler2]); 95 | registry.unregister(['boo', 'far', 'baz'], handler1); 96 | expect(registry.get('boo')).toEqual([]); 97 | expect(registry.get('far')).toEqual([handler2]); 98 | }); 99 | 100 | it('can unregister all handlers', () => { 101 | registry.map.set('boo', [handler1]); 102 | registry.map.set('far', [handler1, handler2]); 103 | expect(registry.get('boo')).toEqual([handler1]); 104 | expect(registry.get('far')).toEqual([handler1, handler2]); 105 | 106 | registry.unregisterAll(); 107 | 108 | expect(registry.get('boo')).toEqual([]); 109 | expect(registry.get('far')).toEqual([]); 110 | }); 111 | 112 | }); 113 | 114 | }); }; 115 | -------------------------------------------------------------------------------- /tests/handoverstate.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import { HandoverState } from '../src/signaling/handoverstate'; 6 | 7 | export default () => { describe('HandoverState', function() { 8 | 9 | let state: HandoverState; 10 | 11 | beforeEach(() => { 12 | state = new HandoverState(); 13 | }); 14 | 15 | it('is initialized to false / false', () => { 16 | expect(state.local).toBeFalsy(); 17 | expect(state.peer).toBeFalsy(); 18 | }); 19 | 20 | it('can determine whether any peer has finished the handover', () => { 21 | // None 22 | expect(state.any).toBeFalsy(); 23 | 24 | // Local 25 | state.local = true; 26 | expect(state.any).toBeTruthy(); 27 | 28 | // Peer 29 | state.reset(); 30 | state.peer = true; 31 | expect(state.any).toBeTruthy(); 32 | 33 | // Both 34 | state.local = true; 35 | expect(state.any).toBeTruthy(); 36 | }); 37 | 38 | it('can determine whether both peers have finished the handover', () => { 39 | // None 40 | expect(state.both).toBeFalsy(); 41 | 42 | // Local 43 | state.local = true; 44 | expect(state.both).toBeFalsy(); 45 | 46 | // Peer 47 | state.reset(); 48 | state.peer = true; 49 | expect(state.both).toBeFalsy(); 50 | 51 | // Both 52 | state.local = true; 53 | expect(state.both).toBeTruthy(); 54 | }); 55 | 56 | it('calls the callback when handover is done', () => { 57 | let onBothCalled = false; 58 | state.onBoth = () => { onBothCalled = true; }; 59 | expect(onBothCalled).toBeFalsy(); 60 | state.local = true; 61 | expect(onBothCalled).toBeFalsy(); 62 | state.peer = true; 63 | expect(onBothCalled).toBeTruthy(); 64 | }); 65 | }); }; 66 | -------------------------------------------------------------------------------- /tests/jasmine.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for Jasmine 2.2 2 | // Project: http://jasmine.github.io/ 3 | // Definitions by: Boris Yankov , Theodore Brown , David Pärsson 4 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 5 | 6 | 7 | // For ddescribe / iit use : https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/karma-jasmine/karma-jasmine.d.ts 8 | 9 | declare function describe(description: string, specDefinitions: () => void): void; 10 | declare function fdescribe(description: string, specDefinitions: () => void): void; 11 | declare function xdescribe(description: string, specDefinitions: () => void): void; 12 | 13 | declare function it(expectation: string, assertion?: () => void, timeout?: number): void; 14 | declare function it(expectation: string, assertion?: (done: DoneFn) => void, timeout?: number): void; 15 | declare function fit(expectation: string, assertion?: () => void, timeout?: number): void; 16 | declare function fit(expectation: string, assertion?: (done: DoneFn) => void, timeout?: number): void; 17 | declare function xit(expectation: string, assertion?: () => void, timeout?: number): void; 18 | declare function xit(expectation: string, assertion?: (done: DoneFn) => void, timeout?: number): void; 19 | 20 | /** If you call the function pending anywhere in the spec body, no matter the expectations, the spec will be marked pending. */ 21 | declare function pending(reason?: string): void; 22 | 23 | declare function beforeEach(action: () => void, timeout?: number): void; 24 | declare function beforeEach(action: (done: DoneFn) => void, timeout?: number): void; 25 | declare function afterEach(action: () => void, timeout?: number): void; 26 | declare function afterEach(action: (done: DoneFn) => void, timeout?: number): void; 27 | 28 | declare function beforeAll(action: () => void, timeout?: number): void; 29 | declare function beforeAll(action: (done: DoneFn) => void, timeout?: number): void; 30 | declare function afterAll(action: () => void, timeout?: number): void; 31 | declare function afterAll(action: (done: DoneFn) => void, timeout?: number): void; 32 | 33 | declare function expect(spy: Function): jasmine.Matchers; 34 | declare function expect(actual: any): jasmine.Matchers; 35 | 36 | declare function fail(e?: any): void; 37 | /** Action method that should be called when the async work is complete */ 38 | interface DoneFn extends Function { 39 | (): void; 40 | 41 | /** fails the spec and indicates that it has completed. If the message is an Error, Error.message is used */ 42 | fail: (message?: Error|string) => void; 43 | } 44 | 45 | declare function spyOn(object: any, method: string): jasmine.Spy; 46 | 47 | declare function runs(asyncMethod: Function): void; 48 | declare function waitsFor(latchMethod: () => boolean, failureMessage?: string, timeout?: number): void; 49 | declare function waits(timeout?: number): void; 50 | 51 | declare namespace jasmine { 52 | 53 | var clock: () => Clock; 54 | 55 | function any(aclass: any): Any; 56 | function anything(): Any; 57 | function arrayContaining(sample: any[]): ArrayContaining; 58 | function objectContaining(sample: any): ObjectContaining; 59 | function createSpy(name: string, originalFn?: Function): Spy; 60 | function createSpyObj(baseName: string, methodNames: any[]): any; 61 | function createSpyObj(baseName: string, methodNames: any[]): T; 62 | function pp(value: any): string; 63 | function getEnv(): Env; 64 | function addCustomEqualityTester(equalityTester: CustomEqualityTester): void; 65 | function addMatchers(matchers: CustomMatcherFactories): void; 66 | function stringMatching(str: string): Any; 67 | function stringMatching(str: RegExp): Any; 68 | 69 | interface Any { 70 | 71 | new (expectedClass: any): any; 72 | 73 | jasmineMatches(other: any): boolean; 74 | jasmineToString(): string; 75 | } 76 | 77 | // taken from TypeScript lib.core.es6.d.ts, applicable to CustomMatchers.contains() 78 | interface ArrayLike { 79 | length: number; 80 | [n: number]: T; 81 | } 82 | 83 | interface ArrayContaining { 84 | new (sample: any[]): any; 85 | 86 | asymmetricMatch(other: any): boolean; 87 | jasmineToString(): string; 88 | } 89 | 90 | interface ObjectContaining { 91 | new (sample: any): any; 92 | 93 | jasmineMatches(other: any, mismatchKeys: any[], mismatchValues: any[]): boolean; 94 | jasmineToString(): string; 95 | } 96 | 97 | interface Block { 98 | 99 | new (env: Env, func: SpecFunction, spec: Spec): any; 100 | 101 | execute(onComplete: () => void): void; 102 | } 103 | 104 | interface WaitsBlock extends Block { 105 | new (env: Env, timeout: number, spec: Spec): any; 106 | } 107 | 108 | interface WaitsForBlock extends Block { 109 | new (env: Env, timeout: number, latchFunction: SpecFunction, message: string, spec: Spec): any; 110 | } 111 | 112 | interface Clock { 113 | install(): void; 114 | uninstall(): void; 115 | /** Calls to any registered callback are triggered when the clock is ticked forward via the jasmine.clock().tick function, which takes a number of milliseconds. */ 116 | tick(ms: number): void; 117 | mockDate(date?: Date): void; 118 | } 119 | 120 | interface CustomEqualityTester { 121 | (first: any, second: any): boolean; 122 | } 123 | 124 | interface CustomMatcher { 125 | compare(actual: T, expected: T): CustomMatcherResult; 126 | compare(actual: any, expected: any): CustomMatcherResult; 127 | } 128 | 129 | interface CustomMatcherFactory { 130 | (util: MatchersUtil, customEqualityTesters: Array): CustomMatcher; 131 | } 132 | 133 | interface CustomMatcherFactories { 134 | [index: string]: CustomMatcherFactory; 135 | } 136 | 137 | interface CustomMatcherResult { 138 | pass: boolean; 139 | message?: string; 140 | } 141 | 142 | interface MatchersUtil { 143 | equals(a: any, b: any, customTesters?: Array): boolean; 144 | contains(haystack: ArrayLike | string, needle: any, customTesters?: Array): boolean; 145 | buildFailureMessage(matcherName: string, isNot: boolean, actual: any, ...expected: Array): string; 146 | } 147 | 148 | interface Env { 149 | setTimeout: any; 150 | clearTimeout: void; 151 | setInterval: any; 152 | clearInterval: void; 153 | updateInterval: number; 154 | 155 | currentSpec: Spec; 156 | 157 | matchersClass: Matchers; 158 | 159 | version(): any; 160 | versionString(): string; 161 | nextSpecId(): number; 162 | addReporter(reporter: Reporter): void; 163 | execute(): void; 164 | describe(description: string, specDefinitions: () => void): Suite; 165 | // ddescribe(description: string, specDefinitions: () => void): Suite; Not a part of jasmine. Angular team adds these 166 | beforeEach(beforeEachFunction: () => void): void; 167 | beforeAll(beforeAllFunction: () => void): void; 168 | currentRunner(): Runner; 169 | afterEach(afterEachFunction: () => void): void; 170 | afterAll(afterAllFunction: () => void): void; 171 | xdescribe(desc: string, specDefinitions: () => void): XSuite; 172 | it(description: string, func: () => void): Spec; 173 | // iit(description: string, func: () => void): Spec; Not a part of jasmine. Angular team adds these 174 | xit(desc: string, func: () => void): XSpec; 175 | compareRegExps_(a: RegExp, b: RegExp, mismatchKeys: string[], mismatchValues: string[]): boolean; 176 | compareObjects_(a: any, b: any, mismatchKeys: string[], mismatchValues: string[]): boolean; 177 | equals_(a: any, b: any, mismatchKeys: string[], mismatchValues: string[]): boolean; 178 | contains_(haystack: any, needle: any): boolean; 179 | addCustomEqualityTester(equalityTester: CustomEqualityTester): void; 180 | addMatchers(matchers: CustomMatcherFactories): void; 181 | specFilter(spec: Spec): boolean; 182 | } 183 | 184 | interface FakeTimer { 185 | 186 | new (): any; 187 | 188 | reset(): void; 189 | tick(millis: number): void; 190 | runFunctionsWithinRange(oldMillis: number, nowMillis: number): void; 191 | scheduleFunction(timeoutKey: any, funcToCall: () => void, millis: number, recurring: boolean): void; 192 | } 193 | 194 | interface HtmlReporter { 195 | new (): any; 196 | } 197 | 198 | interface HtmlSpecFilter { 199 | new (): any; 200 | } 201 | 202 | interface Result { 203 | type: string; 204 | } 205 | 206 | interface NestedResults extends Result { 207 | description: string; 208 | 209 | totalCount: number; 210 | passedCount: number; 211 | failedCount: number; 212 | 213 | skipped: boolean; 214 | 215 | rollupCounts(result: NestedResults): void; 216 | log(values: any): void; 217 | getItems(): Result[]; 218 | addResult(result: Result): void; 219 | passed(): boolean; 220 | } 221 | 222 | interface MessageResult extends Result { 223 | values: any; 224 | trace: Trace; 225 | } 226 | 227 | interface ExpectationResult extends Result { 228 | matcherName: string; 229 | passed(): boolean; 230 | expected: any; 231 | actual: any; 232 | message: string; 233 | trace: Trace; 234 | } 235 | 236 | interface Trace { 237 | name: string; 238 | message: string; 239 | stack: any; 240 | } 241 | 242 | interface PrettyPrinter { 243 | 244 | new (): any; 245 | 246 | format(value: any): void; 247 | iterateObject(obj: any, fn: (property: string, isGetter: boolean) => void): void; 248 | emitScalar(value: any): void; 249 | emitString(value: string): void; 250 | emitArray(array: any[]): void; 251 | emitObject(obj: any): void; 252 | append(value: any): void; 253 | } 254 | 255 | interface StringPrettyPrinter extends PrettyPrinter { 256 | } 257 | 258 | interface Queue { 259 | 260 | new (env: any): any; 261 | 262 | env: Env; 263 | ensured: boolean[]; 264 | blocks: Block[]; 265 | running: boolean; 266 | index: number; 267 | offset: number; 268 | abort: boolean; 269 | 270 | addBefore(block: Block, ensure?: boolean): void; 271 | add(block: any, ensure?: boolean): void; 272 | insertNext(block: any, ensure?: boolean): void; 273 | start(onComplete?: () => void): void; 274 | isRunning(): boolean; 275 | next_(): void; 276 | results(): NestedResults; 277 | } 278 | 279 | interface Matchers { 280 | 281 | new (env: Env, actual: any, spec: Env, isNot?: boolean): any; 282 | 283 | env: Env; 284 | actual: any; 285 | spec: Env; 286 | isNot?: boolean; 287 | message(): any; 288 | 289 | toBe(expected: any, expectationFailOutput?: any): boolean; 290 | toEqual(expected: any, expectationFailOutput?: any): boolean; 291 | toMatch(expected: string | RegExp, expectationFailOutput?: any): boolean; 292 | toBeDefined(expectationFailOutput?: any): boolean; 293 | toBeUndefined(expectationFailOutput?: any): boolean; 294 | toBeNull(expectationFailOutput?: any): boolean; 295 | toBeNaN(): boolean; 296 | toBeTruthy(expectationFailOutput?: any): boolean; 297 | toBeFalsy(expectationFailOutput?: any): boolean; 298 | toHaveBeenCalled(): boolean; 299 | toHaveBeenCalledWith(...params: any[]): boolean; 300 | toHaveBeenCalledTimes(expected: number): boolean; 301 | toContain(expected: any, expectationFailOutput?: any): boolean; 302 | toBeLessThan(expected: number, expectationFailOutput?: any): boolean; 303 | toBeGreaterThan(expected: number, expectationFailOutput?: any): boolean; 304 | toBeCloseTo(expected: number, precision: any, expectationFailOutput?: any): boolean; 305 | toThrow(expected?: any): boolean; 306 | toThrowError(message?: string | RegExp): boolean; 307 | toThrowError(expected?: new (...args: any[]) => Error, message?: string | RegExp): boolean; 308 | not: Matchers; 309 | 310 | Any: Any; 311 | } 312 | 313 | interface Reporter { 314 | reportRunnerStarting(runner: Runner): void; 315 | reportRunnerResults(runner: Runner): void; 316 | reportSuiteResults(suite: Suite): void; 317 | reportSpecStarting(spec: Spec): void; 318 | reportSpecResults(spec: Spec): void; 319 | log(str: string): void; 320 | } 321 | 322 | interface MultiReporter extends Reporter { 323 | addReporter(reporter: Reporter): void; 324 | } 325 | 326 | interface Runner { 327 | 328 | new (env: Env): any; 329 | 330 | execute(): void; 331 | beforeEach(beforeEachFunction: SpecFunction): void; 332 | afterEach(afterEachFunction: SpecFunction): void; 333 | beforeAll(beforeAllFunction: SpecFunction): void; 334 | afterAll(afterAllFunction: SpecFunction): void; 335 | finishCallback(): void; 336 | addSuite(suite: Suite): void; 337 | add(block: Block): void; 338 | specs(): Spec[]; 339 | suites(): Suite[]; 340 | topLevelSuites(): Suite[]; 341 | results(): NestedResults; 342 | } 343 | 344 | interface SpecFunction { 345 | (spec?: Spec): void; 346 | } 347 | 348 | interface SuiteOrSpec { 349 | id: number; 350 | env: Env; 351 | description: string; 352 | queue: Queue; 353 | } 354 | 355 | interface Spec extends SuiteOrSpec { 356 | 357 | new (env: Env, suite: Suite, description: string): any; 358 | 359 | suite: Suite; 360 | 361 | afterCallbacks: SpecFunction[]; 362 | spies_: Spy[]; 363 | 364 | results_: NestedResults; 365 | matchersClass: Matchers; 366 | 367 | getFullName(): string; 368 | results(): NestedResults; 369 | log(arguments: any): any; 370 | runs(func: SpecFunction): Spec; 371 | addToQueue(block: Block): void; 372 | addMatcherResult(result: Result): void; 373 | expect(actual: any): any; 374 | waits(timeout: number): Spec; 375 | waitsFor(latchFunction: SpecFunction, timeoutMessage?: string, timeout?: number): Spec; 376 | fail(e?: any): void; 377 | getMatchersClass_(): Matchers; 378 | addMatchers(matchersPrototype: CustomMatcherFactories): void; 379 | finishCallback(): void; 380 | finish(onComplete?: () => void): void; 381 | after(doAfter: SpecFunction): void; 382 | execute(onComplete?: () => void): any; 383 | addBeforesAndAftersToQueue(): void; 384 | explodes(): void; 385 | spyOn(obj: any, methodName: string, ignoreMethodDoesntExist: boolean): Spy; 386 | removeAllSpies(): void; 387 | } 388 | 389 | interface XSpec { 390 | id: number; 391 | runs(): void; 392 | } 393 | 394 | interface Suite extends SuiteOrSpec { 395 | 396 | new (env: Env, description: string, specDefinitions: () => void, parentSuite: Suite): any; 397 | 398 | parentSuite: Suite; 399 | 400 | getFullName(): string; 401 | finish(onComplete?: () => void): void; 402 | beforeEach(beforeEachFunction: SpecFunction): void; 403 | afterEach(afterEachFunction: SpecFunction): void; 404 | beforeAll(beforeAllFunction: SpecFunction): void; 405 | afterAll(afterAllFunction: SpecFunction): void; 406 | results(): NestedResults; 407 | add(suiteOrSpec: SuiteOrSpec): void; 408 | specs(): Spec[]; 409 | suites(): Suite[]; 410 | children(): any[]; 411 | execute(onComplete?: () => void): void; 412 | } 413 | 414 | interface XSuite { 415 | execute(): void; 416 | } 417 | 418 | interface Spy { 419 | (...params: any[]): any; 420 | 421 | identity: string; 422 | and: SpyAnd; 423 | calls: Calls; 424 | mostRecentCall: { args: any[]; }; 425 | argsForCall: any[]; 426 | wasCalled: boolean; 427 | } 428 | 429 | interface SpyAnd { 430 | /** By chaining the spy with and.callThrough, the spy will still track all calls to it but in addition it will delegate to the actual implementation. */ 431 | callThrough(): Spy; 432 | /** By chaining the spy with and.returnValue, all calls to the function will return a specific value. */ 433 | returnValue(val: any): Spy; 434 | /** By chaining the spy with and.returnValues, all calls to the function will return specific values in order until it reaches the end of the return values list. */ 435 | returnValues(...values: any[]): Spy; 436 | /** By chaining the spy with and.callFake, all calls to the spy will delegate to the supplied function. */ 437 | callFake(fn: Function): Spy; 438 | /** By chaining the spy with and.throwError, all calls to the spy will throw the specified value. */ 439 | throwError(msg: string): Spy; 440 | /** When a calling strategy is used for a spy, the original stubbing behavior can be returned at any time with and.stub. */ 441 | stub(): Spy; 442 | } 443 | 444 | interface Calls { 445 | /** By chaining the spy with calls.any(), will return false if the spy has not been called at all, and then true once at least one call happens. **/ 446 | any(): boolean; 447 | /** By chaining the spy with calls.count(), will return the number of times the spy was called **/ 448 | count(): number; 449 | /** By chaining the spy with calls.argsFor(), will return the arguments passed to call number index **/ 450 | argsFor(index: number): any[]; 451 | /** By chaining the spy with calls.allArgs(), will return the arguments to all calls **/ 452 | allArgs(): any[]; 453 | /** By chaining the spy with calls.all(), will return the context (the this) and arguments passed all calls **/ 454 | all(): CallInfo[]; 455 | /** By chaining the spy with calls.mostRecent(), will return the context (the this) and arguments for the most recent call **/ 456 | mostRecent(): CallInfo; 457 | /** By chaining the spy with calls.first(), will return the context (the this) and arguments for the first call **/ 458 | first(): CallInfo; 459 | /** By chaining the spy with calls.reset(), will clears all tracking for a spy **/ 460 | reset(): void; 461 | } 462 | 463 | interface CallInfo { 464 | /** The context (the this) for the call */ 465 | object: any; 466 | /** All arguments passed to the call */ 467 | args: any[]; 468 | /** The return value of the call */ 469 | returnValue: any; 470 | } 471 | 472 | interface Util { 473 | inherit(childClass: Function, parentClass: Function): any; 474 | formatException(e: any): any; 475 | htmlEscape(str: string): string; 476 | argsToArray(args: any): any; 477 | extend(destination: any, source: any): any; 478 | } 479 | 480 | interface JsApiReporter extends Reporter { 481 | 482 | started: boolean; 483 | finished: boolean; 484 | result: any; 485 | messages: any; 486 | 487 | new (): any; 488 | 489 | suites(): Suite[]; 490 | summarize_(suiteOrSpec: SuiteOrSpec): any; 491 | results(): any; 492 | resultsForSpec(specId: any): any; 493 | log(str: any): any; 494 | resultsForSpecs(specIds: any): any; 495 | summarizeResult_(result: any): any; 496 | } 497 | 498 | interface Jasmine { 499 | Spec: Spec; 500 | clock: Clock; 501 | util: Util; 502 | } 503 | 504 | export var HtmlReporter: HtmlReporter; 505 | export var HtmlSpecFilter: HtmlSpecFilter; 506 | export var DEFAULT_TIMEOUT_INTERVAL: number; 507 | } 508 | -------------------------------------------------------------------------------- /tests/keystore.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import * as nacl from 'tweetnacl'; 6 | import { CryptoError, ValidationError } from '../src/exceptions'; 7 | import { AuthToken, Box, KeyStore, SharedKeyStore } from '../src/keystore'; 8 | import { hexToU8a, u8aToHex } from '../src/utils'; 9 | 10 | export default () => { describe('keystore', function() { 11 | 12 | describe('Box', function() { 13 | 14 | const nonce = nacl.randomBytes(24); 15 | const data = nacl.randomBytes(7); 16 | const box = new Box(nonce, data, 24); 17 | 18 | it('correctly calculates the length', () => { 19 | expect(box.length).toEqual(7 + 24); 20 | }); 21 | 22 | it('correctly returns the data', () => { 23 | expect(box.data).toEqual(data); 24 | }); 25 | 26 | it('correctly returns the nonce', () => { 27 | expect(box.nonce).toEqual(nonce); 28 | }); 29 | 30 | it('can be created from a byte array', () => { 31 | const nonceLength = nacl.box.nonceLength; 32 | const nonce2 = nacl.randomBytes(nonceLength); 33 | const data2 = nacl.randomBytes(5); 34 | const array = new Uint8Array(nonceLength + 5); 35 | array.set(nonce2); 36 | array.set(data2, nonceLength); 37 | const box2 = Box.fromUint8Array(array, nonceLength); 38 | expect(box2.nonce).toEqual(nonce2); 39 | expect(box2.data).toEqual(data2); 40 | expect(box2.length).toEqual(nonceLength + 5); 41 | }); 42 | 43 | it('validates the byte array length', () => { 44 | const nonceLength = nacl.box.nonceLength; 45 | const boxSameLength = () => Box.fromUint8Array(nacl.randomBytes(nonceLength), nonceLength); 46 | const boxLessLength = () => Box.fromUint8Array(nacl.randomBytes(nonceLength - 2), nonceLength); 47 | expect(boxSameLength).toThrow(new CryptoError('bad-message-length', 'Message is shorter than nonce')); 48 | expect(boxLessLength).toThrow(new CryptoError('bad-message-length', 'Message is shorter than nonce')); 49 | }); 50 | 51 | it('can be converted into a byte array', () => { 52 | const array = box.toUint8Array(); 53 | expect(array.slice(0, nacl.secretbox.nonceLength)).toEqual(nonce); 54 | expect(array.slice(nacl.secretbox.nonceLength)).toEqual(data); 55 | }); 56 | 57 | }); 58 | 59 | describe('KeyStore', function() { 60 | 61 | const ks = new KeyStore(); 62 | const nonce = nacl.randomBytes(24); 63 | const data = nacl.randomBytes(7); 64 | 65 | it('generates a keypair', () => { 66 | // Internal test 67 | expect((ks as any)._keyPair.publicKey).toBeTruthy(); 68 | expect((ks as any)._keyPair.secretKey).toBeTruthy(); 69 | }); 70 | 71 | it('can return the secret/public keys as bytes', () => { 72 | expect(ks.publicKeyBytes).toBeTruthy(); 73 | expect(ks.secretKeyBytes).toBeTruthy(); 74 | expect(ks.publicKeyBytes instanceof Uint8Array).toEqual(true); 75 | expect(ks.secretKeyBytes instanceof Uint8Array).toEqual(true); 76 | }); 77 | 78 | it('can return the secret/public keys as hex string', () => { 79 | expect(ks.publicKeyHex).toBeTruthy(); 80 | expect(ks.secretKeyHex).toBeTruthy(); 81 | expect(typeof ks.publicKeyHex).toEqual('string'); 82 | expect(typeof ks.secretKeyHex).toEqual('string'); 83 | }); 84 | 85 | it('can encrypt and decrypt properly (round trip)', () => { 86 | const ks2 = new KeyStore(); 87 | const expected = nacl.randomBytes(24); 88 | let encrypted; 89 | let decrypted; 90 | 91 | encrypted = ks.encrypt(expected, nonce, ks2.publicKeyBytes); 92 | decrypted = ks.decrypt(encrypted, ks2.publicKeyBytes); 93 | expect(decrypted).toEqual(expected); 94 | decrypted = ks.decryptRaw(encrypted.data, encrypted.nonce, ks2.publicKeyBytes); 95 | expect(decrypted).toEqual(expected); 96 | 97 | encrypted = ks.encryptRaw(expected, nonce, ks2.publicKeyBytes); 98 | const encryptedBox = new Box(nonce, encrypted, nacl.box.nonceLength); 99 | decrypted = ks.decrypt(encryptedBox, ks2.publicKeyBytes); 100 | expect(decrypted).toEqual(expected); 101 | decrypted = ks.decryptRaw(encrypted, nonce, ks2.publicKeyBytes); 102 | expect(decrypted).toEqual(expected); 103 | }); 104 | 105 | it('can only encrypt and decrypt if pubkey matches', () => { 106 | const ks2 = new KeyStore(); 107 | const ks3 = new KeyStore(); 108 | const expected = nacl.randomBytes(24); 109 | const encrypted = ks.encrypt(expected, nonce, ks2.publicKeyBytes); 110 | 111 | const decrypts = [ 112 | () => ks.decrypt(encrypted, ks3.publicKeyBytes), 113 | () => ks.decryptRaw(encrypted.data, encrypted.nonce, ks3.publicKeyBytes), 114 | ]; 115 | 116 | for (const decrypt of decrypts) { 117 | const error = new CryptoError('decryption-failed', 'Data could not be decrypted'); 118 | expect(decrypt).toThrow(error); 119 | } 120 | }); 121 | 122 | it('cannot encrypt without a proper nonce', () => { 123 | const encrypts = [ 124 | () => ks.encrypt(data, nacl.randomBytes(3), nacl.randomBytes(32)), 125 | () => ks.encryptRaw(data, nacl.randomBytes(3), nacl.randomBytes(32)), 126 | ]; 127 | 128 | for (const encrypt of encrypts) { 129 | expect(encrypt).toThrow(new Error('bad nonce size')); 130 | } 131 | }); 132 | 133 | it('can be created from an Uint8Array or hex string', () => { 134 | const skBytes = nacl.randomBytes(32); 135 | const skHex = u8aToHex(skBytes); 136 | 137 | const ksBytes = new KeyStore(skBytes); 138 | const ksHex = new KeyStore(skHex); 139 | 140 | for (const keystore of [ksBytes, ksHex]) { 141 | expect(keystore.publicKeyBytes).not.toBeNull(); 142 | expect(keystore.secretKeyBytes).toEqual(skBytes); 143 | expect(keystore.publicKeyHex).not.toBeNull(); 144 | expect(keystore.secretKeyHex).toEqual(skHex); 145 | } 146 | }); 147 | 148 | it('shows a nice error message if key is invalid', () => { 149 | const create1 = () => new KeyStore(Uint8Array.of(1, 2, 3)); 150 | expect(create1).toThrowError('Private key must be 32 bytes long'); 151 | 152 | const create2 = () => new KeyStore(42 as any); 153 | expect(create2).toThrowError('Private key must be an Uint8Array or a hex string'); 154 | 155 | const create3 = () => new KeyStore('ffgghh'); 156 | expect(create3).toThrowError('Private key must be 32 bytes long'); 157 | }); 158 | 159 | }); 160 | 161 | describe('SharedKeyStore', function() { 162 | const ks = new KeyStore(new Uint8Array(32).fill(0xff)); 163 | const sks = ks.getSharedKeyStore(ks.publicKeyBytes); 164 | 165 | const nonce = new Uint8Array(24).fill(0xff); 166 | const data = new Uint8Array(10).fill(0xff); 167 | 168 | it('calculates the shared key', () => { 169 | const key = hexToU8a('9cfcb55fa42de280c84c95d9cf08fcbec63657998d15e139dbd3b4c6a1264541'); 170 | expect((sks as any)._sharedKey).toEqual(key); 171 | }); 172 | 173 | it('can be derived from a KeyStore', () => { 174 | expect(sks.localSecretKeyBytes).toEqual(ks.secretKeyBytes); 175 | expect(sks.localSecretKeyHex).toEqual(ks.secretKeyHex); 176 | expect(sks.remotePublicKeyBytes).toEqual(ks.publicKeyBytes); 177 | expect(sks.remotePublicKeyHex).toEqual(ks.publicKeyHex); 178 | }); 179 | 180 | it('can be constructed from Uint8Array based keys', () => { 181 | const sks2 = new SharedKeyStore(ks.secretKeyBytes, ks.publicKeyBytes); 182 | expect(sks2.localSecretKeyBytes).toEqual(ks.secretKeyBytes); 183 | expect(sks2.localSecretKeyHex).toEqual(ks.secretKeyHex); 184 | expect(sks2.remotePublicKeyBytes).toEqual(ks.publicKeyBytes); 185 | expect(sks2.remotePublicKeyHex).toEqual(ks.publicKeyHex); 186 | }); 187 | 188 | it('can be constructed from hex string based keys', () => { 189 | const sks2 = new SharedKeyStore(ks.secretKeyHex, ks.publicKeyHex); 190 | expect(sks2.localSecretKeyBytes).toEqual(ks.secretKeyBytes); 191 | expect(sks2.localSecretKeyHex).toEqual(ks.secretKeyHex); 192 | expect(sks2.remotePublicKeyBytes).toEqual(ks.publicKeyBytes); 193 | expect(sks2.remotePublicKeyHex).toEqual(ks.publicKeyHex); 194 | }); 195 | 196 | it('rejects invalid keys', () => { 197 | let create; 198 | let error; 199 | 200 | create = () => new SharedKeyStore({ meow: true } as any, ks.publicKeyBytes); 201 | error = new ValidationError('Local private key must be an Uint8Array or a hex string'); 202 | expect(create).toThrow(error); 203 | 204 | create = () => new SharedKeyStore(ks.secretKeyBytes, { meow: true } as any); 205 | error = new ValidationError('Remote public key must be an Uint8Array or a hex string'); 206 | expect(create).toThrow(error); 207 | }); 208 | 209 | it('can encrypt and decrypt properly (round trip)', () => { 210 | const expected = new Uint8Array(24).fill(0xee); 211 | let encrypted; 212 | 213 | encrypted = sks.encrypt(expected, nonce); 214 | expect(sks.decrypt(encrypted)).toEqual(expected); 215 | expect(sks.decryptRaw(encrypted.data, encrypted.nonce)).toEqual(expected); 216 | 217 | encrypted = sks.encryptRaw(expected, nonce); 218 | const encryptedBox = new Box(nonce, encrypted, nacl.box.nonceLength); 219 | expect(sks.decrypt(encryptedBox)).toEqual(expected); 220 | expect(sks.decryptRaw(encrypted, nonce)).toEqual(expected); 221 | }); 222 | 223 | it('cannot encrypt without a proper nonce', () => { 224 | const encrypts = [ 225 | () => sks.encrypt(data, nacl.randomBytes(3)), 226 | () => sks.encryptRaw(data, nacl.randomBytes(3)), 227 | ]; 228 | 229 | for (const encrypt of encrypts) { 230 | expect(encrypt).toThrow(new Error('bad nonce size')); 231 | } 232 | }); 233 | 234 | it('can encrypt/decrypt data from KeyStore', () => { 235 | const expected = new Uint8Array(24).fill(0xee); 236 | let encrypted; 237 | 238 | encrypted = ks.encrypt(expected, nonce, ks.publicKeyBytes); 239 | expect(sks.decrypt(encrypted)).toEqual(expected); 240 | 241 | encrypted = sks.encrypt(expected, nonce); 242 | expect(ks.decrypt(encrypted, ks.publicKeyBytes)).toEqual(expected); 243 | }); 244 | 245 | it('encrypted data matches expectation with a specific set of keys', () => { 246 | const skLocal = Uint8Array.from([ 247 | 4, 4, 4, 4, 4, 4, 4, 4, 248 | 3, 3, 3, 3, 3, 3, 3, 3, 249 | 2, 2, 2, 2, 2, 2, 2, 2, 250 | 1, 1, 1, 1, 1, 1, 1, 1, 251 | ]); 252 | const skRemote = Uint8Array.from([ 253 | 1, 1, 1, 1, 1, 1, 1, 1, 254 | 2, 2, 2, 2, 2, 2, 2, 2, 255 | 3, 3, 3, 3, 3, 3, 3, 3, 256 | 4, 4, 4, 4, 4, 4, 4, 4, 257 | ]); 258 | const ks1 = new KeyStore(skLocal); 259 | const plaintext = new Uint8Array(0); 260 | const nonce1 = new TextEncoder().encode('connectionidconnectionid'); 261 | const expected = Uint8Array.from([ 262 | 253, 142, 84, 143, 263 | 118, 139, 224, 253, 264 | 252, 98, 240, 45, 265 | 22, 73, 234, 94 266 | ]); 267 | 268 | const encrypted = ks1.encryptRaw(plaintext, nonce1, new KeyStore(skRemote).publicKeyBytes); 269 | expect(encrypted).toEqual(expected); 270 | }); 271 | 272 | }); 273 | 274 | describe('AuthToken', function() { 275 | 276 | const at = new AuthToken(); 277 | 278 | it('can return the secret key as bytes', () => { 279 | expect(at.keyBytes).toBeTruthy(); 280 | expect(at.keyBytes instanceof Uint8Array).toEqual(true); 281 | }); 282 | 283 | it('can return the secret key as hex string', () => { 284 | expect(at.keyHex).toBeTruthy(); 285 | expect(typeof at.keyHex).toEqual('string'); 286 | }); 287 | 288 | it('can encrypt and decrypt properly (round trip)', () => { 289 | const expected = nacl.randomBytes(7); 290 | const nonce = nacl.randomBytes(24); 291 | expect(at.encrypt(expected, nonce)).not.toEqual(expected); 292 | expect(at.decrypt(at.encrypt(expected, nonce))).toEqual(expected); 293 | }); 294 | 295 | }); 296 | 297 | }); }; 298 | -------------------------------------------------------------------------------- /tests/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test entry point. 3 | * 4 | * Copyright (C) 2016-2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | 10 | // Apply log groups to Jasmine tests 11 | type Callback = (...args: any) => any; 12 | // @ts-ignore 13 | const jasmineIt = window.it; 14 | // @ts-ignore 15 | window.it = (description: string, callback: Callback, ...args) => { 16 | const handler = (invoker: () => any) => { 17 | // Ugly type hack, sorry :( 18 | console.group((spec as any as jasmine.Spec).getFullName()); 19 | let result: any; 20 | try { 21 | result = invoker(); 22 | } catch (error) { 23 | console.groupEnd(); 24 | throw error; 25 | } 26 | if (result instanceof Promise) { 27 | result 28 | .then(() => console.groupEnd()) 29 | .catch(() => console.groupEnd()); 30 | } else { 31 | console.groupEnd(); 32 | } 33 | return result; 34 | }; 35 | let wrapper: Callback; 36 | if (callback.length > 0) { 37 | wrapper = (done: any) => handler(() => callback(done)); 38 | } else { 39 | wrapper = () => handler(() => callback()); 40 | } 41 | const spec = jasmineIt(description, wrapper, ...args); 42 | return spec; 43 | }; 44 | 45 | import test_client from './client.spec'; 46 | import test_cookie from './cookie.spec'; 47 | import test_csn from './csn.spec'; 48 | import test_eventregistry from './eventregistry.spec'; 49 | import test_handoverstate from './handoverstate.spec'; 50 | import test_integration from './integration.spec'; 51 | import test_keystore from './keystore.spec'; 52 | import test_nonce from './nonce.spec'; 53 | import test_utils from './utils.spec'; 54 | 55 | test_client(); 56 | test_cookie(); 57 | test_csn(); 58 | test_eventregistry(); 59 | test_handoverstate(); 60 | test_integration(); 61 | test_keystore(); 62 | test_nonce(); 63 | test_utils(); 64 | -------------------------------------------------------------------------------- /tests/nonce.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import { Cookie } from '../src/cookie'; 6 | import { Nonce } from '../src/nonce'; 7 | 8 | export default () => { describe('nonce', function() { 9 | 10 | describe('Nonce', function() { 11 | 12 | let array: Uint8Array; 13 | 14 | beforeEach(() => { 15 | array = new Uint8Array([ 16 | // Cookie 17 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18 | // Source: 17 19 | 17, 20 | // Destination: 18 21 | 18, 22 | // Overflow: 258 big endian 23 | 1, 2, 24 | // Sequence number: 50595078 big endian 25 | 3, 4, 5, 6, 26 | ]); 27 | }); 28 | 29 | it('parses correctly', () => { 30 | const nonce = Nonce.fromUint8Array(array); 31 | expect(nonce.cookie.bytes).toEqual(Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)); 32 | expect(nonce.source).toEqual(17); 33 | expect(nonce.destination).toEqual(18); 34 | expect(nonce.overflow).toEqual((2 ** 8) + 2); 35 | expect(nonce.sequenceNumber).toEqual((3 * (2 ** 24)) + (4 * (2 ** 16)) + (5 * (2 ** 8)) + 6); 36 | }); 37 | 38 | it('serializes correctly', () => { 39 | const cookie = new Cookie(Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)); 40 | const source = 17; 41 | const destination = 18; 42 | const overflow = 258; 43 | const sequenceNumber = 50595078; 44 | const nonce = new Nonce(cookie, overflow, sequenceNumber, source, destination); 45 | expect(nonce.toUint8Array()).toEqual(array); 46 | }); 47 | 48 | it('returns the correct combined sequence number', () => { 49 | const nonce = Nonce.fromUint8Array(array); 50 | expect(nonce.combinedSequenceNumber).toEqual((258 * (2 ** 32)) + 50595078); 51 | }); 52 | 53 | }); 54 | 55 | }); }; 56 | -------------------------------------------------------------------------------- /tests/performance.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SaltyRTC Performance Tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/performance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Performance test entry point. 3 | * 4 | * Copyright (C) 2018-2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | 10 | import test_crypto from './performance/crypto.spec'; 11 | 12 | let counter = 1; 13 | beforeEach(() => console.info('------ TEST', counter++, 'BEGIN ------')); 14 | 15 | test_crypto(); 16 | -------------------------------------------------------------------------------- /tests/performance/crypto.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import { KeyStore } from '../../src/keystore'; 6 | import { Config } from '../config'; 7 | import { testData } from './utils'; 8 | 9 | export default () => { 10 | describe('crypto', () => { 11 | describe('Main Thread (shared key store=false)', () => { 12 | it(`encrypt ${Config.CRYPTO_ITERATIONS} times`, () => { 13 | const keyStore = new KeyStore(); 14 | const publicKey = keyStore.publicKeyBytes; 15 | const start = performance.now(); 16 | 17 | for (let i = 0; i <= Config.CRYPTO_ITERATIONS; ++i) { 18 | keyStore.encrypt(testData.bytes, testData.nonce, publicKey); 19 | } 20 | 21 | const end = performance.now(); 22 | console.info(`Took ${(end - start) / 1000} seconds`); 23 | expect(0).toBe(0); 24 | }, 30000); 25 | 26 | it(`decrypt ${Config.CRYPTO_ITERATIONS} times`, () => { 27 | const keyStore = new KeyStore(); 28 | const publicKey = keyStore.publicKeyBytes; 29 | const box = keyStore.encrypt(testData.bytes, testData.nonce, publicKey); 30 | const start = performance.now(); 31 | 32 | for (let i = 0; i <= Config.CRYPTO_ITERATIONS; ++i) { 33 | keyStore.decrypt(box, publicKey); 34 | } 35 | 36 | const end = performance.now(); 37 | console.info(`Took ${(end - start) / 1000} seconds`); 38 | expect(0).toBe(0); 39 | }, 30000); 40 | }); 41 | 42 | describe('Main Thread (shared key store=true)', () => { 43 | it(`encrypt ${Config.CRYPTO_ITERATIONS} times`, () => { 44 | const keyStore = new KeyStore(); 45 | const sharedKeyStore = keyStore.getSharedKeyStore(keyStore.publicKeyBytes); 46 | const start = performance.now(); 47 | 48 | for (let i = 0; i <= Config.CRYPTO_ITERATIONS; ++i) { 49 | sharedKeyStore.encrypt(testData.bytes, testData.nonce); 50 | } 51 | 52 | const end = performance.now(); 53 | console.info(`Took ${(end - start) / 1000} seconds`); 54 | expect(0).toBe(0); 55 | }, 30000); 56 | 57 | it(`decrypt ${Config.CRYPTO_ITERATIONS} times`, () => { 58 | const keyStore = new KeyStore(); 59 | const sharedKeyStore = keyStore.getSharedKeyStore(keyStore.publicKeyBytes); 60 | const box = sharedKeyStore.encrypt(testData.bytes, testData.nonce); 61 | const start = performance.now(); 62 | 63 | for (let i = 0; i <= Config.CRYPTO_ITERATIONS; ++i) { 64 | sharedKeyStore.decrypt(box); 65 | } 66 | 67 | const end = performance.now(); 68 | console.info(`Took ${(end - start) / 1000} seconds`); 69 | expect(0).toBe(0); 70 | }, 30000); 71 | }); 72 | 73 | [false, true].forEach((useSharedKeyStore) => { 74 | describe(`Web Worker (shared key store=${useSharedKeyStore}, transferables=false)`, () => { 75 | it(`encrypt ${Config.CRYPTO_ITERATIONS} times`, (done: any) => { 76 | expect(Worker).toBeDefined(); 77 | const worker = new Worker('performance/crypto.worker.js'); 78 | let iterations = 0; 79 | worker.onmessage = () => { 80 | ++iterations; 81 | 82 | // All encryption tasks resolved? 83 | if (iterations === Config.CRYPTO_ITERATIONS) { 84 | worker.terminate(); 85 | const end = performance.now(); 86 | console.info(`Took ${(end - start) / 1000} seconds`); 87 | done(); 88 | } 89 | }; 90 | 91 | // Initialise worker as an encrypt worker 92 | const keyStore = new KeyStore(); 93 | worker.postMessage({ 94 | type: 'encrypt', 95 | secretKey: keyStore.secretKeyBytes, 96 | useSharedKeyStore: useSharedKeyStore, 97 | }); 98 | const start = performance.now(); 99 | 100 | // Enqueue encryption tasks 101 | for (let i = 0; i <= Config.CRYPTO_ITERATIONS; ++i) { 102 | worker.postMessage({ 103 | bytes: testData.bytes, 104 | nonce: testData.nonce, 105 | }); 106 | } 107 | }, 30000); 108 | 109 | it(`decrypt ${Config.CRYPTO_ITERATIONS} times`, (done: any) => { 110 | expect(Worker).toBeDefined(); 111 | const worker = new Worker('performance/crypto.worker.js'); 112 | let iterations = 0; 113 | worker.onmessage = () => { 114 | ++iterations; 115 | 116 | // All decryption tasks resolved? 117 | if (iterations === Config.CRYPTO_ITERATIONS) { 118 | worker.terminate(); 119 | const end = performance.now(); 120 | console.info(`Took ${(end - start) / 1000} seconds`); 121 | done(); 122 | } 123 | }; 124 | 125 | // Initialise worker as a decrypt worker 126 | const keyStore = new KeyStore(); 127 | const sharedKeyStore = keyStore.getSharedKeyStore(keyStore.publicKeyBytes); 128 | const box = sharedKeyStore.encrypt(testData.bytes, testData.nonce); 129 | worker.postMessage({ 130 | type: 'decrypt', 131 | secretKey: keyStore.secretKeyBytes, 132 | useSharedKeyStore: useSharedKeyStore, 133 | }); 134 | const start = performance.now(); 135 | 136 | // Enqueue encryption tasks 137 | for (let i = 0; i <= Config.CRYPTO_ITERATIONS; ++i) { 138 | worker.postMessage({ 139 | bytes: box.data, 140 | nonce: box.nonce, 141 | }); 142 | } 143 | }, 30000); 144 | }); 145 | 146 | describe(`Web Worker (shared key store=${useSharedKeyStore}, transferables=true)`, () => { 147 | it(`encrypt ${Config.CRYPTO_ITERATIONS} times`, (done: any) => { 148 | expect(Worker).toBeDefined(); 149 | 150 | const worker = new Worker('performance/crypto.worker.js'); 151 | let iterations = 0; 152 | worker.onmessage = () => { 153 | ++iterations; 154 | 155 | // All encryption tasks resolved? 156 | if (iterations === Config.CRYPTO_ITERATIONS) { 157 | worker.terminate(); 158 | const end = performance.now(); 159 | console.info(`Took ${(end - start) / 1000} seconds`); 160 | done(); 161 | } 162 | }; 163 | 164 | // Initialise worker as an encrypt worker 165 | const keyStore = new KeyStore(); 166 | const testDataArray = Array.from({ length: Config.CRYPTO_ITERATIONS }, () => { 167 | // Need to copy the plain data, so it can be transferred 168 | return testData.bytes.slice(0); 169 | }); 170 | worker.postMessage({ 171 | type: 'encrypt-transferable', 172 | secretKey: keyStore.secretKeyBytes, 173 | useSharedKeyStore: useSharedKeyStore, 174 | }); 175 | const start = performance.now(); 176 | 177 | // Enqueue encryption tasks 178 | for (const testData1 of testDataArray) { 179 | expect(testData1.buffer.byteLength).toBeGreaterThan(0); 180 | worker.postMessage({ 181 | bytes: testData1, 182 | nonce: testData.nonce, 183 | }, [testData1.buffer]); 184 | expect(testData1.buffer.byteLength).toBe(0); 185 | expect(testData.nonce.buffer.byteLength).toBeGreaterThan(0); 186 | } 187 | }, 60000); 188 | 189 | it(`decrypt ${Config.CRYPTO_ITERATIONS} times`, (done: any) => { 190 | expect(Worker).toBeDefined(); 191 | 192 | const worker = new Worker('performance/crypto.worker.js'); 193 | let iterations = 0; 194 | worker.onmessage = () => { 195 | ++iterations; 196 | 197 | // All decryption tasks resolved? 198 | if (iterations === Config.CRYPTO_ITERATIONS) { 199 | worker.terminate(); 200 | const end = performance.now(); 201 | console.info(`Took ${(end - start) / 1000} seconds`); 202 | done(); 203 | } 204 | }; 205 | 206 | // Initialise worker as a decrypt worker 207 | const keyStore = new KeyStore(); 208 | const sharedKeyStore = keyStore.getSharedKeyStore(keyStore.publicKeyBytes); 209 | const boxes = Array.from({ length: Config.CRYPTO_ITERATIONS }, () => { 210 | // Need to generate new data, so it can be transferred 211 | return sharedKeyStore.encrypt(testData.bytes, testData.nonce); 212 | }); 213 | worker.postMessage({ 214 | type: 'decrypt-transferable', 215 | secretKey: keyStore.secretKeyBytes, 216 | useSharedKeyStore: useSharedKeyStore, 217 | }); 218 | const start = performance.now(); 219 | 220 | // Enqueue encryption tasks 221 | for (const box of boxes) { 222 | expect(box.data.buffer.byteLength).toBeGreaterThan(0); 223 | worker.postMessage({ 224 | bytes: box.data, 225 | nonce: box.nonce, 226 | }, [box.data.buffer]); 227 | expect(box.data.buffer.byteLength).toBe(0); 228 | expect(box.nonce.buffer.byteLength).toBeGreaterThan(0); 229 | } 230 | }, 60000); 231 | }); 232 | }); 233 | }); 234 | }; 235 | -------------------------------------------------------------------------------- /tests/performance/crypto.worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | importScripts( 4 | '../../node_modules/tweetnacl/nacl-fast.js', 5 | '../../node_modules/msgpack-lite/dist/msgpack.min.js', 6 | '../../dist/saltyrtc-client.es5.min.js', 7 | ); 8 | 9 | let keyStore; 10 | let publicKey; 11 | let sharedkeyStore; 12 | 13 | function encrypt(e) { 14 | const cipher = keyStore.encryptRaw(e.data.bytes, e.data.nonce, publicKey); 15 | postMessage(cipher); 16 | } 17 | 18 | function encryptTransferable(e) { 19 | const cipher = keyStore.encryptRaw(e.data.bytes, e.data.nonce, publicKey); 20 | postMessage(cipher, [cipher.buffer]); 21 | } 22 | 23 | function decrypt(e) { 24 | const plain = keyStore.decryptRaw(e.data.bytes, e.data.nonce, publicKey); 25 | postMessage(plain); 26 | } 27 | 28 | function decryptTransferable(e) { 29 | const plain = keyStore.decryptRaw(e.data.bytes, e.data.nonce, publicKey); 30 | postMessage(plain, [plain.buffer]); 31 | } 32 | 33 | function encryptWithSharedKey(e) { 34 | const cipher = sharedkeyStore.encryptRaw(e.data.bytes, e.data.nonce); 35 | postMessage(cipher); 36 | } 37 | 38 | function encryptWithSharedKeyTransferable(e) { 39 | const cipher = sharedkeyStore.encryptRaw(e.data.bytes, e.data.nonce); 40 | postMessage(cipher, [cipher.buffer]); 41 | } 42 | 43 | function decryptWithSharedKey(e) { 44 | const plain = sharedkeyStore.decryptRaw(e.data.bytes, e.data.nonce); 45 | postMessage(plain); 46 | } 47 | 48 | function decryptWithSharedKeyTransferable(e) { 49 | const plain = sharedkeyStore.decryptRaw(e.data.bytes, e.data.nonce); 50 | postMessage(plain, [plain.buffer]); 51 | } 52 | 53 | addEventListener('message', (e) => { 54 | keyStore = new saltyrtcClient.KeyStore(e.data.secretKey); 55 | publicKey = keyStore.publicKeyBytes; 56 | 57 | // Optionally use the precomputed shared key 58 | let callbackFunctions; 59 | if (e.data.useSharedKeyStore) { 60 | sharedkeyStore = keyStore.getSharedKeyStore(publicKey); 61 | callbackFunctions = { 62 | 'encrypt': encryptWithSharedKey, 63 | 'decrypt': decryptWithSharedKey, 64 | 'encrypt-transferable': encryptWithSharedKeyTransferable, 65 | 'decrypt-transferable': decryptWithSharedKeyTransferable, 66 | }; 67 | } else { 68 | callbackFunctions = { 69 | 'encrypt': encrypt, 70 | 'decrypt': decrypt, 71 | 'encrypt-transferable': encryptTransferable, 72 | 'decrypt-transferable': decryptTransferable, 73 | }; 74 | } 75 | 76 | // Initialise worker by type 77 | const callbackFunction = callbackFunctions[e.data.type]; 78 | if (!callbackFunction) { 79 | console.error('Unable to determine role'); 80 | close(); 81 | return; 82 | } 83 | addEventListener('message', callbackFunction); 84 | }, { once: true }); 85 | -------------------------------------------------------------------------------- /tests/performance/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test utils. 3 | * 4 | * Copyright (C) 2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | 10 | export const testData = { 11 | bytes: new Uint8Array(2 ** 16).fill(0xee), 12 | nonce: new Uint8Array(24).fill(0xdd), 13 | }; 14 | -------------------------------------------------------------------------------- /tests/testsuite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SaltyRTC Unit Tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/testtasks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Performance test entry point. 3 | * 4 | * Copyright (C) 2016-2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | // tslint:disable:no-reference 10 | /// 11 | 12 | export class DummyTask implements saltyrtc.Task { 13 | 14 | public initialized = false; 15 | public peerData: object; 16 | protected signaling: saltyrtc.Signaling; 17 | protected name: string; 18 | 19 | public constructor(name?: string) { 20 | if (name === undefined) { 21 | this.name = 'dummy.tasks.saltyrtc.org'; 22 | } else { 23 | this.name = name; 24 | } 25 | } 26 | 27 | public init(signaling: saltyrtc.Signaling, data: object): void { 28 | this.signaling = signaling; 29 | this.peerData = data; 30 | this.initialized = true; 31 | } 32 | 33 | public onPeerHandshakeDone(): void { 34 | // Nothing 35 | } 36 | 37 | public onTaskMessage(message: saltyrtc.messages.TaskMessage): void { 38 | console.log('Got new task message'); 39 | } 40 | 41 | // noinspection JSMethodCanBeStatic 42 | public sendSignalingMessage(payload: Uint8Array) { 43 | console.log(`Sending signaling message (${payload.byteLength} bytes)`); 44 | } 45 | 46 | public getName(): string { 47 | return this.name; 48 | } 49 | 50 | public getSupportedMessageTypes(): string[] { 51 | return ['dummy']; 52 | } 53 | 54 | // noinspection JSMethodCanBeStatic 55 | public getData(): object { 56 | return {}; 57 | } 58 | 59 | public close(): void { 60 | // Do nothing 61 | } 62 | 63 | } 64 | 65 | export class PingPongTask extends DummyTask { 66 | 67 | public sentPong = false; 68 | public receivedPong = false; 69 | 70 | public constructor() { 71 | super('pingpong.tasks.saltyrtc.org'); 72 | } 73 | 74 | public getSupportedMessageTypes(): string[] { 75 | return ['ping', 'pong']; 76 | } 77 | 78 | public onPeerHandshakeDone(): void { 79 | if (this.signaling.role === 'initiator') { 80 | this.sendPing(); 81 | } 82 | } 83 | 84 | public sendPing(): void { 85 | console.log('[PingPongTask] Sending ping'); 86 | this.signaling.sendTaskMessage({'type': 'ping'}); 87 | } 88 | 89 | public sendPong(): void { 90 | console.log('[PingPongTask] Sending pong'); 91 | this.signaling.sendTaskMessage({'type': 'pong'}); 92 | this.sentPong = true; 93 | } 94 | 95 | public onTaskMessage(message: saltyrtc.messages.TaskMessage): void { 96 | if (message.type === 'ping') { 97 | console.log('[PingPongTask] Received ping'); 98 | this.sendPong(); 99 | } else if (message.type === 'pong') { 100 | console.log('[PingPongTask] Received pong'); 101 | this.receivedPong = true; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:file-header 2 | // tslint:disable:no-reference 3 | /// 4 | 5 | import { 6 | arraysAreEqual, byteToHex, concat, hexToU8a, isString, 7 | randomString, randomUint32, u8aToHex, waitFor, 8 | } from '../src/utils'; 9 | 10 | export default () => { describe('utils', function() { 11 | 12 | describe('hexToU8a / u8aToHex', function() { 13 | 14 | it('conversion from Uint8Array to hex works', () => { 15 | const source = new Uint8Array([0x01, 0x10, 0xde, 0xad, 0xbe, 0xef]); 16 | expect(u8aToHex(source)).toEqual('0110deadbeef'); 17 | }); 18 | 19 | it('conversion from hex to Uint8Array works', () => { 20 | const expected = new Uint8Array([0x01, 0x10, 0xde, 0xad, 0xbe, 0xef]); 21 | expect(hexToU8a('0110deadbeef')).toEqual(expected); 22 | }); 23 | 24 | it('u8a -> hex -> ua8 works properly', () => { 25 | const source = new Uint8Array([0x01, 0x10, 0xde, 0xad, 0xbe, 0xef]); 26 | expect(hexToU8a(u8aToHex(source))).toEqual(source); 27 | }); 28 | 29 | it('hex -> u8a -> hex works properly', () => { 30 | const source = 'f00baa'; 31 | expect(u8aToHex(hexToU8a(source))).toEqual(source); 32 | }); 33 | 34 | it('single-character conversion from hex to Uint8Array works', () => { 35 | expect(hexToU8a('a')).toEqual(new Uint8Array([0x0a])); 36 | }); 37 | 38 | }); 39 | 40 | describe('randomString', function() { 41 | 42 | it('generates a 32 character random string', () => { 43 | const random1 = randomString(); 44 | const random2 = randomString(); 45 | expect(random1 !== random2).toBe(true); 46 | expect(random1.length).toEqual(random2.length); 47 | expect(random1.length).toEqual(32); 48 | }); 49 | 50 | }); 51 | 52 | describe('concat', function() { 53 | 54 | it('does not change a single array', () => { 55 | const src = Uint8Array.of(1, 2, 3, 4); 56 | expect(concat(src)).toEqual(src); 57 | }); 58 | 59 | it('concatenates two arrays', () => { 60 | const src1 = Uint8Array.of(1, 2, 3, 4); 61 | const src2 = Uint8Array.of(5, 6); 62 | expect(concat(src1, src2)) 63 | .toEqual(Uint8Array.of(1, 2, 3, 4, 5, 6)); 64 | }); 65 | 66 | it('concatenates multiple arrays', () => { 67 | const src1 = Uint8Array.of(1, 2, 3, 4); 68 | const src2 = Uint8Array.of(5, 6); 69 | const src3 = Uint8Array.of(7); 70 | const src4 = Uint8Array.of(7, 8, 9); 71 | expect(concat(src1, src2, src3, src4)) 72 | .toEqual(Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 7, 8, 9)); 73 | }); 74 | 75 | }); 76 | 77 | describe('randomUint32', function() { 78 | 79 | it('generates a random number between 0 and 2**32', () => { 80 | let lastNum: number = null; 81 | for (let i = 0; i < 50; i++) { 82 | const num = randomUint32(); 83 | expect(num).not.toEqual(lastNum); 84 | expect(num).toBeGreaterThan(-1); 85 | expect(num).toBeLessThan(0x100000000 + 1); 86 | lastNum = num; 87 | } 88 | }); 89 | 90 | }); 91 | 92 | describe('byteToHex', function() { 93 | 94 | it('converts 0 to 0x00', () => { 95 | expect(byteToHex(0)).toEqual('0x00'); 96 | }); 97 | 98 | it('converts 9 to 0x09', () => { 99 | expect(byteToHex(9)).toEqual('0x09'); 100 | }); 101 | 102 | it('converts 10 to 0x0a', () => { 103 | expect(byteToHex(10)).toEqual('0x0a'); 104 | }); 105 | 106 | it('converts 255 to 0xff', () => { 107 | expect(byteToHex(255)).toEqual('0xff'); 108 | }); 109 | 110 | }); 111 | 112 | describe('waitFor', function() { 113 | 114 | it('retries until the condition is met', (done: any) => { 115 | let i = 3; 116 | // To test, this condition has a side effect. 117 | // It will return true the 4th time it is called. 118 | const test = () => { 119 | i--; 120 | return i < 0; 121 | }; 122 | waitFor(test, 20, 10, () => { 123 | expect(i).toBe(-1); 124 | done(); 125 | }, done.fail); 126 | }); 127 | 128 | it('fails if the condition is not met', (done: any) => { 129 | let tries = 0; 130 | const test = () => { 131 | tries += 1; 132 | return false; 133 | }; 134 | waitFor(test, 20, 3, done.fail, () => { 135 | expect(tries).toBe(3); 136 | done(); 137 | }); 138 | }); 139 | 140 | }); 141 | 142 | describe('isString', function() { 143 | it('detects strings', () => { 144 | expect(isString('hello')).toEqual(true); 145 | // tslint:disable-next-line:no-construct 146 | expect(isString(new String('hello'))).toEqual(true); 147 | expect(isString(String)).toEqual(false); 148 | expect(isString(1232)).toEqual(false); 149 | }); 150 | }); 151 | 152 | describe('arraysAreEqual', function() { 153 | it('returns false when arrays have different length', () => { 154 | expect(arraysAreEqual(Uint8Array.of(1, 1), Uint8Array.of(1))).toEqual(false); 155 | }); 156 | 157 | it('returns true when arrays are both empty', () => { 158 | expect(arraysAreEqual(new Uint8Array([]), new Uint8Array([]))).toEqual(true); 159 | }); 160 | 161 | it('returns false when arrays are different', () => { 162 | expect(arraysAreEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 3, 2]))).toEqual(false); 163 | }); 164 | 165 | it('returns true when arrays are the same', () => { 166 | expect(arraysAreEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toEqual(true); 167 | }); 168 | }); 169 | 170 | }); }; 171 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test utils. 3 | * 4 | * Copyright (C) 2016-2022 Threema GmbH 5 | * 6 | * This software may be modified and distributed under the terms 7 | * of the MIT license. See the `LICENSE.md` file for details. 8 | */ 9 | 10 | /** 11 | * Awaitable promise that sleeps n milliseconds. 12 | */ 13 | export function sleep(milliseconds: number): Promise<{}> { 14 | return new Promise(function(resolve) { 15 | window.setTimeout(resolve, milliseconds); 16 | }); 17 | } 18 | 19 | /** 20 | * Type alias for a function that takes no arguments and returns nothing. 21 | */ 22 | export type Runnable = () => void; 23 | 24 | export interface PromiseFn { 25 | resolve: (value: V) => void; 26 | reject: (reason?: E) => void; 27 | } 28 | 29 | /** 30 | * A {Promise} that allows to resolve or reject outside of the executor and 31 | * query the current status. 32 | */ 33 | export class ResolvablePromise extends Promise { 34 | private _done: boolean; 35 | private readonly _inner: PromiseFn, E>; 36 | 37 | public constructor( 38 | executor?: ( 39 | resolve: (value: V | PromiseLike) => void, 40 | reject: (reason?: E) => void, 41 | ) => void, 42 | ) { 43 | // We have to do this little dance here since `this` cannot be used 44 | // prior to having called `super`. 45 | const inner: PromiseFn, E> = { 46 | resolve: ResolvablePromise._fail, 47 | reject: ResolvablePromise._fail, 48 | }; 49 | const outer: PromiseFn, E> = { 50 | resolve: (value) => this.resolve(value), 51 | reject: (reason) => this.reject(reason), 52 | }; 53 | super( 54 | ( 55 | innerResolve: (value: V | PromiseLike) => void, 56 | innerReject: (reason?: E) => void, 57 | ) => { 58 | inner.resolve = innerResolve; 59 | inner.reject = innerReject; 60 | if (executor) { 61 | executor(outer.resolve, outer.reject); 62 | return; 63 | } 64 | } 65 | ); 66 | this._inner = { 67 | resolve: inner.resolve, 68 | reject: inner.reject, 69 | }; 70 | this._done = false; 71 | } 72 | 73 | /** 74 | * Called if the promise resolve/rejector methods were not available. 75 | * This should never happen! 76 | */ 77 | private static _fail(): void { 78 | throw new Error('Promise resolve/reject not available'); 79 | } 80 | 81 | /** 82 | * Return whether the promise is done (resolved or rejected). 83 | */ 84 | public get done(): boolean { 85 | return this._done; 86 | } 87 | 88 | /** 89 | * Resolve the promise from the outside. 90 | */ 91 | public resolve(value: V | PromiseLike): void { 92 | this._done = true; 93 | this._inner.resolve(value); 94 | } 95 | 96 | /** 97 | * Reject the promise from the outside. 98 | */ 99 | public reject(reason?: E): void { 100 | this._done = true; 101 | this._inner.reject(reason); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "removeComments": true, 7 | "noImplicitAny": true, 8 | "isolatedModules": true 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | "dist", 13 | "example.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "file-header": [true, "Copyright \\(C\\)"], 5 | "indent": [true, "spaces"], 6 | "interface-name": [true, "never-prefix"], 7 | "max-classes-per-file": false, 8 | "member-ordering": false, 9 | "no-console": [false], 10 | "no-namespace": [true, "allow-declarations"], 11 | "object-literal-shorthand": false, 12 | "object-literal-sort-keys": false, 13 | "only-arrow-functions": false, 14 | "quotemark": [true, "single", "avoid-escape"], 15 | "variable-name": [true, "check-format", "allow-leading-underscore", "ban-keywords"], 16 | "whitespace": [true, "check-branch", "check-decl", "check-operator", "check-module", "check-separator", "check-rest-spread", "check-type", "check-typecast", "check-type-operator", "check-preblock"] 17 | } 18 | } 19 | --------------------------------------------------------------------------------