├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .nvmrc
├── LICENSE
├── README.md
├── config
├── default.json
├── development.json
├── production.json
├── staging.json
├── test-lib.json
└── test.json
├── examples
├── LocalSignalProtocolStore.js
└── client.js
├── package-lock.json
├── package.json
├── protos
├── DeviceMessages.proto
├── DeviceName.proto
├── IncomingPushMessageSignal.proto
├── SignalService.proto
├── Stickers.proto
├── SubProtocol.proto
├── UnidentifiedDelivery.proto
└── WhisperTextProtocol.proto
├── src
├── AccountManager.js
├── AttachmentHelper.js
├── Event.js
├── EventTarget.js
├── Message.js
├── MessageReceiver.js
├── MessageSender.js
├── Metadata.js
├── OutgoingMessage.js
├── ProtocolStore.js
├── ProvisioningCipher.js
├── WebAPI.js
├── WebSocketResource.js
├── crypto.js
├── errors.js
├── helpers.js
├── index.js
├── libphonenumber-util.js
├── protobufs.js
└── taskWithTimeout.js
└── test
├── InMemorySignalProtocolStore.js
├── _test.js
├── account_manager_test.js
├── contacts_parser_test.js
├── crypto_test.js
├── generate_keys_test.js
├── helpers_test.js
├── message_receiver_test.js
├── protocol_wrapper_test.js
├── storage_test.js
├── task_with_timeout_test.js
└── websocket-resources_test.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/examples/*.js
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // For reference: https://github.com/airbnb/javascript
2 |
3 | module.exports = {
4 | settings: {
5 | 'import/core-modules': ['electron'],
6 | },
7 |
8 | extends: ['airbnb-base', 'prettier'],
9 |
10 | plugins: ['mocha', 'more'],
11 |
12 | rules: {
13 | 'comma-dangle': [
14 | 'error',
15 | {
16 | arrays: 'always-multiline',
17 | objects: 'always-multiline',
18 | imports: 'always-multiline',
19 | exports: 'always-multiline',
20 | functions: 'never',
21 | },
22 | ],
23 |
24 | // prevents us from accidentally checking in exclusive tests (`.only`):
25 | 'mocha/no-exclusive-tests': 'error',
26 |
27 | // encourage consistent use of `async` / `await` instead of `then`
28 | // shutting this off pending TS re-port - jk
29 | 'more/no-then': 'off',
30 |
31 | // it helps readability to put public API at top,
32 | 'no-use-before-define': 'off',
33 |
34 | // useful for unused or internal fields
35 | 'no-underscore-dangle': 'off',
36 |
37 | // though we have a logger, we still remap console to log to disk
38 | 'no-console': 'error',
39 |
40 | // consistently place operators at end of line except ternaries
41 | 'operator-linebreak': 'error',
42 |
43 | // also shutting these off pending re-port - jk
44 | 'no-multi-assign': 'off',
45 | 'camelcase': 'off',
46 | 'max-classes-per-file': 'off',
47 | 'class-methods-use-this': 'off',
48 |
49 | quotes: [
50 | 'error',
51 | 'single',
52 | { avoidEscape: true, allowTemplateLiterals: false },
53 | ],
54 |
55 | // Prettier overrides:
56 | 'arrow-parens': 'off',
57 | 'function-paren-newline': 'off',
58 | 'max-len': [
59 | 'error',
60 | {
61 | // Prettier generally limits line length to 80 but sometimes goes over.
62 | // The `max-len` plugin doesn’t let us omit `code` so we set it to a
63 | // high value as a buffer to let Prettier control the line length:
64 | code: 999,
65 | // We still want to limit comments as before:
66 | comments: 90,
67 | ignoreUrls: true,
68 | },
69 | ],
70 | },
71 | };
72 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
39 | tags
40 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/*
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # libsignal-service-javascript
2 | **This is a third-party effort, and is NOT a part of the official [Signal](https://signal.org) project or any other project of [Open Whisper Systems](https://whispersystems.org).**
3 |
4 | A javascript library for basic interaction with the [Signal](https://signal.org) messaging service. This library is a standalone port to [Node.js](https://nodejs.org) of the backend components of [Signal-Desktop](https://github.com/WhisperSystems/Signal-Desktop). Not to be confused with [libsignal-protocol-javascript](https://github.com/signalapp/libsignal-protocol-javascript), which only includes the Signal wire protocol, this library contains the logic for actually interacting with the Signal messaging servers as currently operated by OWS. As such, it is intended to be a Javascript equivalent of [libsignal-service-java](https://github.com/signalapp/libsignal-service-java) and provide a similar API.
5 |
6 | ## Usage
7 |
8 | To use this in your Node.js project, run the following from your project directory:
9 |
10 | `npm install --save @throneless/libsignal-service`
11 |
12 | The overall design of the library attempts to keep the overall feel of the upstream code in order to make it (somewhat) easier to keep up with upstream changes, while trying to be relatively ergonomic for developers using this is in their own projects. The library is split into three components that encompass particular functionality:
13 |
14 | * `AccountManager` for registering and confirming accounts.
15 | * `MessageSender` for sending messages.
16 | * `MessageReceiver` for receiving messages.
17 |
18 | Those three components in turn share a fourth component, `ProtocolStore`, to track overall state. This includes key and session data as well as unprocessed messages. There are two additional top-level components that provide various helper functions:
19 |
20 | * `KeyHelper` for various cryptographic helper functions.
21 | * `AttachmentHelper` for file management helper functions.
22 |
23 | Much of the Signal service relies on phone numbers, and when using this library you must use phone numbers in [E.123](https://en.wikipedia.org/wiki/E.123) format (without spaces). So for instance a U.S. phone number (555) 555-5555 would be written +15555555.
24 |
25 | ### Initializing the store
26 |
27 | Usage of the library requires a `ProtocolStore` to save keys and other state. This storage is pluggable for different storage backends via a relatively straightforward API. An example that uses [node-localstorage](https://github.com/lmaccherone/node-localstorage) can be found in the `examples` directory, and an example that just stores the keys in-memory can be found in the `tests` directory. See below for an overview of creating your own storage backend.
28 |
29 | ```
30 | const Signal = require('libsignal-service');
31 | const myBackend = new MyStorageBackend(); // This is your storage backend implementation.
32 | const protocolStore = new Signal.ProtocolStore(myBackend);
33 | protocolStore.load(); // Load the data from the backend into the in-memory cache.
34 | ```
35 |
36 | ### Registering accounts
37 |
38 | Registering an account takes place in two phases. First, you'll request a confirmation code from the Signal server that you are authorized to use this number (when experimenting you probably want to get a temporary phone number via an online service like Google Voice or Twilio rather than clobbering the keys for your own phone! For safety, the library uses the Signal staging server by default. **This means that it will only send and receive messages from other clients using the staging server!** *If utilized with `NODE_ENV=production`, it will use the live Signal server*). The password below is an arbitrary string used for authentication against the Signal API, it will be registered with the Signal servers as part of the registration process.
39 |
40 | ```
41 | const password = Signal.KeyHelper.generatePassword(); // A simple helper function to generate a random password.
42 | const accountManager = new Signal.AccountManager(myPhoneNumber, password, protocolStore); // The protocolStore from above
43 | accountManager.requestSMSVerification().then(result => {
44 | console.log("Sent verification code.");
45 | });
46 | ```
47 |
48 | You'll receive an SMS message with an authentication code at the number your specified (or a voice call, if you use `requestVoiceVerification()` instead). Use the code (without any hyphens or spaces) to register your account with the Signal service:
49 |
50 | ```
51 | accountManager.registerSingleDevice("myCode").then(result => {
52 | console.log("Registered account.");
53 | });
54 | ```
55 |
56 | ### Sending messages
57 |
58 | To send a message, connect a `MessageSender` instance to the Signal service:
59 |
60 | ```
61 | const messageSender = new Signal.MessageSender(protocolStore);
62 | messageSender.connect().then(() => {
63 | messageSender.sendMessageToNumber({
64 | number: destinationNumber,
65 | body: "Hello world!"
66 | })
67 | .then(result => {
68 | console.log(result);
69 | });
70 | });
71 | ```
72 |
73 | If the `sendMessageToNumber()` function also takes an array of attachments, if you wish to send one or more files. To help process files into a format Signal understands, we can use the `AttachmentHelper`:
74 |
75 | ```
76 | const attachments = [];
77 | messageSender.connect().then(() => {
78 | Signal.AttachmentHelper.loadFile(path) // a path to the file to send, can also take a caption string
79 | .then(file => {
80 | attachments.push(file);
81 | })
82 | .then(() => {
83 | messageSender.sendMessageToNumber({
84 | number: destinationNumber,
85 | body: "Hello world!",
86 | attachments: attachments
87 | })
88 | .then(result => {
89 | console.log(result);
90 | });
91 | });
92 | });
93 | ```
94 |
95 | For more complex examples, including experimental support for managing and sending to groups, see `client.js` in the examples directory.
96 |
97 | ### Receiving messages
98 |
99 | To receive messages, you connect a `MessageReceiver` instance to the Signal service and then subscribe to its EventEmitter to listen for particular events. The message is returned as an attribute of the event object:
100 |
101 | ```
102 | const messageReceiver = new Signal.MessageReceiver(protocolStore);
103 | messageReceiver.connect().then(() => {
104 | // Subscribe to the "message" event
105 | messageReceiver.addEventListener("message", ev => {
106 | ev.data.message.attachments.map(attachment => {
107 | messageReceiver
108 | .handleAttachment(attachment)
109 | .then(attachmentPointer => {
110 | // if there are attachments, save them to the current directory.
111 | Signal.AttachmentHelper.saveFile(attachmentPointer, "./").then(
112 | fileName => {
113 | console.log("Wrote file to: ", fileName);
114 | }
115 | );
116 | });
117 | });
118 | if (ev.data.message.group) {
119 | console.log(ev.data.message.group);
120 | console.log(
121 | "Received message in group " +
122 | ev.data.message.group.id +
123 | ": " +
124 | ev.data.message.body
125 | );
126 | } else {
127 | console.log("Received message: ", ev.data.message.body);
128 | }
129 | ev.confirm();
130 | });
131 | });
132 | ```
133 |
134 | For more events you can listen for, see `client.js` in the examples directory.
135 |
136 | ### Implementing your own storage backend
137 |
138 | So how do you store keys and other information in your application's database? Well, you can implement your own storage backend. Just create an object that implements the following methods. The storage backend stores arbitrary JSON objects that each have an 'id' property that holds a string. They're split into different types of data (identity keys, sessions, pre-keys, signed pre-keys, unprocessed messages, and configuration) so that these can easily be split into, for example, different database tables.
139 |
140 | ```
141 | class myStorageBackend {
142 | async getAllIdentityKeys() {}
143 | async createOrUpdateIdentityKey(data) {}
144 | async removeIdentityKeyById(id) {}
145 |
146 | async getAllSessions() {}
147 | async createOrUpdateSession(data) {}
148 | async removeSessionById(id) {}
149 | async removeSessionsByNumber(number) {}
150 | async removeAllSessions() {}
151 |
152 | async getAllPreKeys() {}
153 | async createOrUpdatePreKey(data) {}
154 | async removePreKeyById(id) {}
155 | async removeAllPreKeys() {}
156 |
157 | async getAllSignedPreKeys() {}
158 | async createOrUpdateSignedPreKey(data) {}
159 | async removeSignedPreKeyById(id) {}
160 | async removeAllSignedPreKeys() {}
161 |
162 | async getAllUnprocessed() {}
163 | async getUnprocessedCount() {} // returns the number of unprocessed messages
164 | async getUnprocessedById(id) {}
165 | async saveUnprocessed(data) {}
166 | async updateUnprocessedAttempts(id, attempts) {} // updates the 'attempts' property of the unprocessed message
167 | async updateUnprocessedWithData(id, data) {}
168 | async removeUnprocessed(id) {}
169 | async removeAllUnprocessed() {}
170 |
171 | async getAllConfiguration() {}
172 | async createOrUpdateConfiguration(data) {}
173 | async removeConfigurationById(id) {}
174 | async removeAllConfiguration() {}
175 |
176 | async removeAll() {} // clears all storage
177 | }
178 | ```
179 |
180 | For more details, an example that uses [node-localstorage](https://github.com/lmaccherone/node-localstorage) can be found in the `examples` directory, and an example that just stores the keys in-memory can be found in the `tests` directory.
181 |
182 | ## Todo
183 |
184 | * [X] Additional documentation.
185 | * [X] Simplify `ProtocolStore` API.
186 | * [X] Cleanup frontend API.
187 | * [X] Update wire-protocol dependency.
188 | * [ ] Additional unit test coverage.
189 | * [ ] Webpack integration for browser support.
190 |
191 | ## License
192 | [
](http://www.gnu.org/licenses/gpl-3.0.html)
193 |
194 | Libsignal-service-javascript is a free software project licensed under the GNU General Public License v3.0 (GPLv3) by [Throneless Tech](https://throneless.tech).
195 |
196 | It is derived in part from [Signal-Desktop](https://github.com/WhisperSystems/Signal-Desktop) which is Copyright (c) 2014-2018 Open Whisper Systems, also under the GPLv3.
197 |
--------------------------------------------------------------------------------
/config/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "serverUrl": "https://textsecure-service-staging.whispersystems.org",
3 | "cdn": {
4 | "0": "https://cdn-staging.signal.org",
5 | "2": "https://cdn2-staging.signal.org"
6 | },
7 | "contentProxyUrl": "http://contentproxy.signal.org:443",
8 | "updatesUrl": "https://updates2.signal.org/desktop",
9 | "updatesPublicKey": "fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401",
10 | "updatesEnabled": false,
11 | "openDevTools": false,
12 | "buildExpiration": 0,
13 | "certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n",
14 | "import": false,
15 | "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx"
16 | }
17 |
--------------------------------------------------------------------------------
/config/development.json:
--------------------------------------------------------------------------------
1 | {
2 | "storageProfile": "development",
3 | "openDevTools": true
4 | }
5 |
--------------------------------------------------------------------------------
/config/production.json:
--------------------------------------------------------------------------------
1 | {
2 | "serverUrl": "https://textsecure-service.whispersystems.org",
3 | "cdn": {
4 | "0": "https://cdn.signal.org",
5 | "2": "https://cdn2.signal.org"
6 | },
7 | "serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF",
8 | "updatesEnabled": true
9 | }
10 |
--------------------------------------------------------------------------------
/config/staging.json:
--------------------------------------------------------------------------------
1 | {
2 | "storageProfile": "staging",
3 | "openDevTools": true
4 | }
5 |
--------------------------------------------------------------------------------
/config/test-lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "storageProfile": "test",
3 | "openDevTools": false
4 | }
5 |
--------------------------------------------------------------------------------
/config/test.json:
--------------------------------------------------------------------------------
1 | {
2 | "storageProfile": "test",
3 | "openDevTools": false
4 | }
5 |
--------------------------------------------------------------------------------
/examples/LocalSignalProtocolStore.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const helpers = require("../src/helpers.js");
3 | const LocalStorage = require("node-localstorage").LocalStorage;
4 |
5 | class Storage {
6 | constructor(path) {
7 | this._store = new LocalStorage(path);
8 | }
9 |
10 | _put(namespace, id, data) {
11 | this._store.setItem("" + namespace + id, helpers.jsonThing(data));
12 | }
13 |
14 | _get(namespace, id) {
15 | const value = this._store.getItem("" + namespace + id);
16 | return JSON.parse(value);
17 | }
18 |
19 | _getAll(namespace) {
20 | const collection = [];
21 | for (let id of this._store._keys) {
22 | if (id.startsWith(namespace)) {
23 | collection.push(this._get("", id));
24 | }
25 | }
26 | return collection;
27 | }
28 |
29 | _getAllIds(namespace) {
30 | const collection = [];
31 | for (let key of this._store._keys) {
32 | if (key.startsWith(namespace)) {
33 | const { id } = this._get("", key);
34 | collection.push(id);
35 | }
36 | }
37 | return collection;
38 | }
39 |
40 | _remove(namespace, id) {
41 | this._store.removeItem("" + namespace + id);
42 | }
43 |
44 | _removeAll(namespace) {
45 | for (let id of this._store._keys) {
46 | if (id.startsWith(namespace)) {
47 | this._remove("", id);
48 | }
49 | }
50 | }
51 |
52 | async getAllIdentityKeys() {
53 | return this._getAll("identityKey");
54 | }
55 |
56 | async createOrUpdateIdentityKey(data) {
57 | const { id } = data;
58 | this._put("identityKey", id, data);
59 | }
60 |
61 | async removeIdentityKeyById(id) {
62 | this._remove("identityKey", id);
63 | }
64 |
65 | async getAllSessions() {
66 | return this._getAll("session");
67 | }
68 |
69 | async createOrUpdateSession(data) {
70 | const { id } = data;
71 | this._put("session", id, data);
72 | }
73 |
74 | async removeSessionById(id) {
75 | this._remove("session", id);
76 | }
77 |
78 | async removeSessionsByNumber(number) {
79 | for (let id of this._store._keys) {
80 | if (id.startsWith("session")) {
81 | const session = this._get("", id);
82 | if (session.number === number) {
83 | this._remove("", id);
84 | }
85 | }
86 | }
87 | }
88 |
89 | async removeAllSessions() {
90 | this._removeAll("session");
91 | }
92 |
93 | async getAllPreKeys() {
94 | return this._getAll("25519KeypreKey");
95 | }
96 |
97 | async createOrUpdatePreKey(data) {
98 | const { id } = data;
99 | this._put("25519KeypreKey", id, data);
100 | }
101 | async removePreKeyById(id) {
102 | this._remove("25519KeypreKey", id);
103 | }
104 | async removeAllPreKeys() {
105 | return this._removeAll("25519KeypreKey");
106 | }
107 |
108 | async getAllSignedPreKeys() {
109 | return this._getAll("25519KeysignedKey");
110 | }
111 |
112 | async createOrUpdateSignedPreKey(data) {
113 | const { id } = data;
114 | this._put("25519KeysignedKey", id, data);
115 | }
116 |
117 | async removeSignedPreKeyById(id) {
118 | this._remove("25519KeysignedKey", id);
119 | }
120 | async removeAllSignedPreKeys() {
121 | this._removeAll("25519KeysignedKey");
122 | }
123 |
124 | async getAllUnprocessed() {
125 | return this._getAll("unprocessed");
126 | }
127 |
128 | getUnprocessedCount() {
129 | let count = 0;
130 | for (let id of this._store._keys) {
131 | if (id.startsWith("unprocessed")) {
132 | count++;
133 | }
134 | }
135 | return count;
136 | }
137 |
138 | getUnprocessedById(id) {
139 | return this._get("unprocessed", id);
140 | }
141 |
142 | saveUnprocessed(data) {
143 | const { id } = data;
144 | this._put("unprocessed", id, data);
145 | }
146 |
147 | updateUnprocessedAttempts(id, attempts) {
148 | const data = this._get("unprocessed", id);
149 | data.attempts = attempts;
150 | this._put("unprocessed", id, data);
151 | }
152 |
153 | updateUnprocessedWithData(id, data) {
154 | this._put("unprocessed", id, data);
155 | }
156 |
157 | removeUnprocessed(id) {
158 | this._remove("unprocessed", id);
159 | }
160 |
161 | removeAllUnprocessed() {
162 | this._removeAll("unprocessed");
163 | }
164 |
165 | async createOrUpdateGroup(data) {
166 | const { id } = data;
167 | this._put("groups", id, data);
168 | }
169 |
170 | async getGroupById(id) {
171 | return this._get("groups", id);
172 | }
173 |
174 | async getAllGroups() {
175 | return this._getAll("groups");
176 | }
177 |
178 | async getAllGroupIds() {
179 | return this._getAllIds("groups");
180 | }
181 |
182 | async removeGroupById(id) {
183 | this._remove("groups", id);
184 | }
185 |
186 | async getAllConfiguration() {
187 | return this._getAll("configuration");
188 | }
189 |
190 | async createOrUpdateConfiguration(data) {
191 | const { id } = data;
192 | this._put("configuration", id, data);
193 | }
194 |
195 | async removeConfigurationById(id) {
196 | this._remove("configuration", id);
197 | }
198 |
199 | async removeAllConfiguration() {
200 | this._removeAll("configuration");
201 | }
202 |
203 | async removeAll() {
204 | this._store.clear();
205 | }
206 | }
207 |
208 | exports = module.exports = Storage;
209 |
--------------------------------------------------------------------------------
/examples/client.js:
--------------------------------------------------------------------------------
1 | /*
2 | * This example script allows you to register a phone number with Signal via
3 | * SMS and then send and receive a message. It uses the node-localstorage
4 | * module to save state information to a directory path supplied by the
5 | * environent variable 'STORE'.
6 | *
7 | * For example, with two numbers (by default this utilizes the Signal staging
8 | * server so it is safe to use without it clobbering your keys). The password
9 | * is an arbitrary string, it just must remain consistent between requests:
10 | *
11 | * # Request a verification code via SMS for the first number:
12 | * STORE=./first node ./example/client.js requestSMS +15555555555
13 | *
14 | * # Or via voice:
15 | * STORE=./first node ./example/client.js requestVoice +15555555555
16 | *
17 | * # You then receive an SMS to +15555555555 with the code. Verify it:
18 | * STORE=./first node ./example/client.js register +15555555555
19 | *
20 | * # Repeat the process with a second number:
21 | * STORE=./second node ./example/client.js request +15555556666
22 | * STORE=./second node ./example/client.js register +15555556666
23 | *
24 | * # Now in one terminal listen for messages with one number:
25 | * STORE=./first node ./example/client.js receive
26 | *
27 | * # And in another terminal send the that number a message:
28 | * STORE=./second node ./example/client.js send +15555555555 "PING"
29 | *
30 | * # In the first terminal you should see message output, including "PING"
31 | *
32 | * # To send a file, include the path after your message text:
33 | * STORE=./second node ./example/client.js send +15555555555 "PING" /tmp/foo.jpg
34 | *
35 | * # To update the expiration timer of a conversation:
36 | * STORE=./second node ./example/client.js expire +15555555555
37 | *
38 | */
39 |
40 | const Signal = require('../src/index.js');
41 | const Storage = require('./LocalSignalProtocolStore.js');
42 |
43 | const protocolStore = new Signal.ProtocolStore(new Storage(process.env.STORE));
44 | protocolStore.load();
45 | const ByteBuffer = require('bytebuffer');
46 | const fs = require('fs');
47 | const path = require('path');
48 |
49 | const args = process.argv.slice(2);
50 |
51 | function printError(error) {
52 | console.log(error);
53 | }
54 |
55 | let accountManager;
56 | let messageSender;
57 | let username;
58 | let password;
59 | let number;
60 | let numbers;
61 | let groupId;
62 | let text;
63 | let expire;
64 |
65 | switch (args[0]) {
66 | case 'request':
67 | case 'requestSMS':
68 | username = args[1];
69 | password = args[2];
70 | accountManager = new Signal.AccountManager(
71 | username,
72 | password,
73 | protocolStore
74 | );
75 |
76 | accountManager
77 | .requestSMSVerification()
78 | .then(result => {
79 | console.log('Sent verification code.');
80 |
81 | })
82 | .catch(printError);
83 | break;
84 | case 'requestVoice':
85 | username = args[1];
86 | password = args[2];
87 | accountManager = new Signal.AccountManager(
88 | username,
89 | password,
90 | protocolStore
91 | );
92 |
93 | accountManager
94 | .requestVoiceVerification()
95 | .then(result => {
96 | console.log('Calling for verification.');
97 |
98 | })
99 | .catch(printError);
100 | break;
101 | case 'register':
102 | username = args[1];
103 | password = args[2];
104 | const code = args[3];
105 | accountManager = new Signal.AccountManager(
106 | username,
107 | password,
108 | protocolStore
109 | );
110 |
111 | accountManager
112 | .registerSingleDevice(code)
113 | .then(result => {
114 | console.log(result);
115 | })
116 | .catch(printError);
117 | break;
118 | case 'send':
119 | number = args[1];
120 | text = args[2];
121 | attachments = [];
122 | messageSender = new Signal.MessageSender(protocolStore);
123 | messageSender.connect().then(() => {
124 | if (args[3]) {
125 | Signal.AttachmentHelper.loadFile(args[3])
126 | .then(file => {
127 | attachments.push(file);
128 | })
129 | .then(() => {
130 | messageSender
131 | .sendMessageToNumber({
132 | number,
133 | body: text,
134 | attachments,
135 | })
136 | .then(result => {
137 | console.log(result);
138 | })
139 | .catch(printError);
140 | });
141 | } else {
142 | messageSender
143 | .sendMessageToNumber({
144 | number,
145 | body: text,
146 | attachments,
147 | })
148 | .then(result => {
149 | console.log(result);
150 | })
151 | .catch(printError);
152 | }
153 | });
154 | break;
155 | case 'sendToGroup':
156 | groupId = args[1];
157 | numbers = args[2].split(',');
158 | text = args[3];
159 | attachments = [];
160 | messageSender = new Signal.MessageSender(protocolStore);
161 | messageSender.connect().then(() => {
162 | if (args[4]) {
163 | Signal.AttachmentHelper.loadFile(args[4])
164 | .then(file => {
165 | attachments.push(file);
166 | })
167 | .then(() => {
168 | messageSender
169 | .sendMessageToGroup({
170 | groupId,
171 | recipients: numbers,
172 | body: text,
173 | attachments,
174 | })
175 | .then(result => {
176 | console.log(result);
177 | })
178 | .catch(printError);
179 | });
180 | } else {
181 | messageSender
182 | .sendMessageToGroup({
183 | groupId,
184 | recipients: numbers,
185 | body: text,
186 | })
187 | .then(result => {
188 | console.log(result);
189 | })
190 | .catch(printError);
191 | }
192 | });
193 | break;
194 | case 'expire':
195 | number = args[1];
196 | expire = args[2];
197 | messageSender = new Signal.MessageSender(protocolStore);
198 | messageSender.connect().then(() => {
199 | messageSender
200 | .sendExpirationTimerUpdateToNumber(number, parseInt(expire))
201 | .then(result => {
202 | console.log(result);
203 | })
204 | .catch(printError);
205 | });
206 | break;
207 | case 'createGroup':
208 | name = args[1];
209 | numbers = args[2];
210 | messageSender = new Signal.MessageSender(protocolStore);
211 | messageSender.connect().then(() => {
212 | groupId = Signal.KeyHelper.generateGroupId();
213 | messageSender
214 | .createGroup(numbers.split(','), groupId, name)
215 | .then(result => {
216 | console.log('Created group with ID: ', groupId);
217 | })
218 | .catch(printError);
219 | });
220 | break;
221 | case 'leaveGroup':
222 | groupId = args[1];
223 | numbers = args[2].split(',');
224 | messageSender = new Signal.MessageSender(protocolStore);
225 | messageSender.connect().then(() => {
226 | messageSender
227 | .leaveGroup(groupId, numbers)
228 | .then(result => {
229 | console.log(result);
230 | console.log('Left group with ID: ', groupId);
231 | })
232 | .catch(printError);
233 | });
234 | break;
235 | case 'receive':
236 | const messageReceiver = new Signal.MessageReceiver(protocolStore);
237 | messageReceiver.connect().then(() => {
238 | messageReceiver.addEventListener('message', ev => {
239 | console.log('*** EVENT ***:', ev);
240 | ev.data.message.attachments.map(attachment => {
241 | messageReceiver
242 | .handleAttachment(attachment)
243 | .then(attachmentPointer => {
244 | Signal.AttachmentHelper.saveFile(attachmentPointer, './').then(
245 | fileName => {
246 | console.log('Wrote file to: ', fileName);
247 | }
248 | );
249 | });
250 | });
251 | if (ev.data.message.group) {
252 | console.log(ev.data.message.group);
253 | console.log(
254 | `Received message in group ${
255 | ev.data.message.group.id
256 | }: ${
257 | ev.data.message.body}`
258 | );
259 | } else {
260 | console.log('Received message: ', ev.data.message.body);
261 | }
262 | ev.confirm();
263 | });
264 | messageReceiver.addEventListener('configuration', ev => {
265 | console.log('Received configuration sync: ', ev.configuration);
266 | ev.confirm();
267 | });
268 | messageReceiver.addEventListener('group', ev => {
269 | console.log('Received group details: ', ev.groupDetails);
270 | ev.confirm();
271 | });
272 | messageReceiver.addEventListener('contact', ev => {
273 | console.log(
274 | `Received contact for ${
275 | ev.contactDetails.number
276 | } who has name ${
277 | ev.contactDetails.name}`
278 | );
279 | ev.confirm();
280 | });
281 | messageReceiver.addEventListener('verified', ev => {
282 | console.log('Received verification: ', ev.verified);
283 | ev.confirm();
284 | });
285 | messageReceiver.addEventListener('sent', ev => {
286 | console.log(
287 | `Message successfully sent from device ${
288 | ev.data.deviceId
289 | } to ${
290 | ev.data.destination
291 | } at timestamp ${
292 | ev.data.timestamp}`
293 | );
294 | ev.confirm();
295 | });
296 | messageReceiver.addEventListener('delivery', ev => {
297 | console.log(
298 | `Message successfully delivered to number ${
299 | ev.deliveryReceipt.source
300 | } and device ${
301 | ev.deliveryReceipt.sourceDevice
302 | } at timestamp ${
303 | ev.deliveryReceipt.timestamp}`
304 | );
305 | ev.confirm();
306 | });
307 | messageReceiver.addEventListener('read', ev => {
308 | console.log(
309 | `Message read on ${
310 | ev.read.reader
311 | } at timestamp ${
312 | ev.read.timestamp}`
313 | );
314 | ev.confirm();
315 | });
316 | });
317 | break;
318 | default:
319 | console.log('No valid command specified.');
320 | break;
321 | }
322 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@throneless/libsignal-service",
3 | "version": "1.2.1",
4 | "description": "A fork of the the libtextsecure components of Signal-Desktop, adapted for use by nodejs.",
5 | "main": "src/index.js",
6 | "files": [
7 | "/src",
8 | "/config",
9 | "/protos"
10 | ],
11 | "scripts": {
12 | "test": "mocha",
13 | "lint": "npx eslint ./src/*.js"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/throneless-tech/libsignal-service-javascript.git"
18 | },
19 | "keywords": [
20 | "signal",
21 | "crypto",
22 | "textsecure",
23 | "axolotl"
24 | ],
25 | "author": "Josh King ",
26 | "license": "GPL-3.0-or-later",
27 | "bugs": {
28 | "url": "https://github.com/throneless-tech/libsignal-service-javascript/issues"
29 | },
30 | "homepage": "https://github.com/throneless-tech/libsignal-service-javascript#readme",
31 | "husky": {
32 | "hooks": {
33 | "pre-commit": "lint-staged"
34 | }
35 | },
36 | "lint-staged": {
37 | "*.js": [
38 | "eslint --fix",
39 | "git add"
40 | ]
41 | },
42 | "dependencies": {
43 | "@sindresorhus/is": "^0.12.0",
44 | "@throneless/libsignal-protocol": "^0.1.7",
45 | "btoa": "^1.2.1",
46 | "bytebuffer": "^5.0.1",
47 | "config": "^1.30.0",
48 | "debug": "^4.1.1",
49 | "filereader": "^0.10.3",
50 | "https": "^1.0.0",
51 | "image-size": "^0.7.5",
52 | "libphonenumber-js": "^0.3.14",
53 | "lodash": "^4.17.19",
54 | "long": "^4.0.0",
55 | "mime-types": "^2.1.24",
56 | "node-fetch": "^2.6.1",
57 | "node-webcrypto-ossl": "^1.0.48",
58 | "p-queue": "^6.6.0",
59 | "protobufjs": "^6.8.6",
60 | "proxy-agent": "^3.1.0",
61 | "qs": "^6.9.0",
62 | "tiny-worker": "^2.3.0",
63 | "underscore": "^1.9.1",
64 | "uuid": "^3.3.3",
65 | "websocket": "^1.0.30",
66 | "xmlhttprequest": "*"
67 | },
68 | "devDependencies": {
69 | "chai": "^4.2.0",
70 | "co": "^4.6.0",
71 | "eslint": "^5.16.0",
72 | "eslint-config-airbnb-base": "^14.2.0",
73 | "eslint-config-prettier": "^3.6.0",
74 | "eslint-plugin-import": "^2.22.0",
75 | "eslint-plugin-mocha": "^8.0.0",
76 | "eslint-plugin-more": "^1.0.0",
77 | "eslint-plugin-prettier": "^3.1.1",
78 | "husky": "^1.3.1",
79 | "lint-staged": "^7.3.0",
80 | "mocha": "^8.1.0",
81 | "mock-socket": "^8.1.1",
82 | "node-blob": "0.0.2",
83 | "node-localstorage": "^1.3.1",
84 | "prettier": "^1.17.1",
85 | "prettier-eslint-cli": "^5.0.0"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/protos/DeviceMessages.proto:
--------------------------------------------------------------------------------
1 | package signalservice;
2 |
3 | message ProvisioningUuid {
4 | optional string uuid = 1;
5 | }
6 |
7 |
8 | message ProvisionEnvelope {
9 | optional bytes publicKey = 1;
10 | optional bytes body = 2; // Encrypted ProvisionMessage
11 | }
12 |
13 | message ProvisionMessage {
14 | optional bytes identityKeyPrivate = 2;
15 | optional string number = 3;
16 | optional string uuid = 8;
17 | optional string provisioningCode = 4;
18 | optional string userAgent = 5;
19 | optional bytes profileKey = 6;
20 | optional bool readReceipts = 7;
21 | optional uint32 ProvisioningVersion = 9;
22 | }
23 |
24 | enum ProvisioningVersion {
25 | option allow_alias = true;
26 |
27 | INITIAL = 0;
28 | TABLET_SUPPORT = 1;
29 | CURRENT = 1;
30 | }
--------------------------------------------------------------------------------
/protos/DeviceName.proto:
--------------------------------------------------------------------------------
1 | package signalservice;
2 |
3 | message DeviceName {
4 | optional bytes ephemeralPublic = 1;
5 | optional bytes syntheticIv = 2;
6 | optional bytes ciphertext = 3;
7 | }
8 |
--------------------------------------------------------------------------------
/protos/IncomingPushMessageSignal.proto:
--------------------------------------------------------------------------------
1 | package signalservice;
2 |
3 | option java_package = "org.whispersystems.textsecure.internal.push";
4 | option java_outer_classname = "TextSecureProtos";
5 |
6 | message Envelope {
7 | enum Type {
8 | UNKNOWN = 0;
9 | CIPHERTEXT = 1;
10 | KEY_EXCHANGE = 2;
11 | PREKEY_BUNDLE = 3;
12 | RECEIPT = 5;
13 | }
14 |
15 | optional Type type = 1;
16 | optional string source = 2;
17 | optional uint32 sourceDevice = 7;
18 | optional string relay = 3;
19 | optional uint64 timestamp = 5;
20 | optional bytes legacyMessage = 6; // Contains an encrypted DataMessage
21 | optional bytes content = 8; // Contains an encrypted Content
22 | }
23 |
24 | message Content {
25 | optional DataMessage dataMessage = 1;
26 | optional SyncMessage syncMessage = 2;
27 | }
28 |
29 | message DataMessage {
30 | enum Flags {
31 | END_SESSION = 1;
32 | EXPIRATION_TIMER_UPDATE = 2;
33 | }
34 |
35 | optional string body = 1;
36 | repeated AttachmentPointer attachments = 2;
37 | optional GroupContext group = 3;
38 | optional uint32 flags = 4;
39 | optional uint32 expireTimer = 5;
40 | }
41 |
42 | message SyncMessage {
43 | message Sent {
44 | optional string destination = 1;
45 | optional uint64 timestamp = 2;
46 | optional DataMessage message = 3;
47 | optional uint64 expirationStartTimestamp = 4;
48 | }
49 |
50 | message Contacts {
51 | optional AttachmentPointer blob = 1;
52 | }
53 |
54 | message Groups {
55 | optional AttachmentPointer blob = 1;
56 | }
57 |
58 | message Blocked {
59 | repeated string numbers = 1;
60 | }
61 |
62 | message Request {
63 | enum Type {
64 | UNKNOWN = 0;
65 | CONTACTS = 1;
66 | GROUPS = 2;
67 | BLOCKED = 3;
68 | }
69 | optional Type type = 1;
70 | }
71 | message Read {
72 | optional string sender = 1;
73 | optional uint64 timestamp = 2;
74 | }
75 |
76 | optional Sent sent = 1;
77 | optional Contacts contacts = 2;
78 | optional Groups groups = 3;
79 | optional Request request = 4;
80 | repeated Read read = 5;
81 | optional Blocked blocked = 6;
82 | }
83 |
84 | message AttachmentPointer {
85 | optional fixed64 id = 1;
86 | optional string contentType = 2;
87 | optional bytes key = 3;
88 | }
89 |
90 | message GroupContext {
91 | enum Type {
92 | UNKNOWN = 0;
93 | UPDATE = 1;
94 | DELIVER = 2;
95 | QUIT = 3;
96 | }
97 | optional bytes id = 1;
98 | optional Type type = 2;
99 | optional string name = 3;
100 | repeated string members = 4;
101 | optional AttachmentPointer avatar = 5;
102 | }
103 |
104 | message Avatar {
105 | optional string contentType = 1;
106 | optional uint32 length = 2;
107 | }
108 |
109 | message GroupDetails {
110 | optional bytes id = 1;
111 | optional string name = 2;
112 | repeated string members = 3;
113 | optional Avatar avatar = 4;
114 | optional bool active = 5 [default = true];
115 | }
116 |
117 | message ContactDetails {
118 | optional string number = 1;
119 | optional string name = 2;
120 | optional Avatar avatar = 3;
121 | optional string color = 4;
122 | }
123 |
--------------------------------------------------------------------------------
/protos/SignalService.proto:
--------------------------------------------------------------------------------
1 | // Source: https://github.com/signalapp/libsignal-service-java/blob/4684a49b2ed8f32be619e0d0eea423626b6cb2cb/protobuf/SignalService.proto
2 | package signalservice;
3 |
4 | option java_package = "org.whispersystems.signalservice.internal.push";
5 | option java_outer_classname = "SignalServiceProtos";
6 |
7 | message Envelope {
8 | enum Type {
9 | UNKNOWN = 0;
10 | CIPHERTEXT = 1;
11 | KEY_EXCHANGE = 2;
12 | PREKEY_BUNDLE = 3;
13 | RECEIPT = 5;
14 | UNIDENTIFIED_SENDER = 6;
15 | }
16 |
17 | optional Type type = 1;
18 | optional string source = 2;
19 | optional string sourceUuid = 11;
20 | optional uint32 sourceDevice = 7;
21 | optional string relay = 3;
22 | optional uint64 timestamp = 5;
23 | optional bytes legacyMessage = 6; // Contains an encrypted DataMessage
24 | optional bytes content = 8; // Contains an encrypted Content
25 | optional string serverGuid = 9;
26 | optional uint64 serverTimestamp = 10;
27 |
28 | }
29 |
30 | message Content {
31 | optional DataMessage dataMessage = 1;
32 | optional SyncMessage syncMessage = 2;
33 | optional CallMessage callMessage = 3;
34 | optional NullMessage nullMessage = 4;
35 | optional ReceiptMessage receiptMessage = 5;
36 | optional TypingMessage typingMessage = 6;
37 | }
38 |
39 | message CallMessage {
40 | message Offer {
41 | optional uint64 id = 1;
42 | optional string description = 2;
43 | }
44 |
45 | message Answer {
46 | optional uint64 id = 1;
47 | optional string description = 2;
48 | }
49 |
50 | message IceUpdate {
51 | optional uint64 id = 1;
52 | optional string sdpMid = 2;
53 | optional uint32 sdpMLineIndex = 3;
54 | optional string sdp = 4;
55 | }
56 |
57 | message Busy {
58 | optional uint64 id = 1;
59 | }
60 |
61 | message Hangup {
62 | optional uint64 id = 1;
63 | }
64 |
65 |
66 | optional Offer offer = 1;
67 | optional Answer answer = 2;
68 | repeated IceUpdate iceUpdate = 3;
69 | optional Hangup hangup = 4;
70 | optional Busy busy = 5;
71 | }
72 |
73 | message DataMessage {
74 | enum Flags {
75 | END_SESSION = 1;
76 | EXPIRATION_TIMER_UPDATE = 2;
77 | PROFILE_KEY_UPDATE = 4;
78 | }
79 |
80 | message Quote {
81 | message QuotedAttachment {
82 | optional string contentType = 1;
83 | optional string fileName = 2;
84 | optional AttachmentPointer thumbnail = 3;
85 | }
86 |
87 | optional uint64 id = 1;
88 | optional string author = 2;
89 | optional string authorUuid = 5;
90 | optional string text = 3;
91 | repeated QuotedAttachment attachments = 4;
92 | }
93 |
94 | message Contact {
95 | message Name {
96 | optional string givenName = 1;
97 | optional string familyName = 2;
98 | optional string prefix = 3;
99 | optional string suffix = 4;
100 | optional string middleName = 5;
101 | optional string displayName = 6;
102 | }
103 |
104 | message Phone {
105 | enum Type {
106 | HOME = 1;
107 | MOBILE = 2;
108 | WORK = 3;
109 | CUSTOM = 4;
110 | }
111 |
112 | optional string value = 1;
113 | optional Type type = 2;
114 | optional string label = 3;
115 | }
116 |
117 | message Email {
118 | enum Type {
119 | HOME = 1;
120 | MOBILE = 2;
121 | WORK = 3;
122 | CUSTOM = 4;
123 | }
124 |
125 | optional string value = 1;
126 | optional Type type = 2;
127 | optional string label = 3;
128 | }
129 |
130 | message PostalAddress {
131 | enum Type {
132 | HOME = 1;
133 | WORK = 2;
134 | CUSTOM = 3;
135 | }
136 |
137 | optional Type type = 1;
138 | optional string label = 2;
139 | optional string street = 3;
140 | optional string pobox = 4;
141 | optional string neighborhood = 5;
142 | optional string city = 6;
143 | optional string region = 7;
144 | optional string postcode = 8;
145 | optional string country = 9;
146 | }
147 |
148 | message Avatar {
149 | optional AttachmentPointer avatar = 1;
150 | optional bool isProfile = 2;
151 | }
152 |
153 | optional Name name = 1;
154 | repeated Phone number = 3;
155 | repeated Email email = 4;
156 | repeated PostalAddress address = 5;
157 | optional Avatar avatar = 6;
158 | optional string organization = 7;
159 | }
160 |
161 | message Preview {
162 | optional string url = 1;
163 | optional string title = 2;
164 | optional AttachmentPointer image = 3;
165 | }
166 |
167 | message Sticker {
168 | optional bytes packId = 1;
169 | optional bytes packKey = 2;
170 | optional uint32 stickerId = 3;
171 | optional AttachmentPointer data = 4;
172 | }
173 |
174 | message Reaction {
175 | optional string emoji = 1;
176 | optional bool remove = 2;
177 | optional string targetAuthorE164 = 3;
178 | optional string targetAuthorUuid = 4;
179 | optional uint64 targetTimestamp = 5;
180 | }
181 |
182 | enum ProtocolVersion {
183 | option allow_alias = true;
184 |
185 | INITIAL = 0;
186 | MESSAGE_TIMERS = 1;
187 | VIEW_ONCE = 2;
188 | VIEW_ONCE_VIDEO = 3;
189 | REACTIONS = 4;
190 | CDN_SELECTOR_ATTACHMENTS = 5;
191 | CURRENT = 5;
192 | }
193 |
194 | optional string body = 1;
195 | repeated AttachmentPointer attachments = 2;
196 | optional GroupContext group = 3;
197 | optional GroupContextV2 groupV2 = 15;
198 | optional uint32 flags = 4;
199 | optional uint32 expireTimer = 5;
200 | optional bytes profileKey = 6;
201 | optional uint64 timestamp = 7;
202 | optional Quote quote = 8;
203 | repeated Contact contact = 9;
204 | repeated Preview preview = 10;
205 | optional Sticker sticker = 11;
206 | optional uint32 requiredProtocolVersion = 12;
207 | optional bool isViewOnce = 14;
208 | optional Reaction reaction = 16;
209 | }
210 |
211 | message NullMessage {
212 | optional bytes padding = 1;
213 | }
214 |
215 | message ReceiptMessage {
216 | enum Type {
217 | DELIVERY = 0;
218 | READ = 1;
219 | }
220 |
221 | optional Type type = 1;
222 | repeated uint64 timestamp = 2;
223 | }
224 |
225 | message TypingMessage {
226 | enum Action {
227 | STARTED = 0;
228 | STOPPED = 1;
229 | }
230 |
231 | optional uint64 timestamp = 1;
232 | optional Action action = 2;
233 | optional bytes groupId = 3;
234 | }
235 |
236 | message Verified {
237 | enum State {
238 | DEFAULT = 0;
239 | VERIFIED = 1;
240 | UNVERIFIED = 2;
241 | }
242 |
243 | optional string destination = 1;
244 | optional string destinationUuid = 5;
245 | optional bytes identityKey = 2;
246 | optional State state = 3;
247 | optional bytes nullMessage = 4;
248 | }
249 |
250 | message SyncMessage {
251 | message Sent {
252 | message UnidentifiedDeliveryStatus {
253 | optional string destination = 1;
254 | optional string destinationUuid = 3;
255 | optional bool unidentified = 2;
256 | }
257 |
258 | optional string destination = 1;
259 | optional string destinationUuid = 7;
260 | optional uint64 timestamp = 2;
261 | optional DataMessage message = 3;
262 | optional uint64 expirationStartTimestamp = 4;
263 | repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5;
264 | optional bool isRecipientUpdate = 6 [default = false];
265 | }
266 |
267 | message Contacts {
268 | optional AttachmentPointer blob = 1;
269 | optional bool complete = 2 [default = false];
270 | }
271 |
272 | message Groups {
273 | optional AttachmentPointer blob = 1;
274 | }
275 |
276 | message Blocked {
277 | repeated string numbers = 1;
278 | repeated string uuids = 3;
279 | repeated bytes groupIds = 2;
280 | }
281 |
282 | message Request {
283 | enum Type {
284 | UNKNOWN = 0;
285 | CONTACTS = 1;
286 | GROUPS = 2;
287 | BLOCKED = 3;
288 | CONFIGURATION = 4;
289 | }
290 |
291 | optional Type type = 1;
292 | }
293 |
294 | message Read {
295 | optional string sender = 1;
296 | optional string senderUuid = 3;
297 | optional uint64 timestamp = 2;
298 | }
299 |
300 | message Configuration {
301 | optional bool readReceipts = 1;
302 | optional bool unidentifiedDeliveryIndicators = 2;
303 | optional bool typingIndicators = 3;
304 | optional bool linkPreviews = 4;
305 | }
306 |
307 | message StickerPackOperation {
308 | enum Type {
309 | INSTALL = 0;
310 | REMOVE = 1;
311 | }
312 | optional bytes packId = 1;
313 | optional bytes packKey = 2;
314 | optional Type type = 3;
315 | }
316 |
317 | message ViewOnceOpen {
318 | optional string sender = 1;
319 | optional string senderUuid = 3;
320 | optional uint64 timestamp = 2;
321 | }
322 |
323 | optional Sent sent = 1;
324 | optional Contacts contacts = 2;
325 | optional Groups groups = 3;
326 | optional Request request = 4;
327 | repeated Read read = 5;
328 | optional Blocked blocked = 6;
329 | optional Verified verified = 7;
330 | optional Configuration configuration = 9;
331 | optional bytes padding = 8;
332 | repeated StickerPackOperation stickerPackOperation = 10;
333 | optional ViewOnceOpen viewOnceOpen = 11;
334 | }
335 |
336 | message AttachmentPointer {
337 | enum Flags {
338 | VOICE_MESSAGE = 1;
339 | }
340 |
341 | oneof attachment_identifier {
342 | fixed64 cdnId = 1;
343 | string cdnKey = 15;
344 | }
345 | optional string contentType = 2;
346 | optional bytes key = 3;
347 | optional uint32 size = 4;
348 | optional bytes thumbnail = 5;
349 | optional bytes digest = 6;
350 | optional string fileName = 7;
351 | optional uint32 flags = 8;
352 | optional uint32 width = 9;
353 | optional uint32 height = 10;
354 | optional string caption = 11;
355 | optional string blurHash = 12;
356 | optional uint64 uploadTimestamp = 13;
357 | optional uint32 cdnNumber = 14;
358 | // Next ID: 16
359 | }
360 |
361 | message GroupContext {
362 | enum Type {
363 | UNKNOWN = 0;
364 | UPDATE = 1;
365 | DELIVER = 2;
366 | QUIT = 3;
367 | REQUEST_INFO = 4;
368 | }
369 |
370 | message Member {
371 | optional string uuid = 1;
372 | optional string e164 = 2;
373 | }
374 |
375 | optional bytes id = 1;
376 | optional Type type = 2;
377 | optional string name = 3;
378 | repeated string membersE164 = 4;
379 | repeated Member members = 6;
380 | optional AttachmentPointer avatar = 5;
381 | }
382 |
383 | message GroupContextV2 {
384 | optional bytes masterKey = 1;
385 | optional uint32 revision = 2;
386 | optional bytes groupChange = 3;
387 | }
388 |
389 | message ContactDetails {
390 | message Avatar {
391 | optional string contentType = 1;
392 | optional uint32 length = 2;
393 | }
394 |
395 | optional string number = 1;
396 | optional string uuid = 9;
397 | optional string name = 2;
398 | optional Avatar avatar = 3;
399 | optional string color = 4;
400 | optional Verified verified = 5;
401 | optional bytes profileKey = 6;
402 | optional bool blocked = 7;
403 | optional uint32 expireTimer = 8;
404 | optional uint32 inboxPosition = 10;
405 | }
406 |
407 | message GroupDetails {
408 | message Avatar {
409 | optional string contentType = 1;
410 | optional uint32 length = 2;
411 | }
412 |
413 | message Member {
414 | optional string uuid = 1;
415 | optional string e164 = 2;
416 | }
417 |
418 | optional bytes id = 1;
419 | optional string name = 2;
420 | repeated string membersE164 = 3;
421 | repeated Member members = 9;
422 | optional Avatar avatar = 4;
423 | optional bool active = 5 [default = true];
424 | optional uint32 expireTimer = 6;
425 | optional string color = 7;
426 | optional bool blocked = 8;
427 | optional uint32 inboxPosition = 10;
428 | }
429 |
--------------------------------------------------------------------------------
/protos/Stickers.proto:
--------------------------------------------------------------------------------
1 | package signalservice;
2 |
3 | message StickerPack {
4 | message Sticker {
5 | optional uint32 id = 1;
6 | optional string emoji = 2;
7 | }
8 |
9 | optional string title = 1;
10 | optional string author = 2;
11 | optional Sticker cover = 3;
12 | repeated Sticker stickers = 4;
13 | }
14 |
--------------------------------------------------------------------------------
/protos/SubProtocol.proto:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2014 Open WhisperSystems
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published by
6 | * the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 | package signalservice;
18 |
19 | option java_package = "org.whispersystems.websocket.messages.protobuf";
20 |
21 | message WebSocketRequestMessage {
22 | optional string verb = 1;
23 | optional string path = 2;
24 | optional bytes body = 3;
25 | repeated string headers = 5;
26 | optional uint64 id = 4;
27 | }
28 |
29 | message WebSocketResponseMessage {
30 | optional uint64 id = 1;
31 | optional uint32 status = 2;
32 | optional string message = 3;
33 | repeated string headers = 5;
34 | optional bytes body = 4;
35 | }
36 |
37 | message WebSocketMessage {
38 | enum Type {
39 | UNKNOWN = 0;
40 | REQUEST = 1;
41 | RESPONSE = 2;
42 | }
43 |
44 | optional Type type = 1;
45 | optional WebSocketRequestMessage request = 2;
46 | optional WebSocketResponseMessage response = 3;
47 | }
48 |
--------------------------------------------------------------------------------
/protos/UnidentifiedDelivery.proto:
--------------------------------------------------------------------------------
1 | package signalservice;
2 |
3 | option java_package = "org.whispersystems.libsignal.protocol";
4 | option java_outer_classname = "WhisperProtos";
5 |
6 | message ServerCertificate {
7 | message Certificate {
8 | optional uint32 id = 1;
9 | optional bytes key = 2;
10 | }
11 |
12 | optional bytes certificate = 1;
13 | optional bytes signature = 2;
14 | }
15 |
16 | message SenderCertificate {
17 | message Certificate {
18 | optional string sender = 1;
19 | optional string senderUuid = 6;
20 | optional uint32 senderDevice = 2;
21 | optional fixed64 expires = 3;
22 | optional bytes identityKey = 4;
23 | optional ServerCertificate signer = 5;
24 | }
25 |
26 | optional bytes certificate = 1;
27 | optional bytes signature = 2;
28 | }
29 |
30 | message UnidentifiedSenderMessage {
31 |
32 | message Message {
33 | enum Type {
34 | PREKEY_MESSAGE = 1;
35 | MESSAGE = 2;
36 | }
37 |
38 | optional Type type = 1;
39 | optional SenderCertificate senderCertificate = 2;
40 | optional bytes content = 3;
41 | }
42 |
43 | optional bytes ephemeralPublic = 1;
44 | optional bytes encryptedStatic = 2;
45 | optional bytes encryptedMessage = 3;
46 | }
47 |
--------------------------------------------------------------------------------
/protos/WhisperTextProtocol.proto:
--------------------------------------------------------------------------------
1 | package signalservice;
2 |
3 | option java_package = "org.whispersystems.libsignal.protocol";
4 | option java_outer_classname = "WhisperProtos";
5 |
6 | message WhisperMessage {
7 | optional bytes ephemeralKey = 1;
8 | optional uint32 counter = 2;
9 | optional uint32 previousCounter = 3;
10 | optional bytes ciphertext = 4; // PushMessageContent
11 | }
12 |
13 | message PreKeyWhisperMessage {
14 | optional uint32 registrationId = 5;
15 | optional uint32 preKeyId = 1;
16 | optional uint32 signedPreKeyId = 6;
17 | optional bytes baseKey = 2;
18 | optional bytes identityKey = 3;
19 | optional bytes message = 4; // WhisperMessage
20 | }
21 |
22 | message KeyExchangeMessage {
23 | optional uint32 id = 1;
24 | optional bytes baseKey = 2;
25 | optional bytes ephemeralKey = 3;
26 | optional bytes identityKey = 4;
27 | optional bytes baseKeySignature = 5;
28 | }
29 |
--------------------------------------------------------------------------------
/src/AccountManager.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const btoa = require('btoa');
8 | const { default: PQueue } = require('p-queue');
9 | const debug = require('debug')('libsignal-service:AccountManager');
10 | const libsignal = require('@throneless/libsignal-protocol');
11 | const EventTarget = require('./EventTarget.js');
12 | const Event = require('./Event.js');
13 | const protobuf = require('./protobufs.js');
14 |
15 | const ProvisionEnvelope = protobuf.lookupType(
16 | 'signalservice.ProvisionEnvelope'
17 | );
18 | const DeviceName = protobuf.lookupType('signalservice.DeviceName');
19 | const ProvisioningUuid = protobuf.lookupType('signalservice.ProvisioningUuid');
20 | const crypto = require('./crypto.js');
21 | const WebSocketResource = require('./WebSocketResource.js');
22 | const libphonenumber = require('./libphonenumber-util.js');
23 | const createTaskWithTimeout = require('./taskWithTimeout.js');
24 | const helpers = require('./helpers.js');
25 |
26 | const ARCHIVE_AGE = 7 * 24 * 60 * 60 * 1000;
27 |
28 | const VerifiedStatus = {
29 | DEFAULT: 0,
30 | VERIFIED: 1,
31 | UNVERIFIED: 2,
32 | };
33 |
34 | class AccountManager extends EventTarget {
35 | constructor(username, password, store) {
36 | super(username, password, store);
37 | this.server = this.constructor.WebAPI.connect({ username, password });
38 | this.store = store;
39 | this.username = username;
40 | this.password = password;
41 | this.pending = Promise.resolve();
42 | }
43 |
44 | requestVoiceVerification() {
45 | return this.server.requestVerificationVoice(this.username);
46 | }
47 |
48 | requestSMSVerification() {
49 | return this.server.requestVerificationSMS(this.username);
50 | }
51 |
52 | async encryptDeviceName(name, providedIdentityKey) {
53 | if (!name) {
54 | return null;
55 | }
56 | const identityKey = providedIdentityKey || (await this.store.getIdentityKeyPair());
57 | if (!identityKey) {
58 | throw new Error('Identity key was not provided and is not in database!');
59 | }
60 | const encrypted = await crypto.encryptDeviceName(name, identityKey.pubKey);
61 |
62 | const proto = DeviceName.create();
63 | proto.ephemeralPublic = new Uint8Array(encrypted.ephemeralPublic);
64 | proto.syntheticIv = encrypted.syntheticIv;
65 | proto.ciphertext = new Uint8Array(encrypted.ciphertext);
66 |
67 | const arrayBuffer = DeviceName.encode(proto).finish();
68 | return crypto.arrayBufferToBase64(arrayBuffer);
69 | }
70 |
71 | async decryptDeviceName(base64) {
72 | const identityKey = await this.store.getIdentityKeyPair();
73 |
74 | const arrayBuffer = crypto.base64ToArrayBuffer(base64);
75 | const proto = DeviceName.decode(new Uint8Array(arrayBuffer));
76 | // const encrypted = {
77 | // ephemeralPublic: proto.ephemeralPublic.toArrayBuffer(),
78 | // syntheticIv: proto.syntheticIv.toArrayBuffer(),
79 | // ciphertext: proto.ciphertext.toArrayBuffer()
80 | // };
81 |
82 | const name = await crypto.decryptDeviceName(proto, identityKey.privKey);
83 |
84 | return name;
85 | }
86 |
87 | async maybeUpdateDeviceName() {
88 | const isNameEncrypted = this.store.getDeviceNameEncrypted();
89 | if (isNameEncrypted) {
90 | return;
91 | }
92 | const deviceName = await this.store.getDeviceName();
93 | const base64 = await this.encryptDeviceName(deviceName);
94 |
95 | await this.server.updateDeviceName(base64);
96 | }
97 |
98 | async deviceNameIsEncrypted() {
99 | await this.store.setDeviceNameEncrypted();
100 | }
101 |
102 | async maybeDeleteSignalingKey() {
103 | const key = await this.store.getSignalingKey();
104 | if (key) {
105 | await this.server.removeSignalingKey();
106 | }
107 | }
108 |
109 | registerSingleDevice(verificationCode) {
110 | const registerKeys = this.server.registerKeys.bind(this.server);
111 | const createAccount = this.createAccount.bind(this);
112 | const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this);
113 | const generateKeys = this.generateKeys.bind(this, 100);
114 | const confirmKeys = this.confirmKeys.bind(this);
115 | const registrationDone = this.registrationDone.bind(this);
116 | return this.queueTask(() =>
117 | libsignal.KeyHelper.generateIdentityKeyPair().then(
118 | async identityKeyPair => {
119 | const profileKey = crypto.getRandomBytes(32);
120 | const accessKey = await crypto.deriveAccessKey(profileKey);
121 |
122 | return createAccount(
123 | this.username,
124 | verificationCode,
125 | identityKeyPair,
126 | profileKey,
127 | null,
128 | null,
129 | null,
130 | { accessKey }
131 | )
132 | .then(clearSessionsAndPreKeys)
133 | .then(generateKeys)
134 | .then(keys => registerKeys(keys).then(() => confirmKeys(keys)))
135 | .then(() => registrationDone({ number: this.username }));
136 | }
137 | )
138 | );
139 | }
140 |
141 | registerSecondDevice(setProvisioningUrl, confirmNumber, progressCallback) {
142 | const createAccount = this.createAccount.bind(this);
143 | const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this);
144 | const generateKeys = this.generateKeys.bind(this, 100, progressCallback);
145 | const confirmKeys = this.confirmKeys.bind(this);
146 | const registrationDone = this.registrationDone.bind(this);
147 | const registerKeys = this.server.registerKeys.bind(this.server);
148 | const getSocket = this.server.getProvisioningSocket.bind(this.server);
149 | const queueTask = this.queueTask.bind(this);
150 | const provisioningCipher = new libsignal.ProvisioningCipher();
151 | let gotProvisionEnvelope = false;
152 | return provisioningCipher.getPublicKey().then(
153 | pubKey =>
154 | new Promise((resolve, reject) => {
155 | const socket = getSocket();
156 | socket.onclose = event => {
157 | debug('provisioning socket closed. Code:', event.code);
158 | if (!gotProvisionEnvelope) {
159 | reject(new Error('websocket closed'));
160 | }
161 | };
162 | socket.onopen = () => {
163 | debug('provisioning socket open');
164 | };
165 | const wsr = new WebSocketResource(socket, {
166 | keepalive: { path: '/v1/keepalive/provisioning' },
167 | handleRequest(request) {
168 | if (request.path === '/v1/address' && request.verb === 'PUT') {
169 | const proto = ProvisioningUuid.decode(request.body);
170 | setProvisioningUrl(
171 | [
172 | 'tsdevice:/?uuid=',
173 | proto.uuid,
174 | '&pub_key=',
175 | encodeURIComponent(btoa(helpers.getString(pubKey))),
176 | ].join('')
177 | );
178 | request.respond(200, 'OK');
179 | } else if (
180 | request.path === '/v1/message'
181 | && request.verb === 'PUT'
182 | ) {
183 | const envelope = ProvisionEnvelope.decode(
184 | request.body,
185 | 'binary'
186 | );
187 | request.respond(200, 'OK');
188 | gotProvisionEnvelope = true;
189 | wsr.close();
190 | resolve(
191 | provisioningCipher.decrypt(envelope).then(provisionMessage =>
192 | queueTask(() =>
193 | confirmNumber(provisionMessage.number).then(
194 | deviceName => {
195 | if (
196 | typeof deviceName !== 'string'
197 | || deviceName.length === 0
198 | ) {
199 | throw new Error('Invalid device name');
200 | }
201 | return createAccount(
202 | provisionMessage.number,
203 | provisionMessage.provisioningCode,
204 | provisionMessage.identityKeyPair,
205 | provisionMessage.profileKey,
206 | deviceName,
207 | provisionMessage.userAgent,
208 | provisionMessage.readReceipts,
209 | { uuid: provisionMessage.uuid }
210 | )
211 | .then(clearSessionsAndPreKeys)
212 | .then(generateKeys)
213 | .then(keys =>
214 | registerKeys(keys).then(() => confirmKeys(keys))
215 | )
216 | .then(() => registrationDone(provisionMessage));
217 | }
218 | )
219 | )
220 | )
221 | );
222 | } else {
223 | debug('Unknown websocket message', request.path);
224 | }
225 | },
226 | });
227 | })
228 | );
229 | }
230 |
231 | refreshPreKeys() {
232 | const generateKeys = this.generateKeys.bind(this, 100);
233 | const registerKeys = this.server.registerKeys.bind(this.server);
234 |
235 | return this.queueTask(() =>
236 | this.server.getMyKeys().then(preKeyCount => {
237 | debug(`prekey count ${preKeyCount}`);
238 | if (preKeyCount < 10) {
239 | return generateKeys().then(registerKeys);
240 | }
241 | return null;
242 | })
243 | );
244 | }
245 |
246 | rotateSignedPreKey() {
247 | return this.queueTask(() => {
248 | const signedKeyId = this.store.getSignedKeyId();
249 | if (typeof signedKeyId !== 'number') {
250 | throw new Error('Invalid signedKeyId');
251 | }
252 |
253 | const { server, cleanSignedPreKeys } = this;
254 |
255 | return this.store
256 | .getIdentityKeyPair()
257 | .then(
258 | identityKey =>
259 | libsignal.KeyHelper.generateSignedPreKey(identityKey, signedKeyId),
260 | () => {
261 | // We swallow any error here, because we don't want to get into
262 | // a loop of repeated retries.
263 | debug('Failed to get identity key. Canceling key rotation.');
264 | }
265 | )
266 | .then(res => {
267 | if (!res) {
268 | return null;
269 | }
270 | debug('Saving new signed prekey', res.keyId);
271 | return Promise.all([
272 | this.store.setSignedKeyId(signedKeyId + 1),
273 | this.store.storeSignedPreKey(res.keyId, res.keyPair),
274 | server.setSignedPreKey({
275 | keyId: res.keyId,
276 | publicKey: res.keyPair.pubKey,
277 | signature: res.signature,
278 | }),
279 | ])
280 | .then(() => {
281 | const confirmed = true;
282 | debug('Confirming new signed prekey', res.keyId);
283 | return Promise.all([
284 | this.store.removeSignedKeyRotationRejected(),
285 | this.store.storeSignedPreKey(res.keyId, res.keyPair, confirmed),
286 | ]);
287 | })
288 | .then(() => cleanSignedPreKeys());
289 | })
290 | .catch(e => {
291 | debug('rotateSignedPrekey error:', e && e.stack ? e.stack : e);
292 |
293 | if (
294 | e instanceof Error
295 | && e.name === 'HTTPError'
296 | && e.code >= 400
297 | && e.code <= 599
298 | ) {
299 | const rejections = 1 + this.store.getSignedKeyRotationRejected();
300 | this.store.setSignedKeyRotationRejected(rejections);
301 | debug('Signed key rotation rejected count:', rejections);
302 | } else {
303 | throw e;
304 | }
305 | });
306 | });
307 | }
308 |
309 | queueTask(task) {
310 | this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 });
311 | const taskWithTimeout = createTaskWithTimeout(task);
312 |
313 | return this.pendingQueue.add(taskWithTimeout);
314 | }
315 |
316 | cleanSignedPreKeys() {
317 | const MINIMUM_KEYS = 3;
318 | return this.store.loadSignedPreKeys().then(allKeys => {
319 | allKeys.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
320 | allKeys.reverse(); // we want the most recent first
321 | const confirmed = allKeys.filter(key => key.confirmed);
322 | const unconfirmed = allKeys.filter(key => !key.confirmed);
323 |
324 | const recent = allKeys[0] ? allKeys[0].keyId : 'none';
325 | const recentConfirmed = confirmed[0] ? confirmed[0].keyId : 'none';
326 | debug(`Most recent signed key: ${recent}`);
327 | debug(`Most recent confirmed signed key: ${recentConfirmed}`);
328 | debug(
329 | 'Total signed key count:',
330 | allKeys.length,
331 | '-',
332 | confirmed.length,
333 | 'confirmed'
334 | );
335 |
336 | let confirmedCount = confirmed.length;
337 |
338 | // Keep MINIMUM_KEYS confirmed keys, then drop if older than a week
339 | confirmed.forEach((key, index) => {
340 | if (index < MINIMUM_KEYS) {
341 | return;
342 | }
343 | const createdAt = key.created_at || 0;
344 | const age = Date.now() - createdAt;
345 |
346 | if (age > ARCHIVE_AGE) {
347 | debug(
348 | 'Removing confirmed signed prekey:',
349 | key.keyId,
350 | 'with timestamp:',
351 | new Date(createdAt).toJSON()
352 | );
353 | this.store.removeSignedPreKey(key.keyId);
354 | confirmedCount -= 1;
355 | }
356 | });
357 |
358 | const stillNeeded = MINIMUM_KEYS - confirmedCount;
359 |
360 | // If we still don't have enough total keys, we keep as many unconfirmed
361 | // keys as necessary. If not necessary, and over a week old, we drop.
362 | unconfirmed.forEach((key, index) => {
363 | if (index < stillNeeded) {
364 | return;
365 | }
366 |
367 | const createdAt = key.created_at || 0;
368 | const age = Date.now() - createdAt;
369 | if (age > ARCHIVE_AGE) {
370 | debug(
371 | 'Removing unconfirmed signed prekey:',
372 | key.keyId,
373 | 'with timestamp:',
374 | new Date(createdAt).toJSON()
375 | );
376 | this.store.removeSignedPreKey(key.keyId);
377 | }
378 | });
379 | });
380 | }
381 |
382 | async createAccount(
383 | number,
384 | verificationCode,
385 | identityKeyPair,
386 | profileKey,
387 | deviceName,
388 | userAgent,
389 | readReceipts,
390 | options = {}
391 | ) {
392 | const { accessKey } = options;
393 | const registrationId = libsignal.KeyHelper.generateRegistrationId();
394 |
395 | const previousNumber = this.store.getNumber();
396 | const previousUuid = this.store.getUuid();
397 |
398 | const encryptedDeviceName = await this.encryptDeviceName(
399 | deviceName,
400 | identityKeyPair
401 | );
402 | await this.deviceNameIsEncrypted();
403 |
404 | debug(
405 | `createAccount: Number is ${number}, password has length: ${
406 | this.password ? this.password.length : 'none'
407 | }`
408 | );
409 |
410 | const response = await this.server.confirmCode(
411 | number,
412 | verificationCode,
413 | this.password,
414 | registrationId,
415 | encryptedDeviceName,
416 | { accessKey }
417 | );
418 |
419 | const numberChanged = previousNumber && previousNumber !== number;
420 | const uuidChanged = previousUuid && response.uuid && previousUuid !== response.uuid;
421 |
422 | if (numberChanged || uuidChanged) {
423 | if (numberChanged) {
424 | debug(
425 | 'New number is different from old number; deleting all previous data'
426 | );
427 | }
428 | if (uuidChanged) {
429 | debug(
430 | 'New uuid is different from old uuid; deleting all previous data'
431 | );
432 | }
433 |
434 | try {
435 | await this.store.removeAllData();
436 | debug('Successfully deleted previous data');
437 | } catch (error) {
438 | debug(
439 | 'Something went wrong deleting data from previous number',
440 | error && error.stack ? error.stack : error
441 | );
442 | }
443 | }
444 |
445 | await Promise.all([this.store.removeAllConfiguration()]);
446 |
447 | // `setNumberAndDeviceId` and `setUuidAndDeviceId` need to be called
448 | // before `saveIdentifyWithAttributes` since `saveIdentityWithAttributes`
449 | // indirectly calls `ConversationController.getConverationId()` which
450 | // initializes the conversation for the given number (our number) which
451 | // calls out to the user storage API to get the stored UUID and number
452 | // information.
453 |
454 | await this.store.setNumberAndDeviceId(
455 | number,
456 | response.deviceId || 1,
457 | deviceName
458 | );
459 |
460 | const setUuid = response.uuid;
461 | if (setUuid) {
462 | await this.store.setUuidAndDeviceId(setUuid, response.deviceId || 1);
463 | }
464 |
465 | // update our own identity key, which may have changed
466 | // if we're relinking after a reinstall on the master device
467 | await this.store.saveIdentityWithAttributes(number, {
468 | publicKey: identityKeyPair.pubKey,
469 | firstUse: true,
470 | timestamp: Date.now(),
471 | verified: VerifiedStatus.VERIFIED,
472 | nonblockingApproval: true,
473 | });
474 |
475 | await this.store.setIdentityKeyPair(identityKeyPair);
476 | await this.store.setPassword(this.password);
477 | await this.store.setLocalRegistrationId(registrationId);
478 | if (profileKey) {
479 | await this.store.setProfileKey(profileKey);
480 | }
481 | if (userAgent) {
482 | await this.store.setUserAgent(userAgent);
483 | }
484 | await this.store.setReadReceiptSetting(Boolean(readReceipts));
485 |
486 | const regionCode = libphonenumber.util.getRegionCodeForNumber(number);
487 | await this.store.setRegionCode(regionCode);
488 | }
489 |
490 | async clearSessionsAndPreKeys() {
491 | debug('clearing all sessions, prekeys, and signed prekeys');
492 | await Promise.all([
493 | this.store.clearPreKeyStore(),
494 | this.store.clearSignedPreKeysStore(),
495 | this.store.clearSessionStore(),
496 | ]);
497 | }
498 |
499 | // Takes the same object returned by generateKeys
500 | async confirmKeys(keys) {
501 | const key = keys.signedPreKey;
502 | const confirmed = true;
503 |
504 | debug('confirmKeys: confirming key', key.keyId);
505 | await this.store.storeSignedPreKey(key.keyId, key.keyPair, confirmed);
506 | }
507 |
508 | async generateKeys(count, providedProgressCallback) {
509 | const progressCallback = typeof providedProgressCallback === 'function'
510 | ? providedProgressCallback
511 | : null;
512 | const startId = await this.store.getMaxPreKeyId();
513 | const signedKeyId = await this.store.getSignedKeyId();
514 |
515 | if (typeof startId !== 'number') {
516 | throw new Error('Invalid maxPreKeyId');
517 | }
518 | if (typeof signedKeyId !== 'number') {
519 | throw new Error('Invalid signedKeyId');
520 | }
521 |
522 | return this.store.getIdentityKeyPair().then(identityKey => {
523 | const result = { preKeys: [], identityKey: identityKey.pubKey };
524 | const promises = [];
525 |
526 | for (let keyId = startId; keyId < startId + count; keyId += 1) {
527 | promises.push(
528 | libsignal.KeyHelper.generatePreKey(keyId).then(res => {
529 | this.store.storePreKey(res.keyId, res.keyPair);
530 | result.preKeys.push({
531 | keyId: res.keyId,
532 | publicKey: res.keyPair.pubKey,
533 | });
534 | if (progressCallback) {
535 | progressCallback();
536 | }
537 | })
538 | );
539 | }
540 |
541 | promises.push(
542 | libsignal.KeyHelper.generateSignedPreKey(identityKey, signedKeyId).then(
543 | res => {
544 | this.store.storeSignedPreKey(res.keyId, res.keyPair);
545 | result.signedPreKey = {
546 | keyId: res.keyId,
547 | publicKey: res.keyPair.pubKey,
548 | signature: res.signature,
549 | // server.registerKeys doesn't use keyPair, confirmKeys does
550 | keyPair: res.keyPair,
551 | };
552 | }
553 | )
554 | );
555 |
556 | this.store.setMaxPreKeyId(startId + count);
557 | this.store.setSignedKeyId(signedKeyId + 1);
558 | return Promise.all(promises).then(() =>
559 | // This is primarily for the signed prekey summary it logs out
560 | this.cleanSignedPreKeys().then(() => result)
561 | );
562 | });
563 | }
564 |
565 | // eslint-disable-next-line no-unused-vars
566 | async registrationDone({ uuid, number }) {
567 | debug('registration done');
568 |
569 | // Ensure that we always have a conversation for ourself
570 | // await ConversationController.getOrCreateAndWait(number, "private");
571 | // const conversation = await ConversationController.getOrCreateAndWait(
572 | // number || uuid,
573 | // 'private'
574 | // );
575 | // conversation.updateE164(number);
576 | // conversation.updateUuid(uuid);
577 |
578 | debug('dispatching registration event');
579 |
580 | this.dispatchEvent(new Event('registration'));
581 | }
582 | }
583 |
584 | exports = module.exports = WebAPI => {
585 | AccountManager.WebAPI = WebAPI;
586 | return AccountManager;
587 | };
588 |
--------------------------------------------------------------------------------
/src/AttachmentHelper.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const debug = require('debug')('libsignal-service:Attachment');
8 | const fs = require('fs').promises;
9 | const path = require('path');
10 | const mime = require('mime-types');
11 | const sizeOf = require('image-size');
12 | const getGuid = require('uuid/v4');
13 | const helpers = require('./helpers.js');
14 |
15 | function isImage(mimeType) {
16 | return mimeType.startsWith('image/');
17 | }
18 |
19 | // eslint-disable-next-line no-unused-vars
20 | function isVideo(mimeType) {
21 | return mimeType.startsWith('video/');
22 | }
23 |
24 | // eslint-disable-next-line no-unused-vars
25 | function isAudio(mimeType) {
26 | return mimeType.startsWith('audio/');
27 | }
28 |
29 | function contentTypeToFileName(mimeType) {
30 | switch (mimeType) {
31 | case 'image/jpeg':
32 | return '.jpg';
33 | case 'image/gif':
34 | return '.gif';
35 | case 'image/png':
36 | return '.png';
37 | case 'video/mp4':
38 | return '.mp4';
39 | case 'text/plain':
40 | return '.txt';
41 | default:
42 | return '.bin';
43 | }
44 | }
45 |
46 | // eslint-disable-next-line no-unused-vars
47 | function loadFile(file, caption = '') {
48 | const source = path.normalize(file);
49 | const fileName = path.basename(source);
50 | const contentType = mime.lookup(source);
51 | debug(`Reading file ${ source } with MIME type ${ contentType}`);
52 | return fs.readFile(source).then(buffer => {
53 | const data = helpers.convertToArrayBuffer(buffer);
54 | let width; let height;
55 | if (helpers.isString(contentType) && isImage(contentType)) {
56 | const dimensions = sizeOf(source);
57 | width = dimensions.width;
58 | height = dimensions.height;
59 | }
60 | return {
61 | fileName,
62 | contentType,
63 | width,
64 | height,
65 | data,
66 | size: data.byteLength,
67 | };
68 | });
69 | }
70 |
71 | async function saveFile(file, dest) {
72 | if (!file || !file.data || !file.contentType) {
73 | throw new Error('Invalid file.');
74 | }
75 |
76 | let {fileName} = file;
77 | if (!fileName) {
78 | const extension = contentTypeToFileName(file.contentType);
79 | const guid = getGuid();
80 | fileName = `${ guid }${extension}`;
81 | }
82 | const target = path.join(dest, fileName);
83 | return fs.writeFile(target, Buffer.from(file.data)).then(() => target);
84 | }
85 |
86 | exports = module.exports = {
87 | loadFile,
88 | saveFile,
89 | };
90 |
--------------------------------------------------------------------------------
/src/Event.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | *
4 | */
5 |
6 |
7 |
8 | class Event {
9 | constructor(type) {
10 | this.type = type;
11 | }
12 | }
13 |
14 | exports = module.exports = Event;
15 |
--------------------------------------------------------------------------------
/src/EventTarget.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | // eslint-disable-next-line func-names
8 |
9 | const Event = require('./Event.js');
10 |
11 | class EventTarget {
12 | dispatchEvent(ev) {
13 | if (!(ev instanceof Event)) {
14 | throw new Error('Expects an event');
15 | }
16 | if (this.listeners === null || typeof this.listeners !== 'object') {
17 | this.listeners = {};
18 | }
19 | const listeners = this.listeners[ev.type];
20 | const results = [];
21 | if (typeof listeners === 'object') {
22 | for (let i = 0, max = listeners.length; i < max; i += 1) {
23 | const listener = listeners[i];
24 | if (typeof listener === 'function') {
25 | results.push(listener.call(null, ev));
26 | }
27 | }
28 | }
29 | return results;
30 | }
31 |
32 | addEventListener(eventName, callback) {
33 | if (typeof eventName !== 'string') {
34 | throw new Error('First argument expects a string');
35 | }
36 | if (typeof callback !== 'function') {
37 | throw new Error('Second argument expects a function');
38 | }
39 | if (this.listeners === null || typeof this.listeners !== 'object') {
40 | this.listeners = {};
41 | }
42 | let listeners = this.listeners[eventName];
43 | if (typeof listeners !== 'object') {
44 | listeners = [];
45 | }
46 | listeners.push(callback);
47 | this.listeners[eventName] = listeners;
48 | }
49 |
50 | removeEventListener(eventName, callback) {
51 | if (typeof eventName !== 'string') {
52 | throw new Error('First argument expects a string');
53 | }
54 | if (typeof callback !== 'function') {
55 | throw new Error('Second argument expects a function');
56 | }
57 | if (this.listeners === null || typeof this.listeners !== 'object') {
58 | this.listeners = {};
59 | }
60 | const listeners = this.listeners[eventName];
61 | if (typeof listeners === 'object') {
62 | for (let i = 0; i < listeners.length; i += 1) {
63 | if (listeners[i] === callback) {
64 | listeners.splice(i, 1);
65 | return;
66 | }
67 | }
68 | }
69 | this.listeners[eventName] = listeners;
70 | }
71 |
72 | extend(obj) {
73 | // eslint-disable-next-line no-restricted-syntax, guard-for-in
74 | for (const prop in obj) {
75 | this[prop] = obj[prop];
76 | }
77 | return this;
78 | }
79 | }
80 |
81 | exports = module.exports = EventTarget;
82 |
--------------------------------------------------------------------------------
/src/Message.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const helpers = require('./helpers.js');
8 | const protobuf = require('./protobufs.js');
9 |
10 | const DataMessage = protobuf.lookupType('signalservice.DataMessage');
11 | const DataMessageQuote = protobuf.lookupType('signalservice.DataMessage.Quote');
12 | const DataMessageSticker = protobuf.lookupType(
13 | 'signalservice.DataMessage.Sticker'
14 | );
15 | const DataMessagePreview = protobuf.lookupType(
16 | 'signalservice.DataMessage.Preview'
17 | );
18 | const GroupContext = protobuf.lookupType('signalservice.GroupContext');
19 |
20 | /* eslint-disable more/no-then, no-bitwise */
21 |
22 | class Message {
23 | constructor(options) {
24 | this.attachments = options.attachments || [];
25 | this.body = options.body;
26 | this.expireTimer = options.expireTimer;
27 | this.flags = options.flags;
28 | this.group = options.group;
29 | this.needsSync = options.needsSync;
30 | this.preview = options.preview;
31 | this.profileKey = options.profileKey;
32 | this.quote = options.quote;
33 | this.recipients = options.recipients;
34 | this.sticker = options.sticker;
35 | this.reaction = options.reaction;
36 | this.timestamp = options.timestamp;
37 |
38 | if (!(this.recipients instanceof Array)) {
39 | throw new Error('Invalid recipient list');
40 | }
41 |
42 | if (!this.group && this.recipients.length !== 1) {
43 | throw new Error('Invalid recipient list for non-group');
44 | }
45 |
46 | if (typeof this.timestamp !== 'number') {
47 | throw new Error('Invalid timestamp');
48 | }
49 |
50 | if (this.expireTimer !== undefined && this.expireTimer !== null) {
51 | if (typeof this.expireTimer !== 'number' || !(this.expireTimer >= 0)) {
52 | throw new Error('Invalid expireTimer');
53 | }
54 | }
55 |
56 | if (this.attachments) {
57 | if (!(this.attachments instanceof Array)) {
58 | throw new Error('Invalid message attachments');
59 | }
60 | }
61 | if (this.flags !== undefined) {
62 | if (typeof this.flags !== 'number') {
63 | throw new Error('Invalid message flags');
64 | }
65 | }
66 | if (this.isEndSession()) {
67 | if (
68 | this.body !== null
69 | || this.group !== null
70 | || this.attachments.length !== 0
71 | ) {
72 | throw new Error('Invalid end session message');
73 | }
74 | } else {
75 | if (
76 | typeof this.timestamp !== 'number'
77 | || (this.body && typeof this.body !== 'string')
78 | ) {
79 | throw new Error('Invalid message body');
80 | }
81 | if (this.group) {
82 | if (
83 | typeof this.group.id !== 'string'
84 | || typeof this.group.type !== 'number'
85 | ) {
86 | throw new Error('Invalid group context');
87 | }
88 | }
89 | }
90 | }
91 |
92 | isEndSession() {
93 | return this.flags & DataMessage.Flags.END_SESSION;
94 | }
95 |
96 | toProto() {
97 | if (
98 | this.dataMessage !== undefined
99 | && this.dataMessage.$type === DataMessage
100 | ) {
101 | return this.dataMessage;
102 | }
103 | const proto = DataMessage.create({});
104 |
105 | proto.timestamp = this.timestamp;
106 | proto.attachments = this.attachmentPointers;
107 |
108 | if (this.body) {
109 | proto.body = this.body;
110 | }
111 | if (this.flags) {
112 | proto.flags = this.flags;
113 | }
114 | if (this.group) {
115 | proto.group = GroupContext.create();
116 | proto.group.id = new Uint8Array(helpers.stringToArrayBuffer(this.group.id));
117 | proto.group.type = this.group.type;
118 | }
119 | if (this.sticker) {
120 | proto.sticker = DataMessageSticker.create();
121 | proto.sticker.packId = new Uint8Array(
122 | helpers.hexStringToArrayBuffer(this.sticker.packId)
123 | );
124 | proto.sticker.packKey = new Uint8Array(
125 | helpers.base64ToArrayBuffer(this.sticker.packKey)
126 | );
127 | proto.sticker.stickerId = this.sticker.stickerId;
128 |
129 | if (this.sticker.attachmentPointer) {
130 | proto.sticker.data = this.sticker.attachmentPointer;
131 | }
132 |
133 | if (this.reaction) {
134 | proto.reaction = this.reaction;
135 | }
136 | }
137 | if (Array.isArray(this.preview)) {
138 | proto.preview = this.preview.map(preview => {
139 | const item = DataMessagePreview.create();
140 | item.title = preview.title;
141 | item.url = preview.url;
142 | item.image = preview.image || null;
143 | return item;
144 | });
145 | }
146 | if (this.quote) {
147 | const { QuotedAttachment } = DataMessageQuote;
148 | const { Quote } = DataMessage;
149 |
150 | proto.quote = Quote.create();
151 | const { quote } = proto;
152 |
153 | quote.id = this.quote.id;
154 | quote.author = this.quote.author;
155 | quote.text = this.quote.text;
156 | quote.attachments = (this.quote.attachments || []).map(attachment => {
157 | const quotedAttachment = QuotedAttachment.create();
158 |
159 | quotedAttachment.contentType = attachment.contentType;
160 | quotedAttachment.fileName = attachment.fileName;
161 | if (attachment.attachmentPointer) {
162 | quotedAttachment.thumbnail = attachment.attachmentPointer;
163 | }
164 |
165 | return quotedAttachment;
166 | });
167 | }
168 | if (this.expireTimer) {
169 | proto.expireTimer = this.expireTimer;
170 | }
171 |
172 | if (this.profileKey) {
173 | proto.profileKey = this.profileKey;
174 | }
175 |
176 | this.dataMessage = proto;
177 | return proto;
178 | }
179 |
180 | toArrayBuffer() {
181 | return DataMessage.encode(this.toProto()).finish();
182 | }
183 | }
184 |
185 | exports = module.exports = Message;
186 |
--------------------------------------------------------------------------------
/src/Metadata.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | /* eslint-disable no-bitwise */
8 |
9 | const libsignal = require('@throneless/libsignal-protocol');
10 | const protobuf = require('./protobufs.js');
11 |
12 | const SenderCertificate = protobuf.lookupType(
13 | 'signalservice.SenderCertificate'
14 | );
15 | const ServerCertificate = protobuf.lookupType(
16 | 'signalservice.ServerCertificate'
17 | );
18 | const UnidentifiedSenderMessage = protobuf.lookupType(
19 | 'signalservice.UnidentifiedSenderMessage'
20 | );
21 | const {
22 | bytesFromString,
23 | concatenateBytes,
24 | constantTimeEqual,
25 | decryptAesCtr,
26 | encryptAesCtr,
27 | fromEncodedBinaryToArrayBuffer,
28 | getViewOfArrayBuffer,
29 | getZeroes,
30 | highBitsToInt,
31 | hmacSha256,
32 | intsToByteHighAndLow,
33 | splitBytes,
34 | trimBytes,
35 | } = require('./crypto.js');
36 |
37 | const CiphertextMessage = {
38 | CURRENT_VERSION: 3,
39 |
40 | // This matches Envelope.Type.CIPHERTEXT
41 | WHISPER_TYPE: 1,
42 | // This matches Envelope.Type.PREKEY_BUNDLE
43 | PREKEY_TYPE: 3,
44 |
45 | SENDERKEY_TYPE: 4,
46 | SENDERKEY_DISTRIBUTION_TYPE: 5,
47 |
48 | ENCRYPTED_MESSAGE_OVERHEAD: 53,
49 | };
50 |
51 | const REVOKED_CERTIFICATES = [];
52 |
53 | const CIPHERTEXT_VERSION = 1;
54 | const UNIDENTIFIED_DELIVERY_PREFIX = 'UnidentifiedDelivery';
55 |
56 | // public CertificateValidator(ECPublicKey trustRoot)
57 | function createCertificateValidator(trustRoot) {
58 | return {
59 | // public void validate(SenderCertificate certificate, long validationTime)
60 | async validate(certificate, validationTime) {
61 | const serverCertificate = certificate.signer;
62 |
63 | await libsignal._curve.libsignal_Curve_async.verifySignature(
64 | trustRoot,
65 | serverCertificate.certificate,
66 | serverCertificate.signature
67 | );
68 |
69 | const serverCertId = serverCertificate.certificate.id;
70 | if (REVOKED_CERTIFICATES.includes(serverCertId)) {
71 | throw new Error(
72 | `Server certificate id ${serverCertId} has been revoked`
73 | );
74 | }
75 |
76 | await libsignal._curve.libsignal_Curve_async.verifySignature(
77 | serverCertificate.key,
78 | certificate.certificate,
79 | certificate.signature
80 | );
81 |
82 | if (validationTime > certificate.expires) {
83 | throw new Error('Certificate is expired');
84 | }
85 | },
86 | };
87 | }
88 |
89 | function _decodePoint(serialized, offset = 0) {
90 | const view = offset > 0
91 | ? getViewOfArrayBuffer(serialized, offset, serialized.byteLength)
92 | : serialized;
93 |
94 | return libsignal.Curve.validatePubKeyFormat(view);
95 | }
96 |
97 | // public ServerCertificate(byte[] serialized)
98 | function _createServerCertificateFromBuffer(serialized) {
99 | const wrapper = ServerCertificate.decode(serialized);
100 |
101 | if (!wrapper.certificate || !wrapper.signature) {
102 | throw new Error('Missing fields');
103 | }
104 |
105 | const certificate = ServerCertificate.Certificate.decode(
106 | wrapper.certificate.toArrayBuffer()
107 | );
108 |
109 | if (!certificate.id || !certificate.key) {
110 | throw new Error('Missing fields');
111 | }
112 |
113 | return {
114 | id: certificate.id,
115 | key: certificate.key.toArrayBuffer(),
116 | serialized,
117 | certificate: wrapper.certificate.toArrayBuffer(),
118 |
119 | signature: wrapper.signature.toArrayBuffer(),
120 | };
121 | }
122 |
123 | // public SenderCertificate(byte[] serialized)
124 | function _createSenderCertificateFromBuffer(serialized) {
125 | const wrapper = SenderCertificate.decode(serialized);
126 |
127 | if (!wrapper.signature || !wrapper.certificate) {
128 | throw new Error('Missing fields');
129 | }
130 |
131 | const certificate = SenderCertificate.Certificate.decode(
132 | wrapper.certificate.toArrayBuffer()
133 | );
134 |
135 | if (
136 | !certificate.signer
137 | || !certificate.identityKey
138 | || !certificate.senderDevice
139 | || !certificate.expires
140 | || !certificate.sender
141 | ) {
142 | throw new Error('Missing fields');
143 | }
144 |
145 | return {
146 | sender: certificate.sender,
147 | senderDevice: certificate.senderDevice,
148 | expires: certificate.expires.toNumber(),
149 | identityKey: certificate.identityKey.toArrayBuffer(),
150 | signer: _createServerCertificateFromBuffer(
151 | certificate.signer.toArrayBuffer()
152 | ),
153 |
154 | certificate: wrapper.certificate.toArrayBuffer(),
155 | signature: wrapper.signature.toArrayBuffer(),
156 |
157 | serialized,
158 | };
159 | }
160 |
161 | // public UnidentifiedSenderMessage(byte[] serialized)
162 | function _createUnidentifiedSenderMessageFromBuffer(serialized) {
163 | const version = highBitsToInt(serialized[0]);
164 |
165 | if (version > CIPHERTEXT_VERSION) {
166 | throw new Error(`Unknown version: ${this.version}`);
167 | }
168 |
169 | const view = getViewOfArrayBuffer(serialized, 1, serialized.byteLength);
170 | const unidentifiedSenderMessage = UnidentifiedSenderMessage.decode(view);
171 |
172 | if (
173 | !unidentifiedSenderMessage.ephemeralPublic
174 | || !unidentifiedSenderMessage.encryptedStatic
175 | || !unidentifiedSenderMessage.encryptedMessage
176 | ) {
177 | throw new Error('Missing fields');
178 | }
179 |
180 | return {
181 | version,
182 |
183 | ephemeralPublic: unidentifiedSenderMessage.ephemeralPublic.toArrayBuffer(),
184 | encryptedStatic: unidentifiedSenderMessage.encryptedStatic.toArrayBuffer(),
185 | encryptedMessage: unidentifiedSenderMessage.encryptedMessage.toArrayBuffer(),
186 |
187 | serialized,
188 | };
189 | }
190 |
191 | // public UnidentifiedSenderMessage(
192 | // ECPublicKey ephemeral, byte[] encryptedStatic, byte[] encryptedMessage) {
193 | function _createUnidentifiedSenderMessage(
194 | ephemeralPublic,
195 | encryptedStatic,
196 | encryptedMessage
197 | ) {
198 | const versionBytes = new Uint8Array([
199 | intsToByteHighAndLow(CIPHERTEXT_VERSION, CIPHERTEXT_VERSION),
200 | ]);
201 | const unidentifiedSenderMessage = UnidentifiedSenderMessage.create();
202 |
203 | unidentifiedSenderMessage.encryptedMessage = encryptedMessage;
204 | unidentifiedSenderMessage.encryptedStatic = encryptedStatic;
205 | unidentifiedSenderMessage.ephemeralPublic = ephemeralPublic;
206 |
207 | const messageBytes = unidentifiedSenderMessage.encode().toArrayBuffer();
208 |
209 | return {
210 | version: CIPHERTEXT_VERSION,
211 |
212 | ephemeralPublic,
213 | encryptedStatic,
214 | encryptedMessage,
215 |
216 | serialized: concatenateBytes(versionBytes, messageBytes),
217 | };
218 | }
219 |
220 | // public UnidentifiedSenderMessageContent(byte[] serialized)
221 | function _createUnidentifiedSenderMessageContentFromBuffer(serialized) {
222 | const TypeEnum = UnidentifiedSenderMessage.Message.Type;
223 |
224 | const message = UnidentifiedSenderMessage.Message.decode(serialized);
225 |
226 | if (!message.type || !message.senderCertificate || !message.content) {
227 | throw new Error('Missing fields');
228 | }
229 |
230 | let type;
231 | switch (message.type) {
232 | case TypeEnum.MESSAGE:
233 | type = CiphertextMessage.WHISPER_TYPE;
234 | break;
235 | case TypeEnum.PREKEY_MESSAGE:
236 | type = CiphertextMessage.PREKEY_TYPE;
237 | break;
238 | default:
239 | throw new Error(`Unknown type: ${message.type}`);
240 | }
241 |
242 | return {
243 | type,
244 | senderCertificate: _createSenderCertificateFromBuffer(
245 | message.senderCertificate.toArrayBuffer()
246 | ),
247 | content: message.content.toArrayBuffer(),
248 |
249 | serialized,
250 | };
251 | }
252 |
253 | // private int getProtoType(int type)
254 | function _getProtoMessageType(type) {
255 | const TypeEnum = UnidentifiedSenderMessage.Message.Type;
256 |
257 | switch (type) {
258 | case CiphertextMessage.WHISPER_TYPE:
259 | return TypeEnum.MESSAGE;
260 | case CiphertextMessage.PREKEY_TYPE:
261 | return TypeEnum.PREKEY_MESSAGE;
262 | default:
263 | throw new Error(`_getProtoMessageType: type '${type}' does not exist`);
264 | }
265 | }
266 |
267 | // public UnidentifiedSenderMessageContent(
268 | // int type, SenderCertificate senderCertificate, byte[] content)
269 | function _createUnidentifiedSenderMessageContent(
270 | type,
271 | senderCertificate,
272 | content
273 | ) {
274 | const innerMessage = new UnidentifiedSenderMessage.Message();
275 | innerMessage.type = _getProtoMessageType(type);
276 | innerMessage.senderCertificate = SenderCertificate.decode(
277 | senderCertificate.serialized
278 | );
279 | innerMessage.content = content;
280 |
281 | return {
282 | type,
283 | senderCertificate,
284 | content,
285 |
286 | serialized: innerMessage.encode().toArrayBuffer(),
287 | };
288 | }
289 |
290 | class SecretSessionCipher {
291 | // public byte[] encrypt(
292 | // SignalProtocolAddress destinationAddress,
293 | // SenderCertificate senderCertificate,
294 | // byte[] paddedPlaintext
295 | // )
296 | constructor(storage) {
297 | this.storage = storage;
298 |
299 | // We do this on construction because libsignal won't be available when this
300 | // file loads
301 | const { SessionCipher } = libsignal;
302 | this.SessionCipher = SessionCipher;
303 | }
304 |
305 | async encrypt(destinationAddress, senderCertificate, paddedPlaintext) {
306 | // Capture this.xxx variables to replicate Java's implicit this syntax
307 | const { SessionCipher } = this;
308 | const signalProtocolStore = this.storage;
309 | const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this);
310 | const _encryptWithSecretKeys = this._encryptWithSecretKeys.bind(this);
311 | const _calculateStaticKeys = this._calculateStaticKeys.bind(this);
312 |
313 | const sessionCipher = new SessionCipher(
314 | signalProtocolStore,
315 | destinationAddress
316 | );
317 |
318 | const message = await sessionCipher.encrypt(paddedPlaintext);
319 | const ourIdentity = await signalProtocolStore.getIdentityKeyPair();
320 | const theirIdentity = fromEncodedBinaryToArrayBuffer(
321 | await signalProtocolStore.loadIdentityKey(destinationAddress.getName())
322 | );
323 |
324 | const ephemeral = await libsignal._curve.libsignal_Curve_async.generateKeyPair();
325 | const ephemeralSalt = concatenateBytes(
326 | bytesFromString(UNIDENTIFIED_DELIVERY_PREFIX),
327 | theirIdentity,
328 | ephemeral.pubKey
329 | );
330 | const ephemeralKeys = await _calculateEphemeralKeys(
331 | theirIdentity,
332 | ephemeral.privKey,
333 | ephemeralSalt
334 | );
335 | const staticKeyCiphertext = await _encryptWithSecretKeys(
336 | ephemeralKeys.cipherKey,
337 | ephemeralKeys.macKey,
338 | ourIdentity.pubKey
339 | );
340 |
341 | const staticSalt = concatenateBytes(
342 | ephemeralKeys.chainKey,
343 | staticKeyCiphertext
344 | );
345 | const staticKeys = await _calculateStaticKeys(
346 | theirIdentity,
347 | ourIdentity.privKey,
348 | staticSalt
349 | );
350 | const content = _createUnidentifiedSenderMessageContent(
351 | message.type,
352 | senderCertificate,
353 | fromEncodedBinaryToArrayBuffer(message.body)
354 | );
355 | const messageBytes = await _encryptWithSecretKeys(
356 | staticKeys.cipherKey,
357 | staticKeys.macKey,
358 | content.serialized
359 | );
360 |
361 | const unidentifiedSenderMessage = _createUnidentifiedSenderMessage(
362 | ephemeral.pubKey,
363 | staticKeyCiphertext,
364 | messageBytes
365 | );
366 |
367 | return unidentifiedSenderMessage.serialized;
368 | }
369 |
370 | // public Pair decrypt(
371 | // CertificateValidator validator, byte[] ciphertext, long timestamp)
372 | async decrypt(validator, ciphertext, timestamp, me) {
373 | // Capture this.xxx variables to replicate Java's implicit this syntax
374 | const signalProtocolStore = this.storage;
375 | const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this);
376 | const _calculateStaticKeys = this._calculateStaticKeys.bind(this);
377 | const _decryptWithUnidentifiedSenderMessage = this._decryptWithUnidentifiedSenderMessage.bind(
378 | this
379 | );
380 | const _decryptWithSecretKeys = this._decryptWithSecretKeys.bind(this);
381 |
382 | const ourIdentity = await signalProtocolStore.getIdentityKeyPair();
383 | const wrapper = _createUnidentifiedSenderMessageFromBuffer(ciphertext);
384 | const ephemeralSalt = concatenateBytes(
385 | bytesFromString(UNIDENTIFIED_DELIVERY_PREFIX),
386 | ourIdentity.pubKey,
387 | wrapper.ephemeralPublic
388 | );
389 | const ephemeralKeys = await _calculateEphemeralKeys(
390 | wrapper.ephemeralPublic,
391 | ourIdentity.privKey,
392 | ephemeralSalt
393 | );
394 | const staticKeyBytes = await _decryptWithSecretKeys(
395 | ephemeralKeys.cipherKey,
396 | ephemeralKeys.macKey,
397 | wrapper.encryptedStatic
398 | );
399 |
400 | const staticKey = _decodePoint(staticKeyBytes, 0);
401 | const staticSalt = concatenateBytes(
402 | ephemeralKeys.chainKey,
403 | wrapper.encryptedStatic
404 | );
405 | const staticKeys = await _calculateStaticKeys(
406 | staticKey,
407 | ourIdentity.privKey,
408 | staticSalt
409 | );
410 | const messageBytes = await _decryptWithSecretKeys(
411 | staticKeys.cipherKey,
412 | staticKeys.macKey,
413 | wrapper.encryptedMessage
414 | );
415 |
416 | const content = _createUnidentifiedSenderMessageContentFromBuffer(
417 | messageBytes
418 | );
419 |
420 | await validator.validate(content.senderCertificate, timestamp);
421 | if (
422 | !constantTimeEqual(content.senderCertificate.identityKey, staticKeyBytes)
423 | ) {
424 | throw new Error(
425 | "Sender's certificate key does not match key used in message"
426 | );
427 | }
428 |
429 | const { sender, senderDevice } = content.senderCertificate;
430 | const { number, deviceId } = me || {};
431 | if (sender === number && senderDevice === deviceId) {
432 | return {
433 | isMe: true,
434 | };
435 | }
436 | const address = new libsignal.SignalProtocolAddress(sender, senderDevice);
437 |
438 | try {
439 | return {
440 | sender: address,
441 | content: await _decryptWithUnidentifiedSenderMessage(content),
442 | };
443 | } catch (error) {
444 | if (!error) {
445 | // eslint-disable-next-line no-ex-assign
446 | error = new Error('Decryption error was falsey!');
447 | }
448 |
449 | error.sender = address;
450 |
451 | throw error;
452 | }
453 | }
454 |
455 | // public int getSessionVersion(SignalProtocolAddress remoteAddress) {
456 | getSessionVersion(remoteAddress) {
457 | const { SessionCipher } = this;
458 | const signalProtocolStore = this.storage;
459 |
460 | const cipher = new SessionCipher(signalProtocolStore, remoteAddress);
461 |
462 | return cipher.getSessionVersion();
463 | }
464 |
465 | // public int getRemoteRegistrationId(SignalProtocolAddress remoteAddress) {
466 | getRemoteRegistrationId(remoteAddress) {
467 | const { SessionCipher } = this;
468 | const signalProtocolStore = this.storage;
469 |
470 | const cipher = new SessionCipher(signalProtocolStore, remoteAddress);
471 |
472 | return cipher.getRemoteRegistrationId();
473 | }
474 |
475 | // Used by OutgoingMessage.js
476 | closeOpenSessionForDevice(remoteAddress) {
477 | const { SessionCipher } = this;
478 | const signalProtocolStore = this.storage;
479 |
480 | const cipher = new SessionCipher(signalProtocolStore, remoteAddress);
481 |
482 | return cipher.closeOpenSessionForDevice();
483 | }
484 |
485 | // private EphemeralKeys calculateEphemeralKeys(
486 | // ECPublicKey ephemeralPublic, ECPrivateKey ephemeralPrivate, byte[] salt)
487 | async _calculateEphemeralKeys(ephemeralPublic, ephemeralPrivate, salt) {
488 | const ephemeralSecret = await libsignal._curve.libsignal_Curve_async.calculateAgreement(
489 | ephemeralPublic,
490 | ephemeralPrivate
491 | );
492 | const ephemeralDerivedParts = await libsignal.HKDF.deriveSecrets(
493 | ephemeralSecret,
494 | salt,
495 | new ArrayBuffer()
496 | );
497 |
498 | // private EphemeralKeys(byte[] chainKey, byte[] cipherKey, byte[] macKey)
499 | return {
500 | chainKey: ephemeralDerivedParts[0],
501 | cipherKey: ephemeralDerivedParts[1],
502 | macKey: ephemeralDerivedParts[2],
503 | };
504 | }
505 |
506 | // private StaticKeys calculateStaticKeys(
507 | // ECPublicKey staticPublic, ECPrivateKey staticPrivate, byte[] salt)
508 | async _calculateStaticKeys(staticPublic, staticPrivate, salt) {
509 | const staticSecret = await libsignal._curve.libsignal_Curve_async.calculateAgreement(
510 | staticPublic,
511 | staticPrivate
512 | );
513 | const staticDerivedParts = await libsignal.HKDF.deriveSecrets(
514 | staticSecret,
515 | salt,
516 | new ArrayBuffer()
517 | );
518 |
519 | // private StaticKeys(byte[] cipherKey, byte[] macKey)
520 | return {
521 | cipherKey: staticDerivedParts[1],
522 | macKey: staticDerivedParts[2],
523 | };
524 | }
525 |
526 | // private byte[] decrypt(UnidentifiedSenderMessageContent message)
527 | _decryptWithUnidentifiedSenderMessage(message) {
528 | const { SessionCipher } = this;
529 | const signalProtocolStore = this.storage;
530 |
531 | const sender = new libsignal.SignalProtocolAddress(
532 | message.senderCertificate.sender,
533 | message.senderCertificate.senderDevice
534 | );
535 |
536 | switch (message.type) {
537 | case CiphertextMessage.WHISPER_TYPE:
538 | return new SessionCipher(
539 | signalProtocolStore,
540 | sender
541 | ).decryptWhisperMessage(message.content);
542 | case CiphertextMessage.PREKEY_TYPE:
543 | return new SessionCipher(
544 | signalProtocolStore,
545 | sender
546 | ).decryptPreKeyWhisperMessage(message.content);
547 | default:
548 | throw new Error(`Unknown type: ${message.type}`);
549 | }
550 | }
551 |
552 | // private byte[] encrypt(
553 | // SecretKeySpec cipherKey, SecretKeySpec macKey, byte[] plaintext)
554 | async _encryptWithSecretKeys(cipherKey, macKey, plaintext) {
555 | // Cipher const cipher = Cipher.getInstance('AES/CTR/NoPadding');
556 | // cipher.init(Cipher.ENCRYPT_MODE, cipherKey, new IvParameterSpec(new byte[16]));
557 |
558 | // Mac const mac = Mac.getInstance('HmacSHA256');
559 | // mac.init(macKey);
560 |
561 | // byte[] const ciphertext = cipher.doFinal(plaintext);
562 | const ciphertext = await encryptAesCtr(cipherKey, plaintext, getZeroes(16));
563 |
564 | // byte[] const ourFullMac = mac.doFinal(ciphertext);
565 | const ourFullMac = await hmacSha256(macKey, ciphertext);
566 | const ourMac = trimBytes(ourFullMac, 10);
567 |
568 | return concatenateBytes(ciphertext, ourMac);
569 | }
570 |
571 | // private byte[] decrypt(
572 | // SecretKeySpec cipherKey, SecretKeySpec macKey, byte[] ciphertext)
573 | async _decryptWithSecretKeys(cipherKey, macKey, ciphertext) {
574 | if (ciphertext.byteLength < 10) {
575 | throw new Error('Ciphertext not long enough for MAC!');
576 | }
577 |
578 | const ciphertextParts = splitBytes(
579 | ciphertext,
580 | ciphertext.byteLength - 10,
581 | 10
582 | );
583 |
584 | // Mac const mac = Mac.getInstance('HmacSHA256');
585 | // mac.init(macKey);
586 |
587 | // byte[] const digest = mac.doFinal(ciphertextParts[0]);
588 | const digest = await hmacSha256(macKey, ciphertextParts[0]);
589 | const ourMac = trimBytes(digest, 10);
590 | const theirMac = ciphertextParts[1];
591 |
592 | if (!constantTimeEqual(ourMac, theirMac)) {
593 | throw new Error('Bad mac!');
594 | }
595 |
596 | // Cipher const cipher = Cipher.getInstance('AES/CTR/NoPadding');
597 | // cipher.init(Cipher.DECRYPT_MODE, cipherKey, new IvParameterSpec(new byte[16]));
598 |
599 | // return cipher.doFinal(ciphertextParts[0]);
600 | return decryptAesCtr(cipherKey, ciphertextParts[0], getZeroes(16));
601 | }
602 | }
603 |
604 | module.exports = {
605 | SecretSessionCipher,
606 | createCertificateValidator,
607 | _createServerCertificateFromBuffer,
608 | _createSenderCertificateFromBuffer,
609 | };
610 |
--------------------------------------------------------------------------------
/src/OutgoingMessage.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | /* eslint-disable more/no-then */
8 |
9 | const debug = require('debug')('libsignal-service:OutgoingMessage');
10 | const _ = require('lodash');
11 | const btoa = require('btoa');
12 | const libsignal = require('@throneless/libsignal-protocol');
13 | const errors = require('./errors.js');
14 | const protobuf = require('./protobufs.js');
15 | const crypto = require('./crypto.js');
16 | const { SecretSessionCipher } = require('./Metadata.js');
17 |
18 | const Content = protobuf.lookupType('signalservice.Content');
19 | const DataMessage = protobuf.lookupType('signalservice.DataMessage');
20 | const Envelope = protobuf.lookupType('signalservice.DataMessage');
21 |
22 | class OutgoingMessage {
23 | constructor(
24 | server,
25 | store,
26 | timestamp,
27 | identifiers,
28 | message,
29 | silent,
30 | callback,
31 | options = {}
32 | ) {
33 | if (message.$type === DataMessage) {
34 | const content = Content.create();
35 | content.dataMessage = message;
36 | // eslint-disable-next-line no-param-reassign
37 | message = content;
38 | }
39 | this.server = server;
40 | this.store = store;
41 | this.timestamp = timestamp;
42 | this.identifiers = identifiers;
43 | this.message = message; // ContentMessage proto
44 | this.callback = callback;
45 | this.silent = silent;
46 |
47 | this.identifiersCompleted = 0;
48 | this.errors = [];
49 | this.successfulIdentifiers = [];
50 | this.failoverIdentifiers = [];
51 | this.unidentifiedDeliveries = [];
52 |
53 | const { sendMetadata, senderCertificate, online } = options || {};
54 | this.sendMetadata = sendMetadata;
55 | this.senderCertificate = senderCertificate;
56 | this.online = online;
57 | }
58 |
59 | numberCompleted() {
60 | this.identifiersCompleted += 1;
61 | if (this.identifiersCompleted >= this.identifiers.length) {
62 | this.callback({
63 | successfulIdentifiers: this.successfulIdentifiers,
64 | failoverIdentifiers: this.failoverIdentifiers,
65 | errors: this.errors,
66 | unidentifiedDeliveries: this.unidentifiedDeliveries,
67 | });
68 | }
69 | }
70 |
71 | registerError(identifier, reason, error) {
72 | if (!error || (error.name === 'HTTPError' && error.code !== 404)) {
73 | // eslint-disable-next-line no-param-reassign
74 | error = new errors.OutgoingMessageError(
75 | identifier,
76 | Content.encode(this.message).finish(),
77 | this.timestamp,
78 | error
79 | );
80 | }
81 |
82 | // eslint-disable-next-line no-param-reassign
83 | error.reason = reason;
84 | this.errors[this.errors.length] = error;
85 | this.numberCompleted();
86 | }
87 |
88 | reloadDevicesAndSend(identifier, recurse) {
89 | return () =>
90 | this.store.getDeviceIds(identifier).then(deviceIds => {
91 | if (deviceIds.length === 0) {
92 | return this.registerError(
93 | identifier,
94 | 'Got empty device list when loading device keys',
95 | null
96 | );
97 | }
98 | return this.doSendMessage(identifier, deviceIds, recurse);
99 | });
100 | }
101 |
102 | getKeysForIdentifier(identifier, updateDevices) {
103 | const handleResult = response =>
104 | Promise.all(
105 | response.devices.map(device => {
106 | // eslint-disable-next-line no-param-reassign
107 | device.identityKey = response.identityKey;
108 | if (
109 | updateDevices === undefined
110 | || updateDevices.indexOf(device.deviceId) > -1
111 | ) {
112 | const address = new libsignal.SignalProtocolAddress(
113 | identifier,
114 | device.deviceId
115 | );
116 | const builder = new libsignal.SessionBuilder(this.store, address);
117 | if (device.registrationId === 0) {
118 | debug('device registrationId 0!');
119 | }
120 | return builder.processPreKey(device).catch(error => {
121 | if (error.message === 'Identity key changed') {
122 | // eslint-disable-next-line no-param-reassign
123 | error.timestamp = this.timestamp;
124 | // eslint-disable-next-line no-param-reassign
125 | error.originalMessage = Content.encode(this.message).finish();
126 | // eslint-disable-next-line no-param-reassign
127 | error.identityKey = device.identityKey;
128 | }
129 | throw error;
130 | });
131 | }
132 |
133 | return null;
134 | })
135 | );
136 |
137 | const { sendMetadata } = this;
138 | const info = sendMetadata && sendMetadata[identifier] ? sendMetadata[identifier] : {};
139 | const { accessKey } = info || {};
140 |
141 | if (updateDevices === undefined) {
142 | if (accessKey) {
143 | return this.server
144 | .getKeysForIdentifierUnauth(identifier, '*', { accessKey })
145 | .catch(error => {
146 | if (error.code === 401 || error.code === 403) {
147 | if (this.failoverIdentifiers.indexOf(identifier) === -1) {
148 | this.failoverIdentifiers.push(identifier);
149 | }
150 | return this.server.getKeysForIdentifier(identifier, '*');
151 | }
152 | throw error;
153 | })
154 | .then(handleResult);
155 | }
156 |
157 | return this.server
158 | .getKeysForIdentifier(identifier, '*')
159 | .then(handleResult);
160 | }
161 |
162 | let promise = Promise.resolve();
163 | updateDevices.forEach(deviceId => {
164 | promise = promise.then(() => {
165 | let innerPromise;
166 |
167 | if (accessKey) {
168 | innerPromise = this.server
169 | .getKeysForIdentifierUnauth(identifier, deviceId, { accessKey })
170 | .then(handleResult)
171 | .catch(error => {
172 | if (error.code === 401 || error.code === 403) {
173 | if (this.failoverIdentifiers.indexOf(identifier) === -1) {
174 | this.failoverIdentifiers.push(identifier);
175 | }
176 | return this.server
177 | .getKeysForIdentifier(identifier, deviceId)
178 | .then(handleResult);
179 | }
180 | throw error;
181 | });
182 | } else {
183 | innerPromise = this.server
184 | .getKeysForIdentifier(identifier, deviceId)
185 | .then(handleResult);
186 | }
187 |
188 | return innerPromise.catch(e => {
189 | if (e.name === 'HTTPError' && e.code === 404) {
190 | if (deviceId !== 1) {
191 | return this.removeDeviceIdsForIdentifier(identifier, [deviceId]);
192 | }
193 | throw new errors.UnregisteredUserError(identifier, e);
194 | } else {
195 | throw e;
196 | }
197 | });
198 | });
199 | });
200 |
201 | return promise;
202 | }
203 |
204 | transmitMessage(identifier, jsonData, timestamp, { accessKey } = {}) {
205 | let promise;
206 |
207 | if (accessKey) {
208 | promise = this.server.sendMessagesUnauth(
209 | identifier,
210 | jsonData,
211 | timestamp,
212 | this.silent,
213 | this.online,
214 | { accessKey }
215 | );
216 | } else {
217 | promise = this.server.sendMessages(
218 | identifier,
219 | jsonData,
220 | timestamp,
221 | this.silent,
222 | this.online
223 | );
224 | }
225 |
226 | return promise.catch(e => {
227 | if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) {
228 | // 409 and 410 should bubble and be handled by doSendMessage
229 | // 404 should throw UnregisteredUserError
230 | // all other network errors can be retried later.
231 | if (e.code === 404) {
232 | throw new errors.UnregisteredUserError(identifier, e);
233 | }
234 | throw new errors.SendMessageNetworkError(
235 | identifier,
236 | jsonData,
237 | e,
238 | timestamp
239 | );
240 | }
241 | throw e;
242 | });
243 | }
244 |
245 | getPaddedMessageLength(messageLength) {
246 | const messageLengthWithTerminator = messageLength + 1;
247 | let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
248 |
249 | if (messageLengthWithTerminator % 160 !== 0) {
250 | messagePartCount += 1;
251 | }
252 |
253 | return messagePartCount * 160;
254 | }
255 |
256 | getPlaintext() {
257 | if (!this.plaintext) {
258 | const messageBuffer = Content.encode(this.message).finish();
259 | this.plaintext = new Uint8Array(
260 | this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
261 | );
262 | this.plaintext.set(new Uint8Array(messageBuffer));
263 | this.plaintext[messageBuffer.byteLength] = 0x80;
264 | }
265 | return this.plaintext;
266 | }
267 |
268 | async doSendMessage(identifier, deviceIds, recurse) {
269 | const ciphers = {};
270 | const plaintext = this.getPlaintext();
271 |
272 | const { sendMetadata } = this;
273 | const info = sendMetadata && sendMetadata[identifier] ? sendMetadata[identifier] : {};
274 | const { accessKey, useUuidSenderCert } = info || {};
275 | const senderCertificate = useUuidSenderCert
276 | ? this.senderCertificateWithUuid
277 | : this.senderCertificate;
278 |
279 | if (accessKey && !senderCertificate) {
280 | debug(
281 | 'OutgoingMessage.doSendMessage: accessKey was provided, but senderCertificate was not'
282 | );
283 | }
284 |
285 | const sealedSender = Boolean(accessKey && senderCertificate);
286 |
287 | // We don't send to ourselves if unless sealedSender is enabled
288 | const ourNumber = await this.store.getNumber();
289 | const ourUuid = await this.store.getUuid();
290 | const ourDeviceId = await this.store.getDeviceId();
291 | if (
292 | (identifier === ourNumber || identifier === ourUuid)
293 | && !sealedSender
294 | ) {
295 | // eslint-disable-next-line no-param-reassign
296 | deviceIds = _.reject(
297 | deviceIds,
298 | deviceId =>
299 | // because we store our own device ID as a string at least sometimes
300 | deviceId === ourDeviceId || deviceId === parseInt(ourDeviceId, 10)
301 | );
302 | }
303 |
304 | return Promise.all(
305 | deviceIds.map(async deviceId => {
306 | const address = new libsignal.SignalProtocolAddress(
307 | identifier,
308 | deviceId
309 | );
310 |
311 | const options = {};
312 |
313 | // No limit on message keys if we're communicating with our other devices
314 | if (ourNumber === identifier || ourUuid === identifier) {
315 | options.messageKeysLimit = false;
316 | }
317 |
318 | if (sealedSender) {
319 | const secretSessionCipher = new SecretSessionCipher(
320 | this.store
321 | );
322 | ciphers[address.getDeviceId()] = secretSessionCipher;
323 |
324 | const ciphertext = await secretSessionCipher.encrypt(
325 | address,
326 | senderCertificate,
327 | plaintext
328 | );
329 | return {
330 | type: Envelope.Type.UNIDENTIFIED_SENDER,
331 | destinationDeviceId: address.getDeviceId(),
332 | destinationRegistrationId: await secretSessionCipher.getRemoteRegistrationId(
333 | address
334 | ),
335 | content: crypto.arrayBufferToBase64(ciphertext),
336 | };
337 | }
338 | const sessionCipher = new libsignal.SessionCipher(
339 | this.store,
340 | address,
341 | options
342 | );
343 | ciphers[address.getDeviceId()] = sessionCipher;
344 |
345 | const ciphertext = await sessionCipher.encrypt(plaintext);
346 | return {
347 | type: ciphertext.type,
348 | destinationDeviceId: address.getDeviceId(),
349 | destinationRegistrationId: ciphertext.registrationId,
350 | content: btoa(ciphertext.body),
351 | };
352 | })
353 | )
354 | .then(jsonData => {
355 | if (sealedSender) {
356 | return this.transmitMessage(identifier, jsonData, this.timestamp, {
357 | accessKey,
358 | }).then(
359 | () => {
360 | this.unidentifiedDeliveries.push(identifier);
361 | this.successfulIdentifiers.push(identifier);
362 | this.numberCompleted();
363 | },
364 | error => {
365 | if (error.code === 401 || error.code === 403) {
366 | if (this.failoverIdentifiers.indexOf(identifier) === -1) {
367 | this.failoverIdentifiers.push(identifier);
368 | }
369 | if (info) {
370 | info.accessKey = null;
371 | }
372 |
373 | // Set final parameter to true to ensure we don't hit this codepath a
374 | // second time.
375 | return this.doSendMessage(identifier, deviceIds, recurse, true);
376 | }
377 |
378 | throw error;
379 | }
380 | );
381 | }
382 |
383 | return this.transmitMessage(identifier, jsonData, this.timestamp).then(
384 | () => {
385 | this.successfulIdentifiers.push(identifier);
386 | this.numberCompleted();
387 | }
388 | );
389 | })
390 | .catch(error => {
391 | if (
392 | error instanceof Error
393 | && error.name === 'HTTPError'
394 | && (error.code === 410 || error.code === 409)
395 | ) {
396 | if (!recurse)
397 | return this.registerError(
398 | identifier,
399 | 'Hit retry limit attempting to reload device list',
400 | error
401 | );
402 |
403 | let p;
404 | if (error.code === 409) {
405 | p = this.removeDeviceIdsForIdentifier(
406 | identifier,
407 | error.response.extraDevices
408 | );
409 | } else {
410 | p = Promise.all(
411 | error.response.staleDevices.map(deviceId =>
412 | ciphers[deviceId].closeOpenSessionForDevice(
413 | new libsignal.SignalProtocolAddress(identifier, deviceId)
414 | )
415 | )
416 | );
417 | }
418 |
419 | return p.then(() => {
420 | const resetDevices = error.code === 410
421 | ? error.response.staleDevices
422 | : error.response.missingDevices;
423 | return this.getKeysForIdentifier(identifier, resetDevices).then(
424 | // We continue to retry as long as the error code was 409; the assumption is
425 | // that we'll request new device info and the next request will succeed.
426 | this.reloadDevicesAndSend(identifier, error.code === 409)
427 | );
428 | });
429 | } if (error.message === 'Identity key changed') {
430 | // eslint-disable-next-line no-param-reassign
431 | error.timestamp = this.timestamp;
432 | // eslint-disable-next-line no-param-reassign
433 | error.originalMessage = Content.encode(this.message).finish();
434 | debug(
435 | 'Got "key changed" error from encrypt - no identityKey for application layer',
436 | identifier,
437 | deviceIds
438 | );
439 |
440 | debug('closing all sessions for', identifier);
441 | const address = new libsignal.SignalProtocolAddress(identifier, 1);
442 |
443 | const sessionCipher = new libsignal.SessionCipher(
444 | this.store,
445 | address
446 | );
447 | debug('closing session for', address.toString());
448 | return Promise.all([
449 | // Primary device
450 | sessionCipher.closeOpenSessionForDevice(),
451 | // The rest of their devices
452 | this.store.archiveSiblingSessions(address.toString()),
453 | ]).then(
454 | () => {
455 | throw error;
456 | },
457 | innerError => {
458 | debug(
459 | `doSendMessage: Error closing sessions: ${innerError.stack}`
460 | );
461 | throw error;
462 | }
463 | );
464 | }
465 |
466 | this.registerError(
467 | identifier,
468 | 'Failed to create or send message',
469 | error
470 | );
471 | return null;
472 | });
473 | }
474 |
475 | getStaleDeviceIdsForIdentifier(identifier) {
476 | return this.store.getDeviceIds(identifier).then(deviceIds => {
477 | if (deviceIds.length === 0) {
478 | return [1];
479 | }
480 | const updateDevices = [];
481 | return Promise.all(
482 | deviceIds.map(deviceId => {
483 | const address = new libsignal.SignalProtocolAddress(
484 | identifier,
485 | deviceId
486 | );
487 | const sessionCipher = new libsignal.SessionCipher(
488 | this.store,
489 | address
490 | );
491 | return sessionCipher.hasOpenSession().then(hasSession => {
492 | if (!hasSession) {
493 | updateDevices.push(deviceId);
494 | }
495 | });
496 | })
497 | ).then(() => updateDevices);
498 | });
499 | }
500 |
501 | removeDeviceIdsForIdentifier(identifier, deviceIdsToRemove) {
502 | let promise = Promise.resolve();
503 | // eslint-disable-next-line no-restricted-syntax, guard-for-in
504 | for (const j in deviceIdsToRemove) {
505 | promise = promise.then(() => {
506 | const encodedAddress = `${identifier}.${deviceIdsToRemove[j]}`;
507 | return this.store.removeSession(encodedAddress);
508 | });
509 | }
510 | return promise;
511 | }
512 |
513 | async sendToIdentifier(identifier) {
514 | try {
515 | const updateDevices = await this.getStaleDeviceIdsForIdentifier(
516 | identifier
517 | );
518 | await this.getKeysForIdentifier(identifier, updateDevices);
519 | await this.reloadDevicesAndSend(identifier, true)();
520 | } catch (error) {
521 | if (error.message === 'Identity key changed') {
522 | // eslint-disable-next-line no-param-reassign
523 | const newError = new errors.OutgoingIdentityKeyError(
524 | identifier,
525 | error.originalMessage,
526 | error.timestamp,
527 | error.identityKey
528 | );
529 | this.registerError(identifier, 'Identity key changed', newError);
530 | } else {
531 | this.registerError(
532 | identifier,
533 | `Failed to retrieve new device keys for number ${identifier}`,
534 | error
535 | );
536 | }
537 | }
538 | }
539 | }
540 |
541 | exports = module.exports = OutgoingMessage;
542 |
--------------------------------------------------------------------------------
/src/ProvisioningCipher.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const libsignal = require('@throneless/libsignal-protocol');
8 | const ProvisionMessage = require('./protobufs.js').lookupType(
9 | 'signalservice.ProvisionMessage'
10 | );
11 |
12 | /* eslint-disable more/no-then */
13 |
14 | // eslint-disable-next-line func-names
15 | class ProvisioningCipher {
16 | decrypt(provisionEnvelope) {
17 | const masterEphemeral = provisionEnvelope.publicKey.toArrayBuffer();
18 | const message = provisionEnvelope.body.toArrayBuffer();
19 | if (new Uint8Array(message)[0] !== 1) {
20 | throw new Error('Bad version number on ProvisioningMessage');
21 | }
22 |
23 | const iv = message.slice(1, 16 + 1);
24 | const mac = message.slice(message.byteLength - 32, message.byteLength);
25 | const ivAndCiphertext = message.slice(0, message.byteLength - 32);
26 | const ciphertext = message.slice(16 + 1, message.byteLength - 32);
27 |
28 | return libsignal.Curve.async
29 | .calculateAgreement(masterEphemeral, this.keyPair.privKey)
30 | .then(ecRes =>
31 | libsignal.HKDF.deriveSecrets(
32 | ecRes,
33 | new ArrayBuffer(32),
34 | 'TextSecure Provisioning Message'
35 | )
36 | )
37 | .then(keys =>
38 | libsignal.crypto
39 | .verifyMAC(ivAndCiphertext, keys[1], mac, 32)
40 | .then(() => libsignal.crypto.decrypt(keys[0], ciphertext, iv))
41 | )
42 | .then(plaintext => {
43 | const provisionMessage = ProvisionMessage.decode(plaintext);
44 | const privKey = provisionMessage.identityKeyPrivate.toArrayBuffer();
45 |
46 | return libsignal.Curve.async.createKeyPair(privKey).then(keyPair => {
47 | const ret = {
48 | identityKeyPair: keyPair,
49 | number: provisionMessage.number,
50 | provisioningCode: provisionMessage.provisioningCode,
51 | userAgent: provisionMessage.userAgent,
52 | readReceipts: provisionMessage.readReceipts,
53 | };
54 | if (provisionMessage.profileKey) {
55 | ret.profileKey = provisionMessage.profileKey.toArrayBuffer();
56 | }
57 | return ret;
58 | });
59 | });
60 | }
61 |
62 | getPublicKey() {
63 | return Promise.resolve()
64 | .then(() => {
65 | if (!this.keyPair) {
66 | return libsignal.Curve.async.generateKeyPair().then(keyPair => {
67 | this.keyPair = keyPair;
68 | });
69 | }
70 |
71 | return null;
72 | })
73 | .then(() => this.keyPair.pubKey);
74 | }
75 | }
76 |
77 | exports = module.exports = ProvisioningCipher;
78 |
--------------------------------------------------------------------------------
/src/WebSocketResource.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const debug = require('debug')('libsignal-service:WebSocketResource');
8 | const FileReader = require('filereader');
9 | const Long = require('long');
10 | const EventTarget = require('./EventTarget.js');
11 | const Event = require('./Event.js');
12 | const crypto = require('./crypto.js');
13 | const protobuf = require('./protobufs.js');
14 |
15 | const WebSocketMessage = protobuf.lookupType('signalservice.WebSocketMessage');
16 | // eslint-disable-next-line func-names
17 | /*
18 | * WebSocket-Resources
19 | *
20 | * Create a request-response interface over websockets using the
21 | * WebSocket-Resources sub-protocol[1].
22 | *
23 | * var client = new WebSocketResource(socket, function(request) {
24 | * request.respond(200, 'OK');
25 | * });
26 | *
27 | * client.sendRequest({
28 | * verb: 'PUT',
29 | * path: '/v1/messages',
30 | * body: '{ some: "json" }',
31 | * success: function(message, status, request) {...},
32 | * error: function(message, status, request) {...}
33 | * });
34 | *
35 | * 1. https://github.com/signalapp/WebSocket-Resources
36 | *
37 | */
38 | class Request {
39 | constructor(options) {
40 | this.verb = options.verb || options.type;
41 | this.path = options.path || options.url;
42 | this.headers = options.headers;
43 | this.body = new Uint8Array(options.body || options.data);
44 | this.success = options.success;
45 | this.error = options.error;
46 | this.id = options.id;
47 | if (this.id === undefined) {
48 | const bits = new Uint32Array(2);
49 | crypto.getRandomValues(bits);
50 | this.id = Long.fromBits(bits[0], bits[1], true);
51 | }
52 | if (this.body === undefined) {
53 | this.body = null;
54 | }
55 | }
56 | }
57 |
58 | class IncomingWebSocketRequest {
59 | constructor(options) {
60 | const request = new Request(options);
61 | const { socket } = options;
62 | this.verb = request.verb;
63 | this.path = request.path;
64 | this.body = request.body;
65 | this.headers = request.headers;
66 |
67 | this.respond = (status, message) => {
68 | const wsmessage = WebSocketMessage.create({
69 | type: WebSocketMessage.Type.RESPONSE,
70 | response: { id: request.id, message, status },
71 | });
72 | socket.send(WebSocketMessage.encode(wsmessage).finish());
73 | };
74 | }
75 | }
76 |
77 | const outgoing = {};
78 | class OutgoingWebSocketRequest {
79 | constructor(options, socket) {
80 | const request = new Request(options);
81 | outgoing[request.id] = request;
82 | const message = WebSocketMessage.create({
83 | type: WebSocketMessage.Type.REQUEST,
84 | request: {
85 | verb: request.verb,
86 | path: request.path,
87 | body: request.body,
88 | headers: request.headers,
89 | id: request.id,
90 | },
91 | });
92 | socket.send(WebSocketMessage.encode(message).finish());
93 | }
94 | }
95 |
96 | class KeepAlive {
97 | constructor(websocketResource, opts = {}) {
98 | if (websocketResource instanceof WebSocketResource) {
99 | this.path = opts.path;
100 | if (this.path === undefined) {
101 | this.path = '/';
102 | }
103 | this.disconnect = opts.disconnect;
104 | if (this.disconnect === undefined) {
105 | this.disconnect = true;
106 | }
107 | this.wsr = websocketResource;
108 | } else {
109 | throw new TypeError('KeepAlive expected a WebSocketResource');
110 | }
111 | }
112 |
113 | stop() {
114 | clearTimeout(this.keepAliveTimer);
115 | clearTimeout(this.disconnectTimer);
116 | }
117 |
118 | reset() {
119 | clearTimeout(this.keepAliveTimer);
120 | clearTimeout(this.disconnectTimer);
121 | this.keepAliveTimer = setTimeout(() => {
122 | if (this.disconnect) {
123 | // automatically disconnect if server doesn't ack
124 | this.disconnectTimer = setTimeout(() => {
125 | clearTimeout(this.keepAliveTimer);
126 | this.wsr.close(3001, 'No response to keepalive request');
127 | }, 10000);
128 | } else {
129 | this.reset();
130 | }
131 | debug('Sending a keepalive message');
132 | this.wsr.sendRequest({
133 | verb: 'GET',
134 | path: this.path,
135 | success: this.reset.bind(this),
136 | });
137 | }, 55000);
138 | }
139 | }
140 |
141 | class WebSocketResource extends EventTarget {
142 | constructor(socket, opts = {}) {
143 | super();
144 | let { handleRequest } = opts;
145 | if (typeof handleRequest !== 'function') {
146 | handleRequest = request => request.respond(404, 'Not found');
147 | }
148 | this.sendRequest = options => new OutgoingWebSocketRequest(options, socket);
149 |
150 | // eslint-disable-next-line no-param-reassign
151 | socket.onmessage = socketMessage => {
152 | const blob = socketMessage.data;
153 | const handleArrayBuffer = buffer => {
154 | const message = WebSocketMessage.decode(new Uint8Array(buffer));
155 | if (message.type === WebSocketMessage.Type.REQUEST) {
156 | handleRequest(
157 | new IncomingWebSocketRequest({
158 | verb: message.request.verb,
159 | path: message.request.path,
160 | body: message.request.body,
161 | headers: message.request.headers,
162 | id: message.request.id,
163 | socket,
164 | })
165 | );
166 | } else if (message.type === WebSocketMessage.Type.RESPONSE) {
167 | const { response } = message;
168 | const request = outgoing[response.id];
169 | if (request) {
170 | request.response = response;
171 | let callback = request.error;
172 | if (response.status >= 200 && response.status < 300) {
173 | callback = request.success;
174 | }
175 |
176 | if (typeof callback === 'function') {
177 | callback(response.message, response.status, request);
178 | }
179 | } else {
180 | throw new Error(
181 | `Received response for unknown request ${message.response.id}`
182 | );
183 | }
184 | }
185 | };
186 |
187 | if (blob instanceof ArrayBuffer) {
188 | handleArrayBuffer(blob);
189 | } else {
190 | const reader = new FileReader();
191 | reader.onload = () => handleArrayBuffer(reader.result);
192 | reader.readAsArrayBuffer(blob);
193 | }
194 | };
195 |
196 | if (opts.keepalive) {
197 | this.keepalive = new KeepAlive(this, {
198 | path: opts.keepalive.path,
199 | disconnect: opts.keepalive.disconnect,
200 | });
201 | const resetKeepAliveTimer = this.keepalive.reset.bind(this.keepalive);
202 | socket.addEventListener('open', resetKeepAliveTimer);
203 | socket.addEventListener('message', resetKeepAliveTimer);
204 | socket.addEventListener(
205 | 'close',
206 | this.keepalive.stop.bind(this.keepalive)
207 | );
208 | }
209 |
210 | socket.addEventListener('close', () => {
211 | this.closed = true;
212 | });
213 |
214 | this.close = (code = 3000, reason) => {
215 | if (this.closed) {
216 | return;
217 | }
218 |
219 | debug('WebSocketResource.close()');
220 | if (this.keepalive) {
221 | this.keepalive.stop();
222 | }
223 |
224 | socket.close(code, reason);
225 | // eslint-disable-next-line no-param-reassign
226 | socket.onmessage = null;
227 |
228 | // On linux the socket can wait a long time to emit its close event if we've
229 | // lost the internet connection. On the order of minutes. This speeds that
230 | // process up.
231 | setTimeout(() => {
232 | if (this.closed) {
233 | return;
234 | }
235 | this.closed = true;
236 |
237 | debug('Dispatching our own socket close event');
238 | const ev = new Event('close');
239 | ev.code = code;
240 | ev.reason = reason;
241 | this.dispatchEvent(ev);
242 | }, 5000);
243 | };
244 | }
245 | }
246 |
247 | exports = module.exports = WebSocketResource;
248 |
--------------------------------------------------------------------------------
/src/errors.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | // eslint-disable-next-line func-names
8 | function appendStack(newError, originalError) {
9 | // eslint-disable-next-line no-param-reassign
10 | newError.stack += `\nOriginal stack:\n${originalError.stack}`;
11 | }
12 |
13 | class ReplayableError extends Error {
14 | constructor(options = {}) {
15 | super(options.message);
16 | this.name = options.name || 'ReplayableError';
17 | this.message = options.message;
18 |
19 | // Maintains proper stack trace, where our error was thrown (only available on V8)
20 | // via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
21 | if (Error.captureStackTrace) {
22 | Error.captureStackTrace(this);
23 | }
24 |
25 | this.functionCode = options.functionCode;
26 | }
27 | }
28 |
29 | class IncomingIdentityKeyError extends ReplayableError {
30 | constructor(identifier, message, key) {
31 | const newIdentifier = identifier.split('.')[0];
32 | super({
33 | name: 'IncomingIdentityKeyError',
34 | message: `The identity of ${newIdentifier} has changed.`,
35 | });
36 | this.identityKey = key;
37 | this.identifier = newIdentifier;
38 | }
39 | }
40 |
41 | class OutgoingIdentityKeyError extends ReplayableError {
42 | constructor(identifier, message, timestamp, identityKey) {
43 | const newIdentifier = identifier.split('.')[0];
44 | super({
45 | name: 'OutgoingIdentityKeyError',
46 | message: `The identity of ${newIdentifier} has changed.`,
47 | });
48 | this.identityKey = identityKey;
49 | this.identifier = newIdentifier;
50 | }
51 | }
52 |
53 | class OutgoingMessageError extends ReplayableError {
54 | constructor(identifier, message, timestamp, httpError) {
55 | super({
56 | name: 'OutgoingMessageError',
57 | message: httpError ? httpError.message : 'no http error',
58 | });
59 | [this.identifier ] = identifier.split('.');
60 | if (httpError) {
61 | this.code = httpError.code;
62 | appendStack(this, httpError);
63 | }
64 | }
65 | }
66 |
67 | class SendMessageNetworkError extends ReplayableError {
68 | constructor(identifier, jsonData, httpError) {
69 | super({
70 | name: 'SendMessageNetworkError',
71 | message: httpError.message,
72 | });
73 | this.identifier = identifier;
74 | this.code = httpError.code;
75 | appendStack(this, httpError);
76 | }
77 | }
78 |
79 | class SignedPreKeyRotationError extends ReplayableError {
80 | constructor() {
81 | super({
82 | name: 'SignedPreKeyRotationError',
83 | message: 'Too many signed prekey rotation failures',
84 | });
85 | }
86 | }
87 |
88 | class MessageError extends ReplayableError {
89 | constructor(message, httpError) {
90 | super({
91 | name: 'MessageError',
92 | message: httpError.message,
93 | });
94 | this.code = httpError.code;
95 | appendStack(this, httpError);
96 | }
97 | }
98 |
99 | class UnregisteredUserError extends Error {
100 | constructor(identifier, httpError) {
101 | super(httpError.message);
102 | this.message = httpError.message;
103 | this.name = 'UnregisteredUserError';
104 | // Maintains proper stack trace, where our error was thrown (only available on V8)
105 | // via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
106 | if (Error.captureStackTrace) {
107 | Error.captureStackTrace(this);
108 | }
109 | this.identifier = identifier;
110 | this.code = httpError.code;
111 | appendStack(this, httpError);
112 | }
113 | }
114 |
115 | module.exports.UnregisteredUserError = UnregisteredUserError;
116 | module.exports.SendMessageNetworkError = SendMessageNetworkError;
117 | module.exports.IncomingIdentityKeyError = IncomingIdentityKeyError;
118 | module.exports.OutgoingIdentityKeyError = OutgoingIdentityKeyError;
119 | module.exports.ReplayableError = ReplayableError;
120 | module.exports.OutgoingMessageError = OutgoingMessageError;
121 | module.exports.MessageError = MessageError;
122 | module.exports.SignedPreKeyRotationError = SignedPreKeyRotationError;
123 |
--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const ByteBuffer = require('bytebuffer');
8 | const _ = require('lodash');
9 | const { default: PQueue } = require('p-queue');
10 | const debug = require('debug')('libsignal-service:helpers');
11 | /* eslint-disable no-proto, no-restricted-syntax, guard-for-in */
12 |
13 | /** *******************************
14 | *** Type conversion utilities ***
15 | ******************************** */
16 | // Strings/arrays
17 | // TODO: Throw all this shit in favor of consistent types
18 | // TODO: Namespace
19 | const StaticByteBufferProto = new ByteBuffer().__proto__;
20 | const StaticArrayBufferProto = new ArrayBuffer().__proto__;
21 | const StaticUint8ArrayProto = new Uint8Array().__proto__;
22 | const StaticBufferProto = Buffer.from([]).__proto__;
23 |
24 | function isString(s) {
25 | return typeof s === 'string' || s instanceof String;
26 | }
27 |
28 | function getString(thing) {
29 | if (thing === Object(thing)) {
30 | if (thing.__proto__ === StaticUint8ArrayProto)
31 | return String.fromCharCode.apply(null, thing);
32 | if (thing.__proto__ === StaticArrayBufferProto)
33 | return getString(new Uint8Array(thing));
34 | if (
35 | thing.__proto__ === StaticByteBufferProto
36 | || thing.__proto__ === StaticBufferProto
37 | )
38 | return thing.toString('binary');
39 | }
40 | return thing;
41 | }
42 |
43 | function isStringable(thing) {
44 | return (
45 | typeof thing === 'string'
46 | || typeof thing === 'number'
47 | || typeof thing === 'boolean'
48 | || (thing === Object(thing)
49 | && (thing.__proto__ === StaticArrayBufferProto
50 | || thing.__proto__ === StaticUint8ArrayProto
51 | || thing.__proto__ === StaticByteBufferProto
52 | || thing.__proto__ === StaticBufferProto))
53 | );
54 | }
55 |
56 | function stringToArrayBuffer(str) {
57 | if (typeof str !== 'string') {
58 | throw new Error('Passed non-string to stringToArrayBuffer');
59 | }
60 | const res = new ArrayBuffer(str.length);
61 | const uint = new Uint8Array(res);
62 | for (let i = 0; i < str.length; i+=1) {
63 | uint[i] = str.charCodeAt(i);
64 | }
65 | return res;
66 | }
67 |
68 | function hexStringToArrayBuffer(string) {
69 | return ByteBuffer.wrap(string, 'hex')
70 | .toByteBuffer.wrap(string, 'base64')
71 | .toArrayBuffer();
72 | }
73 |
74 | function base64ToArrayBuffer(string) {
75 | return ByteBuffer.wrap(string, 'base64').toArrayBuffer();
76 | }
77 |
78 | function convertToArrayBuffer(thing) {
79 | if (thing === undefined) {
80 | return undefined;
81 | }
82 | if (thing === Object(thing)) {
83 | if (thing.__proto__ === StaticArrayBufferProto) {
84 | return thing;
85 | }
86 | // TODO: Several more cases here...
87 | }
88 |
89 | if (thing instanceof Array) {
90 | // Assuming Uint16Array from curve25519
91 | const res = new ArrayBuffer(thing.length * 2);
92 | const uint = new Uint16Array(res);
93 | for (let i = 0; i < thing.length; i += 1) {
94 | uint[i] = thing[i];
95 | }
96 | return res;
97 | }
98 |
99 | let str;
100 | if (isStringable(thing)) {
101 | str = getString(thing);
102 | } else if (typeof thing === 'string') {
103 | str = thing;
104 | } else {
105 | throw new Error(
106 | `Tried to convert a non-stringable thing of type ${typeof thing} to an array buffer`
107 | );
108 | }
109 | const res = new ArrayBuffer(str.length);
110 | const uint = new Uint8Array(res);
111 | for (let i = 0; i < str.length; i += 1) {
112 | uint[i] = str.charCodeAt(i);
113 | }
114 | return res;
115 | }
116 |
117 | function equalArrayBuffers(ab1, ab2) {
118 | if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) {
119 | return false;
120 | }
121 | if (ab1.byteLength !== ab2.byteLength) {
122 | return false;
123 | }
124 | let result = 0;
125 | const ta1 = new Uint8Array(ab1);
126 | const ta2 = new Uint8Array(ab2);
127 | for (let i = 0; i < ab1.byteLength; i += 1) {
128 | // eslint-disable-next-line no-bitwise
129 | result |= ta1[i] ^ ta2[i];
130 | }
131 | return result === 0;
132 | }
133 |
134 | // Number formatting utils
135 | function unencodeNumber(number) {
136 | return number.split('.');
137 | }
138 |
139 | function isNumberSane(number) {
140 | return number[0] === '+' && /^[0-9]+$/.test(number.substring(1));
141 | }
142 |
143 | /** ************************
144 | *** JSON'ing Utilities ***
145 | ************************* */
146 | function ensureStringed(thing) {
147 | if (isStringable(thing)) return getString(thing);
148 | if (thing instanceof Array) {
149 | const res = [];
150 | for (let i = 0; i < thing.length; i += 1) res[i] = ensureStringed(thing[i]);
151 | return res;
152 | } if (thing === Object(thing)) {
153 | const res = {};
154 | for (const key in thing) res[key] = ensureStringed(thing[key]);
155 | return res;
156 | } if (thing === null) {
157 | return null;
158 | }
159 | throw new Error(`unsure of how to jsonify object of type ${typeof thing}`);
160 | }
161 |
162 | function jsonThing(thing) {
163 | return JSON.stringify(ensureStringed(thing));
164 | }
165 |
166 | /** *******************
167 | *** UUID Utilities ***
168 | ********************** */
169 |
170 | function isValidGuid(maybeGuid) {
171 | return /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(
172 | maybeGuid
173 | );
174 | }
175 |
176 | // https://stackoverflow.com/a/23299989
177 | function isValidE164(maybeE164) {
178 | return /^\+?[1-9]\d{1,14}$/.test(maybeE164);
179 | }
180 |
181 | function normalizeUuids(obj, paths, context) {
182 | if (!obj) {
183 | return;
184 | }
185 | paths.forEach(path => {
186 | const val = _.get(obj, path);
187 | if (val) {
188 | if (!isValidGuid(val)) {
189 | debug(
190 | `Normalizing invalid uuid: ${val} at path ${path} in context "${context}"`
191 | );
192 | }
193 | _.set(obj, path, val.toLowerCase());
194 | }
195 | });
196 | }
197 |
198 | /** *******************
199 | *** Queue Utilities ***
200 | ********************** */
201 |
202 | let batchers = [];
203 |
204 | async function sleep(ms) {
205 | await new Promise(resolve => setTimeout(resolve, ms));
206 | }
207 |
208 | function createBatcher(options) {
209 | let batcher;
210 | let timeout;
211 | let items = [];
212 | const queue = new PQueue({ concurrency: 1 });
213 |
214 | function _kickBatchOff() {
215 | const itemsRef = items;
216 | items = [];
217 | queue.add(async () => {
218 | await options.processBatch(itemsRef);
219 | });
220 | }
221 |
222 | function add(item) {
223 | items.push(item);
224 |
225 | if (timeout) {
226 | clearTimeout(timeout);
227 | timeout = null;
228 | }
229 |
230 | if (items.length >= options.maxSize) {
231 | _kickBatchOff();
232 | } else {
233 | timeout = setTimeout(() => {
234 | timeout = null;
235 | _kickBatchOff();
236 | }, options.wait);
237 | }
238 | }
239 |
240 | function anyPending() {
241 | return queue.size > 0 || queue.pending > 0 || items.length > 0;
242 | }
243 |
244 | async function onIdle() {
245 | while (anyPending()) {
246 | if (queue.size > 0 || queue.pending > 0) {
247 | // eslint-disable-next-line no-await-in-loop
248 | await queue.onIdle();
249 | }
250 |
251 | if (items.length > 0) {
252 | // eslint-disable-next-line no-await-in-loop
253 | await sleep(options.wait * 2);
254 | }
255 | }
256 | }
257 |
258 | function unregister() {
259 | batchers = batchers.filter(item => item !== batcher);
260 | }
261 |
262 | async function flushAndWait() {
263 | if (timeout) {
264 | clearTimeout(timeout);
265 | timeout = null;
266 | }
267 | if (items.length) {
268 | _kickBatchOff();
269 | }
270 |
271 | return onIdle();
272 | }
273 |
274 | batcher = {
275 | add,
276 | anyPending,
277 | onIdle,
278 | flushAndWait,
279 | unregister,
280 | };
281 |
282 | batchers.push(batcher);
283 |
284 | return batcher;
285 | }
286 |
287 | exports = module.exports = {
288 | isString,
289 | getString,
290 | isStringable,
291 | unencodeNumber,
292 | isNumberSane,
293 | stringToArrayBuffer,
294 | hexStringToArrayBuffer,
295 | base64ToArrayBuffer,
296 | convertToArrayBuffer,
297 | equalArrayBuffers,
298 | ensureStringed,
299 | jsonThing,
300 | isValidGuid,
301 | isValidE164,
302 | normalizeUuids,
303 | createBatcher,
304 | };
305 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const origConfigDir = process.env.NODE_CONFIG_DIR;
8 | process.env.NODE_CONFIG_DIR = `${__dirname }/../config`;
9 | const config = require('config');
10 |
11 | // const CLIENT_VERSION = '1.33.4';
12 | const CLIENT_VERSION = 'v1';
13 |
14 | process.env.NODE_CONFIG_DIR = origConfigDir;
15 | let proxyUrl;
16 | if (config.has('proxyUrl')) {
17 | proxyUrl = config.get('proxyUrl');
18 | }
19 | const cdnUrl0 = config.get('cdn').get('0');
20 | const cdnUrl2 = config.get('cdn').get('2');
21 | const WebAPI = require('./WebAPI.js').initialize({
22 | url: config.get('serverUrl'),
23 | cdnUrlObject: {
24 | '0': cdnUrl0,
25 | '2': cdnUrl2,
26 | },
27 | certificateAuthority: config.get('certificateAuthority'),
28 | contentProxyUrl: config.get('contentProxyUrl'),
29 | proxyUrl,
30 | version: CLIENT_VERSION,
31 | });
32 |
33 | module.exports = {};
34 | exports = module.exports;
35 | exports.AccountManager = require('./AccountManager.js')(WebAPI);
36 | exports.MessageReceiver = require('./MessageReceiver.js')(
37 | WebAPI,
38 | config.get('serverTrustRoot')
39 | );
40 | exports.MessageSender = require('./MessageSender.js')(WebAPI);
41 | exports.ProtocolStore = require('./ProtocolStore.js');
42 | exports.AttachmentHelper = require('./AttachmentHelper.js');
43 | exports.KeyHelper = require('@throneless/libsignal-protocol').KeyHelper;
44 | exports.KeyHelper.getRandomBytes = require('./crypto.js').getRandomBytes;
45 | exports.KeyHelper.generatePassword = require('./crypto.js').generatePassword;
46 | exports.KeyHelper.generateGroupId = require('./crypto.js').generateGroupId;
47 |
--------------------------------------------------------------------------------
/src/libphonenumber-util.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const libphonenumber = require('libphonenumber-js');
8 |
9 | /*
10 | * This file extends the libphonenumber object with a set of phonenumbery
11 | * utility functions. libphonenumber must be included before you call these
12 | * functions, but the order of the files/script-tags doesn't matter.
13 | */
14 |
15 | libphonenumber.util = {
16 | getRegionCodeForNumber(number) {
17 | try {
18 | const parsedNumber = libphonenumber.parse(number);
19 | return libphonenumber.getRegionCodeForNumber(parsedNumber);
20 | } catch (e) {
21 | return 'ZZ';
22 | }
23 | },
24 |
25 | splitCountryCode(number) {
26 | const parsedNumber = libphonenumber.parse(number);
27 | return {
28 | country_code: parsedNumber.values_[1],
29 | national_number: parsedNumber.values_[2],
30 | };
31 | },
32 |
33 | getCountryCode(regionCode) {
34 | const cc = libphonenumber.getCountryCodeForRegion(regionCode);
35 | return cc !== 0 ? cc : '';
36 | },
37 |
38 | parseNumber(number, defaultRegionCode) {
39 | try {
40 | const parsedNumber = libphonenumber.parse(number, defaultRegionCode);
41 |
42 | return {
43 | isValidNumber: libphonenumber.isValidNumber(parsedNumber),
44 | regionCode: libphonenumber.getRegionCodeForNumber(parsedNumber),
45 | countryCode: `${parsedNumber.getCountryCode()}`,
46 | nationalNumber: `${parsedNumber.getNationalNumber()}`,
47 | e164: libphonenumber.format(
48 | parsedNumber,
49 | libphonenumber.PhoneNumberFormat.E164
50 | ),
51 | };
52 | } catch (ex) {
53 | return { error: ex, isValidNumber: false };
54 | }
55 | },
56 |
57 | getAllRegionCodes() {
58 | return {
59 | AD: 'Andorra',
60 | AE: 'United Arab Emirates',
61 | AF: 'Afghanistan',
62 | AG: 'Antigua and Barbuda',
63 | AI: 'Anguilla',
64 | AL: 'Albania',
65 | AM: 'Armenia',
66 | AO: 'Angola',
67 | AR: 'Argentina',
68 | AS: 'AmericanSamoa',
69 | AT: 'Austria',
70 | AU: 'Australia',
71 | AW: 'Aruba',
72 | AX: 'Åland Islands',
73 | AZ: 'Azerbaijan',
74 | BA: 'Bosnia and Herzegovina',
75 | BB: 'Barbados',
76 | BD: 'Bangladesh',
77 | BE: 'Belgium',
78 | BF: 'Burkina Faso',
79 | BG: 'Bulgaria',
80 | BH: 'Bahrain',
81 | BI: 'Burundi',
82 | BJ: 'Benin',
83 | BL: 'Saint Barthélemy',
84 | BM: 'Bermuda',
85 | BN: 'Brunei Darussalam',
86 | BO: 'Bolivia, Plurinational State of',
87 | BR: 'Brazil',
88 | BS: 'Bahamas',
89 | BT: 'Bhutan',
90 | BW: 'Botswana',
91 | BY: 'Belarus',
92 | BZ: 'Belize',
93 | CA: 'Canada',
94 | CC: 'Cocos (Keeling) Islands',
95 | CD: 'Congo, The Democratic Republic of the',
96 | CF: 'Central African Republic',
97 | CG: 'Congo',
98 | CH: 'Switzerland',
99 | CI: "Cote d'Ivoire",
100 | CK: 'Cook Islands',
101 | CL: 'Chile',
102 | CM: 'Cameroon',
103 | CN: 'China',
104 | CO: 'Colombia',
105 | CR: 'Costa Rica',
106 | CU: 'Cuba',
107 | CV: 'Cape Verde',
108 | CX: 'Christmas Island',
109 | CY: 'Cyprus',
110 | CZ: 'Czech Republic',
111 | DE: 'Germany',
112 | DJ: 'Djibouti',
113 | DK: 'Denmark',
114 | DM: 'Dominica',
115 | DO: 'Dominican Republic',
116 | DZ: 'Algeria',
117 | EC: 'Ecuador',
118 | EE: 'Estonia',
119 | EG: 'Egypt',
120 | ER: 'Eritrea',
121 | ES: 'Spain',
122 | ET: 'Ethiopia',
123 | FI: 'Finland',
124 | FJ: 'Fiji',
125 | FK: 'Falkland Islands (Malvinas)',
126 | FM: 'Micronesia, Federated States of',
127 | FO: 'Faroe Islands',
128 | FR: 'France',
129 | GA: 'Gabon',
130 | GB: 'United Kingdom',
131 | GD: 'Grenada',
132 | GE: 'Georgia',
133 | GF: 'French Guiana',
134 | GG: 'Guernsey',
135 | GH: 'Ghana',
136 | GI: 'Gibraltar',
137 | GL: 'Greenland',
138 | GM: 'Gambia',
139 | GN: 'Guinea',
140 | GP: 'Guadeloupe',
141 | GQ: 'Equatorial Guinea',
142 | GR: 'Ελλάδα',
143 | GT: 'Guatemala',
144 | GU: 'Guam',
145 | GW: 'Guinea-Bissau',
146 | GY: 'Guyana',
147 | HK: 'Hong Kong',
148 | HN: 'Honduras',
149 | HR: 'Croatia',
150 | HT: 'Haiti',
151 | HU: 'Magyarország',
152 | ID: 'Indonesia',
153 | IE: 'Ireland',
154 | IL: 'Israel',
155 | IM: 'Isle of Man',
156 | IN: 'India',
157 | IO: 'British Indian Ocean Territory',
158 | IQ: 'Iraq',
159 | IR: 'Iran, Islamic Republic of',
160 | IS: 'Iceland',
161 | IT: 'Italy',
162 | JE: 'Jersey',
163 | JM: 'Jamaica',
164 | JO: 'Jordan',
165 | JP: 'Japan',
166 | KE: 'Kenya',
167 | KG: 'Kyrgyzstan',
168 | KH: 'Cambodia',
169 | KI: 'Kiribati',
170 | KM: 'Comoros',
171 | KN: 'Saint Kitts and Nevis',
172 | KP: "Korea, Democratic People's Republic of",
173 | KR: 'Korea, Republic of',
174 | KW: 'Kuwait',
175 | KY: 'Cayman Islands',
176 | KZ: 'Kazakhstan',
177 | LA: "Lao People's Democratic Republic",
178 | LB: 'Lebanon',
179 | LC: 'Saint Lucia',
180 | LI: 'Liechtenstein',
181 | LK: 'Sri Lanka',
182 | LR: 'Liberia',
183 | LS: 'Lesotho',
184 | LT: 'Lithuania',
185 | LU: 'Luxembourg',
186 | LV: 'Latvia',
187 | LY: 'Libyan Arab Jamahiriya',
188 | MA: 'Morocco',
189 | MC: 'Monaco',
190 | MD: 'Moldova, Republic of',
191 | ME: 'Црна Гора',
192 | MF: 'Saint Martin',
193 | MG: 'Madagascar',
194 | MH: 'Marshall Islands',
195 | MK: 'Macedonia, The Former Yugoslav Republic of',
196 | ML: 'Mali',
197 | MM: 'Myanmar',
198 | MN: 'Mongolia',
199 | MO: 'Macao',
200 | MP: 'Northern Mariana Islands',
201 | MQ: 'Martinique',
202 | MR: 'Mauritania',
203 | MS: 'Montserrat',
204 | MT: 'Malta',
205 | MU: 'Mauritius',
206 | MV: 'Maldives',
207 | MW: 'Malawi',
208 | MX: 'Mexico',
209 | MY: 'Malaysia',
210 | MZ: 'Mozambique',
211 | NA: 'Namibia',
212 | NC: 'New Caledonia',
213 | NE: 'Niger',
214 | NF: 'Norfolk Island',
215 | NG: 'Nigeria',
216 | NI: 'Nicaragua',
217 | NL: 'Netherlands',
218 | NO: 'Norway',
219 | NP: 'Nepal',
220 | NR: 'Nauru',
221 | NU: 'Niue',
222 | NZ: 'New Zealand',
223 | OM: 'Oman',
224 | PA: 'Panama',
225 | PE: 'Peru',
226 | PF: 'French Polynesia',
227 | PG: 'Papua New Guinea',
228 | PH: 'Philippines',
229 | PK: 'Pakistan',
230 | PL: 'Polska',
231 | PM: 'Saint Pierre and Miquelon',
232 | PR: 'Puerto Rico',
233 | PS: 'Palestinian Territory, Occupied',
234 | PT: 'Portugal',
235 | PW: 'Palau',
236 | PY: 'Paraguay',
237 | QA: 'Qatar',
238 | RE: 'Réunion',
239 | RO: 'Romania',
240 | RS: 'Србија',
241 | RU: 'Russia',
242 | RW: 'Rwanda',
243 | SA: 'Saudi Arabia',
244 | SB: 'Solomon Islands',
245 | SC: 'Seychelles',
246 | SD: 'Sudan',
247 | SE: 'Sweden',
248 | SG: 'Singapore',
249 | SH: 'Saint Helena, Ascension and Tristan Da Cunha',
250 | SI: 'Slovenia',
251 | SJ: 'Svalbard and Jan Mayen',
252 | SK: 'Slovakia',
253 | SL: 'Sierra Leone',
254 | SM: 'San Marino',
255 | SN: 'Senegal',
256 | SO: 'Somalia',
257 | SR: 'Suriname',
258 | ST: 'Sao Tome and Principe',
259 | SV: 'El Salvador',
260 | SY: 'Syrian Arab Republic',
261 | SZ: 'Swaziland',
262 | TC: 'Turks and Caicos Islands',
263 | TD: 'Chad',
264 | TG: 'Togo',
265 | TH: 'Thailand',
266 | TJ: 'Tajikistan',
267 | TK: 'Tokelau',
268 | TL: 'Timor-Leste',
269 | TM: 'Turkmenistan',
270 | TN: 'Tunisia',
271 | TO: 'Tonga',
272 | TR: 'Turkey',
273 | TT: 'Trinidad and Tobago',
274 | TV: 'Tuvalu',
275 | TW: 'Taiwan, Province of China',
276 | TZ: 'Tanzania, United Republic of',
277 | UA: 'Ukraine',
278 | UG: 'Uganda',
279 | US: 'United States',
280 | UY: 'Uruguay',
281 | UZ: 'Uzbekistan',
282 | VA: 'Holy See (Vatican City State)',
283 | VC: 'Saint Vincent and the Grenadines',
284 | VE: 'Venezuela',
285 | VG: 'Virgin Islands, British',
286 | VI: 'Virgin Islands, U.S.',
287 | VN: 'Viet Nam',
288 | VU: 'Vanuatu',
289 | WF: 'Wallis and Futuna',
290 | WS: 'Samoa',
291 | YE: 'Yemen',
292 | YT: 'Mayotte',
293 | ZA: 'South Africa',
294 | ZM: 'Zambia',
295 | ZW: 'Zimbabwe',
296 | };
297 | }, // getAllRegionCodes
298 | }; // libphonenumber.util
299 |
300 | exports = module.exports = libphonenumber;
301 |
--------------------------------------------------------------------------------
/src/protobufs.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | const debug = require('debug')('libsignal-service:protobuf');
8 | const ByteBuffer = require('bytebuffer');
9 | const protobufjs = require('protobufjs');
10 | const path = require('path');
11 | const helpers = require('./helpers.js');
12 |
13 | const protobuf = protobufjs.loadSync([
14 | path.join(__dirname, '..', 'protos', 'SubProtocol.proto'),
15 | path.join(__dirname, '..', 'protos', 'DeviceMessages.proto'),
16 | path.join(__dirname, '..', 'protos', 'SignalService.proto'),
17 | path.join(__dirname, '..', 'protos', 'Stickers.proto'),
18 | path.join(__dirname, '..', 'protos', 'DeviceName.proto'),
19 | path.join(__dirname, '..', 'protos', 'UnidentifiedDelivery.proto'),
20 | ]).root;
21 |
22 | // Add contacts_parser.js extended types
23 | const ContactDetails = protobuf.lookupType('signalservice.ContactDetails');
24 | const GroupDetails = protobuf.lookupType('signalservice.GroupDetails');
25 |
26 | class ProtoParser {
27 | constructor(arrayBuffer, proto) {
28 | this.protobuf = proto;
29 | this.buffer = new ByteBuffer();
30 | this.buffer.append(arrayBuffer);
31 | this.buffer.offset = 0;
32 | this.buffer.limit = arrayBuffer.byteLength;
33 | }
34 |
35 | next() {
36 | try {
37 | if (this.buffer.limit === this.buffer.offset) {
38 | return undefined; // eof
39 | }
40 | const len = this.buffer.readVarint32();
41 | const nextBuffer = this.buffer
42 | .slice(this.buffer.offset, this.buffer.offset + len)
43 | .toArrayBuffer();
44 | // TODO: de-dupe ByteBuffer.js includes in libaxo/libts
45 | // then remove this toArrayBuffer call.
46 |
47 | const proto = this.protobuf.decode(new Uint8Array(nextBuffer));
48 | this.buffer.skip(len);
49 |
50 | if (proto.avatar) {
51 | const attachmentLen = proto.avatar.length;
52 | proto.avatar.data = this.buffer
53 | .slice(this.buffer.offset, this.buffer.offset + attachmentLen)
54 | .toArrayBuffer();
55 | this.buffer.skip(attachmentLen);
56 | }
57 |
58 | // if (proto.profileKey) {
59 | // proto.profileKey = proto.profileKey.toArrayBuffer();
60 | // }
61 |
62 | if (proto.uuid) {
63 | helpers.normalizeUuids(
64 | proto,
65 | ['uuid'],
66 | 'ProtoParser::next (proto.uuid)'
67 | );
68 | }
69 |
70 | if (proto.members) {
71 | helpers.normalizeUuids(
72 | proto,
73 | proto.members.map((_member, i) => `members.${i}.uuid`),
74 | 'ProtoParser::next (proto.members)'
75 | );
76 | }
77 |
78 | return proto;
79 | } catch (error) {
80 | debug(
81 | 'ProtoParser.next error:',
82 | error && error.stack ? error.stack : error
83 | );
84 | }
85 |
86 | return null;
87 | }
88 | }
89 |
90 | class GroupBuffer extends ProtoParser {
91 | constructor(arrayBuffer) {
92 | super(arrayBuffer, GroupDetails);
93 | }
94 | }
95 |
96 | class ContactBuffer extends ProtoParser {
97 | constructor(arrayBuffer) {
98 | super(arrayBuffer, ContactDetails);
99 | }
100 | }
101 |
102 | exports = module.exports = protobuf;
103 |
104 | module.exports.GroupBuffer = GroupBuffer;
105 | module.exports.ContactBuffer = ContactBuffer;
106 |
--------------------------------------------------------------------------------
/src/taskWithTimeout.js:
--------------------------------------------------------------------------------
1 | /*
2 | * vim: ts=2:sw=2:expandtab
3 | */
4 |
5 |
6 |
7 | /* eslint-disable more/no-then */
8 | const debug = require('debug')('libsignal-service:TaskWithTimeout');
9 |
10 | // eslint-disable-next-line func-names
11 | exports = module.exports = (task, id, options = {}) => {
12 | const timeout = options.timeout || 1000 * 60 * 2; // two minutes
13 |
14 | const errorForStack = new Error('for stack');
15 | return () =>
16 | new Promise((resolve, reject) => {
17 | let complete = false;
18 | let timer = setTimeout(() => {
19 | if (!complete) {
20 | const message = `${id
21 | || ''} task did not complete in time. Calling stack: ${
22 | errorForStack.stack
23 | }`;
24 |
25 | debug(message);
26 | return reject(new Error(message));
27 | }
28 |
29 | return null;
30 | }, timeout);
31 | const clearTimer = () => {
32 | try {
33 | const localTimer = timer;
34 | if (localTimer) {
35 | timer = null;
36 | clearTimeout(localTimer);
37 | }
38 | } catch (error) {
39 | debug(
40 | id || '',
41 | 'task ran into problem canceling timer. Calling stack:',
42 | errorForStack.stack
43 | );
44 | }
45 | };
46 |
47 | const success = result => {
48 | clearTimer();
49 | complete = true;
50 | return resolve(result);
51 | };
52 | const failure = error => {
53 | clearTimer();
54 | complete = true;
55 | return reject(error);
56 | };
57 |
58 | let promise;
59 | try {
60 | promise = task();
61 | } catch (error) {
62 | clearTimer();
63 | throw error;
64 | }
65 | if (!promise || !promise.then) {
66 | clearTimer();
67 | complete = true;
68 | return resolve(promise);
69 | }
70 |
71 | return promise.then(success, failure);
72 | });
73 | };
74 |
--------------------------------------------------------------------------------
/test/InMemorySignalProtocolStore.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const helpers = require("../src/helpers.js");
3 |
4 | class Storage {
5 | constructor() {
6 | this._init();
7 | }
8 |
9 | _init() {
10 | this._store = {
11 | identityKey: {},
12 | session: {},
13 | "25519KeypreKey": {},
14 | "25519KeysignedKey": {},
15 | unprocessed: {},
16 | groups: {},
17 | configuration: {}
18 | };
19 | }
20 |
21 | _put(namespace, id, data) {
22 | this._store[namespace][id] = helpers.jsonThing(data);
23 | }
24 |
25 | _get(namespace, id) {
26 | const value = this._store[namespace][id];
27 | return JSON.parse(value);
28 | }
29 |
30 | _getAll(namespace) {
31 | const collection = [];
32 | for (let id of Object.keys(this._store[namespace])) {
33 | collection.push(this._get("", id));
34 | }
35 | return collection;
36 | }
37 |
38 | _getAllIds(namespace) {
39 | const collection = [];
40 | for (let id of Object.keys(this._store[namespace])) {
41 | collection.push(id);
42 | }
43 | return collection;
44 | }
45 |
46 | _remove(namespace, id) {
47 | delete this._store[namespace][id];
48 | }
49 |
50 | _removeAll(namespace) {
51 | this._store[namespace] = {};
52 | }
53 |
54 | async getAllIdentityKeys() {
55 | return this._getAll("identityKey");
56 | }
57 |
58 | async createOrUpdateIdentityKey(data) {
59 | const { id } = data;
60 | this._put("identityKey", id, data);
61 | }
62 |
63 | async removeIdentityKeyById(id) {
64 | this._remove("identityKey", id);
65 | }
66 |
67 | async getAllSessions() {
68 | return this._getAll("session");
69 | }
70 |
71 | async createOrUpdateSession(data) {
72 | const { id } = data;
73 | this._put("session", id, data);
74 | }
75 |
76 | async removeSessionById(id) {
77 | this._remove("session", id);
78 | }
79 |
80 | async removeSessionsByNumber(number) {
81 | for (let id of Object.keys(this._store["session"])) {
82 | const session = this._get("session", id);
83 | if (session.number === number) {
84 | this._remove("session", id);
85 | }
86 | }
87 | }
88 |
89 | async removeAllSessions() {
90 | this._removeAll("session");
91 | }
92 |
93 | async getAllPreKeys() {
94 | return this._getAll("25519KeypreKey");
95 | }
96 |
97 | async createOrUpdatePreKey(data) {
98 | const { id } = data;
99 | this._put("25519KeypreKey", id, data);
100 | }
101 | async removePreKeyById(id) {
102 | this._remove("25519KeypreKey", id);
103 | }
104 | async removeAllPreKeys() {
105 | return this._removeAll("25519KeypreKey");
106 | }
107 |
108 | async getAllSignedPreKeys() {
109 | return this._getAll("25519KeysignedKey");
110 | }
111 |
112 | async createOrUpdateSignedPreKey(data) {
113 | const { id } = data;
114 | this._put("25519KeysignedKey", id, data);
115 | }
116 |
117 | async removeSignedPreKeyById(id) {
118 | this._remove("25519KeysignedKey", id);
119 | }
120 | async removeAllSignedPreKeys() {
121 | this._removeAll("25519KeysignedKey");
122 | }
123 |
124 | async getAllUnprocessed() {
125 | return this._getAll("unprocessed");
126 | }
127 |
128 | getUnprocessedCount() {
129 | return Object.keys(this.store["unprocessed"]).length;
130 | }
131 |
132 | getUnprocessedById(id) {
133 | this._get("unprocessed", id);
134 | }
135 |
136 | saveUnprocessed(data) {
137 | const { id } = data;
138 | this._put("unprocessed", id, data);
139 | }
140 |
141 | updateUnprocessedAttempts(id, attempts) {
142 | const data = this._get("unprocessed", id);
143 | data.attempts = attempts;
144 | this._put("unprocessed", id, data);
145 | }
146 |
147 | updateUnprocessedWithData(id, data) {
148 | this._put("unprocessed", id, data);
149 | }
150 |
151 | removeUnprocessed(id) {
152 | this._remove("unprocessed", id);
153 | }
154 |
155 | removeAllUnprocessed() {
156 | this._removeAll("unprocessed");
157 | }
158 |
159 | async createOrUpdateGroup(data) {
160 | const { id } = data;
161 | this._put("groups", id, data);
162 | }
163 |
164 | async getGroupById(id) {
165 | return this._get("groups", id);
166 | }
167 |
168 | async getAllGroups() {
169 | return this._getAll("groups");
170 | }
171 |
172 | async getAllGroupIds() {
173 | return this._getAllIds("groups");
174 | }
175 |
176 | async removeGroupById(id) {
177 | this._remove("groups", id);
178 | }
179 |
180 | async getAllConfiguration() {
181 | return this._getAll("configuration");
182 | }
183 |
184 | async createOrUpdateConfiguration(data) {
185 | const { id } = data;
186 | this._put("configuration", id, data);
187 | }
188 |
189 | async removeConfigurationById(id) {
190 | this._remove("configuration", id);
191 | }
192 |
193 | async removeAllConfiguration() {
194 | this._removeAll("configuration");
195 | }
196 |
197 | async removeAll() {
198 | this._init();
199 | }
200 | }
201 |
202 | exports = module.exports = Storage;
203 |
--------------------------------------------------------------------------------
/test/_test.js:
--------------------------------------------------------------------------------
1 | /*
2 | * global helpers for tests
3 | */
4 | var assert = require("assert");
5 |
6 | module.exports.assertEqualArrayBuffers = function(ab1, ab2) {
7 | assert.deepStrictEqual(new Uint8Array(ab1), new Uint8Array(ab2));
8 | };
9 |
10 | module.exports.hexToArrayBuffer = function(str) {
11 | var ret = new ArrayBuffer(str.length / 2);
12 | var array = new Uint8Array(ret);
13 | for (var i = 0; i < str.length / 2; i++)
14 | array[i] = parseInt(str.substr(i * 2, 2), 16);
15 | return ret;
16 | };
17 |
18 | var KeyHelper = require("../src/index.js").KeyHelper;
19 |
20 | module.exports.generateIdentity = function(store) {
21 | return Promise.all([
22 | KeyHelper.generateIdentityKeyPair(),
23 | KeyHelper.generateRegistrationId()
24 | ]).then(function(result) {
25 | store.put("identityKey", result[0]);
26 | store.put("registrationId", result[1]);
27 | });
28 | };
29 |
30 | module.exports.generatePreKeyBundle = function(
31 | store,
32 | preKeyId,
33 | signedPreKeyId
34 | ) {
35 | return Promise.all([
36 | store.getIdentityKeyPair(),
37 | store.getLocalRegistrationId()
38 | ]).then(function(result) {
39 | var identity = result[0];
40 | var registrationId = result[1];
41 |
42 | return Promise.all([
43 | KeyHelper.generatePreKey(preKeyId),
44 | KeyHelper.generateSignedPreKey(identity, signedPreKeyId)
45 | ]).then(function(keys) {
46 | var preKey = keys[0];
47 | var signedPreKey = keys[1];
48 |
49 | store.storePreKey(preKeyId, preKey.keyPair);
50 | store.storeSignedPreKey(signedPreKeyId, signedPreKey.keyPair);
51 |
52 | return {
53 | identityKey: identity.pubKey,
54 | registrationId: registrationId,
55 | preKey: {
56 | keyId: preKeyId,
57 | publicKey: preKey.keyPair.pubKey
58 | },
59 | signedPreKey: {
60 | keyId: signedPreKeyId,
61 | publicKey: signedPreKey.keyPair.pubKey,
62 | signature: signedPreKey.signature
63 | }
64 | };
65 | });
66 | });
67 | };
68 |
--------------------------------------------------------------------------------
/test/account_manager_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const assert = require("chai").assert;
3 | const api = require("../src/index.js");
4 | const USERNAME = "+15555555";
5 | const PASSWORD = "password";
6 |
7 | describe("AccountManager", () => {
8 | describe("#cleanSignedPreKeys", async () => {
9 | let signedPreKeys;
10 | const identityKey = await api.KeyHelper.generateIdentityKeyPair();
11 | const protocolStore = {
12 | getIdentityKeyPair() {
13 | return identityKey;
14 | },
15 | loadSignedPreKeys() {
16 | return Promise.resolve(signedPreKeys);
17 | }
18 | };
19 | let accountManager = new api.AccountManager(
20 | USERNAME,
21 | PASSWORD,
22 | protocolStore
23 | );
24 | const DAY = 1000 * 60 * 60 * 24;
25 |
26 | describe("encrypted device name", () => {
27 | it("roundtrips", async () => {
28 | const deviceName = "v2.5.0 on Ubunto 20.04";
29 | const encrypted = await accountManager.encryptDeviceName(deviceName);
30 | assert.strictEqual(typeof encrypted, "string");
31 | const decrypted = await accountManager.decryptDeviceName(encrypted);
32 |
33 | assert.strictEqual(decrypted, deviceName);
34 | });
35 |
36 | it("handles null deviceName", async () => {
37 | const encrypted = await accountManager.encryptDeviceName(null);
38 | assert.strictEqual(encrypted, null);
39 | });
40 | });
41 |
42 | it("keeps three confirmed keys even if over a week old", () => {
43 | const now = Date.now();
44 | signedPreKeys = [
45 | {
46 | keyId: 1,
47 | created_at: now - DAY * 21,
48 | confirmed: true
49 | },
50 | {
51 | keyId: 2,
52 | created_at: now - DAY * 14,
53 | confirmed: true
54 | },
55 | {
56 | keyId: 3,
57 | created_at: now - DAY * 18,
58 | confirmed: true
59 | }
60 | ];
61 |
62 | // should be no calls to store.removeSignedPreKey, would cause crash
63 | return accountManager.cleanSignedPreKeys();
64 | });
65 |
66 | it("eliminates confirmed keys over a week old, if more than three", async () => {
67 | const now = Date.now();
68 | signedPreKeys = [
69 | {
70 | keyId: 1,
71 | created_at: now - DAY * 21,
72 | confirmed: true
73 | },
74 | {
75 | keyId: 2,
76 | created_at: now - DAY * 14,
77 | confirmed: true
78 | },
79 | {
80 | keyId: 3,
81 | created_at: now - DAY * 4,
82 | confirmed: true
83 | },
84 | {
85 | keyId: 4,
86 | created_at: now - DAY * 18,
87 | confirmed: true
88 | },
89 | {
90 | keyId: 5,
91 | created_at: now - DAY,
92 | confirmed: true
93 | }
94 | ];
95 |
96 | let count = 0;
97 | protocolStore.removeSignedPreKey = keyId => {
98 | if (keyId !== 1 && keyId !== 4) {
99 | throw new Error(`Wrong keys were eliminated! ${keyId}`);
100 | }
101 |
102 | count += 1;
103 | };
104 |
105 | await accountManager.cleanSignedPreKeys();
106 | assert.strictEqual(count, 2);
107 | });
108 |
109 | it("keeps at least three unconfirmed keys if no confirmed", async () => {
110 | const now = Date.now();
111 | signedPreKeys = [
112 | {
113 | keyId: 1,
114 | created_at: now - DAY * 14
115 | },
116 | {
117 | keyId: 2,
118 | created_at: now - DAY * 21
119 | },
120 | {
121 | keyId: 3,
122 | created_at: now - DAY * 18
123 | },
124 | {
125 | keyId: 4,
126 | created_at: now - DAY
127 | }
128 | ];
129 |
130 | let count = 0;
131 | protocolStore.removeSignedPreKey = keyId => {
132 | if (keyId !== 2) {
133 | throw new Error(`Wrong keys were eliminated! ${keyId}`);
134 | }
135 |
136 | count += 1;
137 | };
138 |
139 | await accountManager.cleanSignedPreKeys();
140 | assert.strictEqual(count, 1);
141 | });
142 |
143 | it("if some confirmed keys, keeps unconfirmed to addd up to three total", async () => {
144 | const now = Date.now();
145 | signedPreKeys = [
146 | {
147 | keyId: 1,
148 | created_at: now - DAY * 21,
149 | confirmed: true
150 | },
151 | {
152 | keyId: 2,
153 | created_at: now - DAY * 14,
154 | confirmed: true
155 | },
156 | {
157 | keyId: 3,
158 | created_at: now - DAY * 12
159 | },
160 | {
161 | keyId: 4,
162 | created_at: now - DAY * 8
163 | }
164 | ];
165 |
166 | let count = 0;
167 | protocolStore.removeSignedPreKey = keyId => {
168 | if (keyId !== 3) {
169 | throw new Error(`Wrong keys were eliminated! ${keyId}`);
170 | }
171 |
172 | count += 1;
173 | };
174 |
175 | await accountManager.cleanSignedPreKeys();
176 | assert.strictEqual(count, 1);
177 | });
178 | });
179 | });
180 |
--------------------------------------------------------------------------------
/test/contacts_parser_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var assert = require("chai").assert;
3 | var ByteBuffer = require("bytebuffer");
4 | var protobuf = require("../src/protobufs.js");
5 | var ContactDetails = protobuf.lookupType("signalservice.ContactDetails");
6 | var GroupDetails = protobuf.lookupType("signalservice.GroupDetails");
7 | var ContactBuffer = protobuf.ContactBuffer;
8 | var GroupBuffer = protobuf.GroupBuffer;
9 | var assertEqualArrayBuffers = require("./_test.js").assertEqualArrayBuffers;
10 | var helpers = require("../src/helpers.js");
11 |
12 | describe("ContactBuffer", function() {
13 | function getTestBuffer() {
14 | var buffer = new ByteBuffer();
15 | var avatarBuffer = new ByteBuffer();
16 | var avatarLen = 255;
17 | for (var i = 0; i < avatarLen; i += 1) {
18 | avatarBuffer.writeUint8(i);
19 | }
20 | avatarBuffer.limit = avatarBuffer.offset;
21 | avatarBuffer.offset = 0;
22 | var contactInfo = ContactDetails.create({
23 | name: "Zero Cool",
24 | number: "+10000000000",
25 | uuid: "7198E1BD-1293-452A-A098-F982FF201902",
26 | avatar: { contentType: "image/jpeg", length: avatarLen }
27 | });
28 | var contactInfoBuffer = ContactDetails.encode(contactInfo).finish();
29 |
30 | for (var i = 0; i < 3; i += 1) {
31 | buffer.writeVarint32(contactInfoBuffer.byteLength);
32 | buffer.append(contactInfoBuffer);
33 | buffer.append(avatarBuffer.clone());
34 | }
35 |
36 | buffer.limit = buffer.offset;
37 | buffer.offset = 0;
38 | return buffer.toArrayBuffer();
39 | }
40 |
41 | it("parses an array buffer of contacts", function() {
42 | var arrayBuffer = getTestBuffer();
43 | var contactBuffer = new ContactBuffer(arrayBuffer);
44 | var contact = contactBuffer.next();
45 | var count = 0;
46 | while (contact !== undefined) {
47 | count += 1;
48 | assert.strictEqual(contact.name, "Zero Cool");
49 | assert.strictEqual(contact.number, "+10000000000");
50 | assert.strictEqual(contact.uuid, "7198e1bd-1293-452a-a098-f982ff201902");
51 | assert.strictEqual(contact.avatar.contentType, "image/jpeg");
52 | assert.strictEqual(contact.avatar.length, 255);
53 | assert.strictEqual(contact.avatar.data.byteLength, 255);
54 | var avatarBytes = new Uint8Array(contact.avatar.data);
55 | for (var j = 0; j < 255; j += 1) {
56 | assert.strictEqual(avatarBytes[j], j);
57 | }
58 | contact = contactBuffer.next();
59 | }
60 | assert.strictEqual(count, 3);
61 | });
62 | });
63 |
64 | describe("GroupBuffer", function() {
65 | function getTestBuffer() {
66 | var buffer = new ByteBuffer();
67 | var avatarBuffer = new ByteBuffer();
68 | var avatarLen = 255;
69 | for (var i = 0; i < avatarLen; i += 1) {
70 | avatarBuffer.writeUint8(i);
71 | }
72 | avatarBuffer.limit = avatarBuffer.offset;
73 | avatarBuffer.offset = 0;
74 | var groupInfo = GroupDetails.create({
75 | id: new Uint8Array([1, 3, 3, 7]),
76 | name: "Hackers",
77 | membersE164: ["cereal", "burn", "phreak", "joey"],
78 | members: [
79 | { uuid: "3EA23646-92E8-4604-8833-6388861971C1", e164: "cereal" },
80 | { uuid: "B8414169-7149-4736-8E3B-477191931301", e164: "burn" },
81 | { uuid: "64C97B95-A782-4E1E-BBCC-5A4ACE8d71f6", e164: "phreak" },
82 | { uuid: "CA334652-C35B-4FDC-9CC7-5F2060C771EE", e164: "joey" }
83 | ],
84 | avatar: { contentType: "image/jpeg", length: avatarLen }
85 | });
86 | var groupInfoBuffer = GroupDetails.encode(groupInfo).finish();
87 |
88 | for (var i = 0; i < 3; i += 1) {
89 | buffer.writeVarint32(groupInfoBuffer.byteLength);
90 | buffer.append(groupInfoBuffer);
91 | buffer.append(avatarBuffer.clone());
92 | }
93 |
94 | buffer.limit = buffer.offset;
95 | buffer.offset = 0;
96 | return buffer.toArrayBuffer();
97 | }
98 |
99 | it("parses an array buffer of groups", function() {
100 | var arrayBuffer = getTestBuffer();
101 | var groupBuffer = new GroupBuffer(arrayBuffer);
102 | var group = groupBuffer.next();
103 | var count = 0;
104 | while (group !== undefined) {
105 | count += 1;
106 | assert.strictEqual(group.name, "Hackers");
107 | assert.sameMembers(group.membersE164, [
108 | "cereal",
109 | "burn",
110 | "phreak",
111 | "joey"
112 | ]);
113 | assert.sameDeepMembers(
114 | group.members.map(({ uuid, e164 }) => ({ uuid, e164 })),
115 | [
116 | { uuid: "3ea23646-92e8-4604-8833-6388861971c1", e164: "cereal" },
117 | { uuid: "b8414169-7149-4736-8e3b-477191931301", e164: "burn" },
118 | { uuid: "64c97b95-a782-4e1e-bbcc-5a4ace8d71f6", e164: "phreak" },
119 | { uuid: "ca334652-c35b-4fdc-9cc7-5f2060c771ee", e164: "joey" }
120 | ]
121 | );
122 | assert.strictEqual(group.avatar.contentType, "image/jpeg");
123 | assert.strictEqual(group.avatar.length, 255);
124 | assert.strictEqual(group.avatar.data.byteLength, 255);
125 | assertEqualArrayBuffers(group.id, new Uint8Array([1, 3, 3, 7]).buffer);
126 | var avatarBytes = new Uint8Array(group.avatar.data);
127 | for (var j = 0; j < 255; j += 1) {
128 | assert.strictEqual(avatarBytes[j], j);
129 | }
130 | group = groupBuffer.next();
131 | }
132 | assert.strictEqual(count, 3);
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
/test/crypto_test.js:
--------------------------------------------------------------------------------
1 | var ByteBuffer = require("bytebuffer");
2 | var crypto = require("../src/crypto.js");
3 | var assert = require("assert");
4 | var assertEqualArrayBuffers = require("./_test.js").assertEqualArrayBuffers;
5 |
6 | describe("encrypting and decrypting profile data", function() {
7 | var NAME_PADDED_LENGTH = 53;
8 | describe("encrypting and decrypting profile names", function() {
9 | it("pads, encrypts, decrypts, and unpads a short string", function() {
10 | var name = "Alice";
11 | var buffer = ByteBuffer.wrap(name).toArrayBuffer();
12 | var key = crypto.getRandomBytes(32);
13 |
14 | return crypto.encryptProfileName(buffer, key).then(encrypted => {
15 | assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
16 | return crypto
17 | .decryptProfileName(encrypted, key)
18 | .then(({ given, family }) => {
19 | assert.strictEqual(family, null);
20 | assert.strictEqual(
21 | ByteBuffer.wrap(given).toString("utf8"),
22 | "Alice"
23 | );
24 | });
25 | });
26 | });
27 | it("handles a given name of the max, 53 characters", () => {
28 | const name = "01234567890123456789012345678901234567890123456789123";
29 | const buffer = ByteBuffer.wrap(name).toArrayBuffer();
30 | const key = crypto.getRandomBytes(32);
31 |
32 | return crypto.encryptProfileName(buffer, key).then(encrypted => {
33 | assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
34 | return crypto
35 | .decryptProfileName(encrypted, key)
36 | .then(({ given, family }) => {
37 | assert.strictEqual(ByteBuffer.wrap(given).toString("utf8"), name);
38 | assert.strictEqual(family, null);
39 | });
40 | });
41 | });
42 | it("handles family/given name of the max, 53 characters", () => {
43 | const name = "01234567890123456789\u000001234567890123456789012345678912";
44 | const buffer = ByteBuffer.wrap(name).toArrayBuffer();
45 | const key = crypto.getRandomBytes(32);
46 |
47 | return crypto.encryptProfileName(buffer, key).then(encrypted => {
48 | assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
49 | return crypto
50 | .decryptProfileName(encrypted, key)
51 | .then(({ given, family }) => {
52 | assert.strictEqual(
53 | ByteBuffer.wrap(given).toString("utf8"),
54 | "01234567890123456789"
55 | );
56 | assert.strictEqual(
57 | ByteBuffer.wrap(family).toString("utf8"),
58 | "01234567890123456789012345678912"
59 | );
60 | });
61 | });
62 | });
63 | it("handles a string with family/given name", () => {
64 | const name = "Alice\0Jones";
65 | const buffer = ByteBuffer.wrap(name).toArrayBuffer();
66 | const key = crypto.getRandomBytes(32);
67 |
68 | return crypto.encryptProfileName(buffer, key).then(encrypted => {
69 | assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
70 | return crypto
71 | .decryptProfileName(encrypted, key)
72 | .then(({ given, family }) => {
73 | assert.strictEqual(
74 | ByteBuffer.wrap(given).toString("utf8"),
75 | "Alice"
76 | );
77 | assert.strictEqual(
78 | ByteBuffer.wrap(family).toString("utf8"),
79 | "Jones"
80 | );
81 | });
82 | });
83 | });
84 |
85 | it("works for empty string", function() {
86 | var name = ByteBuffer.wrap("").toArrayBuffer();
87 | var key = crypto.getRandomBytes(32);
88 |
89 | return crypto.encryptProfileName(name.buffer, key).then(encrypted => {
90 | assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
91 | return crypto
92 | .decryptProfileName(encrypted, key)
93 | .then(({ given, family }) => {
94 | assert.strictEqual(family, null);
95 | assert.strictEqual(given.byteLength, 0);
96 | assert.strictEqual(ByteBuffer.wrap(given).toString("utf8"), "");
97 | });
98 | });
99 | });
100 | });
101 | describe("encrypting and decrypting profile avatars", function() {
102 | it("encrypts and decrypts", function() {
103 | var buffer = ByteBuffer.wrap("This is an avatar").toArrayBuffer();
104 | var key = crypto.getRandomBytes(32);
105 |
106 | return crypto.encryptProfile(buffer, key).then(function(encrypted) {
107 | assert(encrypted.byteLength === buffer.byteLength + 16 + 12);
108 | return crypto.decryptProfile(encrypted, key).then(function(decrypted) {
109 | assertEqualArrayBuffers(buffer, decrypted);
110 | });
111 | });
112 | });
113 | it("throws when decrypting with the wrong key", function() {
114 | var buffer = ByteBuffer.wrap("This is an avatar").toArrayBuffer();
115 | var key = crypto.getRandomBytes(32);
116 | var badKey = crypto.getRandomBytes(32);
117 |
118 | return crypto.encryptProfile(buffer, key).then(function(encrypted) {
119 | assert(encrypted.byteLength === buffer.byteLength + 16 + 12);
120 | return crypto.decryptProfile(encrypted, badKey).catch(function(error) {
121 | assert.strictEqual(error.name, "ProfileDecryptError");
122 | });
123 | });
124 | });
125 | });
126 | });
127 |
--------------------------------------------------------------------------------
/test/generate_keys_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var api = require("../src/index.js");
4 | var storage = require("./InMemorySignalProtocolStore.js");
5 | var protocolStore = new api.ProtocolStore(new storage());
6 | var USERNAME = "+15555555";
7 | var PASSWORD = "password";
8 | var assert = require("chai").assert;
9 | var assertEqualArrayBuffers = require("./_test.js").assertEqualArrayBuffers;
10 |
11 | describe("Key generation", function thisNeeded() {
12 | var count = 10;
13 | this.timeout(count * 2000);
14 |
15 | function validateStoredKeyPair(keyPair) {
16 | /* Ensure the keypair matches the format used internally by libsignal-protocol */
17 | assert.isObject(keyPair, "Stored keyPair is not an object");
18 | assert.instanceOf(keyPair.pubKey, ArrayBuffer);
19 | assert.instanceOf(keyPair.privKey, ArrayBuffer);
20 | assert.strictEqual(keyPair.pubKey.byteLength, 33);
21 | assert.strictEqual(new Uint8Array(keyPair.pubKey)[0], 5);
22 | assert.strictEqual(keyPair.privKey.byteLength, 32);
23 | }
24 | function itStoresPreKey(keyId) {
25 | it("prekey " + keyId + " is valid", function() {
26 | return protocolStore.loadPreKey(keyId).then(function(keyPair) {
27 | validateStoredKeyPair(keyPair);
28 | });
29 | });
30 | }
31 | function itStoresSignedPreKey(keyId) {
32 | it("signed prekey " + keyId + " is valid", function() {
33 | return protocolStore.loadSignedPreKey(keyId).then(function(keyPair) {
34 | validateStoredKeyPair(keyPair);
35 | });
36 | });
37 | }
38 | function validateResultKey(resultKey) {
39 | return protocolStore.loadPreKey(resultKey.keyId).then(function(keyPair) {
40 | assertEqualArrayBuffers(resultKey.publicKey, keyPair.pubKey);
41 | });
42 | }
43 | function validateResultSignedKey(resultSignedKey) {
44 | return protocolStore
45 | .loadSignedPreKey(resultSignedKey.keyId)
46 | .then(function(keyPair) {
47 | assertEqualArrayBuffers(resultSignedKey.publicKey, keyPair.pubKey);
48 | });
49 | }
50 |
51 | before(done => {
52 | protocolStore
53 | .removeAllData()
54 | .then(() => api.KeyHelper.generateIdentityKeyPair())
55 | .then(keyPair => protocolStore.setIdentityKeyPair(keyPair))
56 | .then(() => done());
57 | });
58 |
59 | describe("the first time", function() {
60 | var result;
61 | /* result should have this format
62 | * {
63 | * preKeys: [ { keyId, publicKey }, ... ],
64 | * signedPreKey: { keyId, publicKey, signature },
65 | * identityKey:
66 | * }
67 | */
68 | before(function() {
69 | var accountManager = new api.AccountManager(
70 | USERNAME,
71 | PASSWORD,
72 | protocolStore
73 | );
74 | return accountManager.generateKeys(count).then(function(res) {
75 | result = res;
76 | });
77 | });
78 | for (var i = 1; i <= count; i += 1) {
79 | itStoresPreKey(i);
80 | }
81 | itStoresSignedPreKey(1);
82 |
83 | it("result contains " + count + " preKeys", function() {
84 | assert.isArray(result.preKeys);
85 | assert.lengthOf(result.preKeys, count);
86 | for (var i = 0; i < count; i += 1) {
87 | assert.isObject(result.preKeys[i]);
88 | }
89 | });
90 | it("result contains the correct keyIds", function() {
91 | for (var i = 0; i < count; i += 1) {
92 | assert.strictEqual(result.preKeys[i].keyId, i + 1);
93 | }
94 | });
95 | it("result contains the correct public keys", function() {
96 | return Promise.all(result.preKeys.map(validateResultKey));
97 | });
98 | it("returns a signed prekey", function() {
99 | assert.strictEqual(result.signedPreKey.keyId, 1);
100 | assert.instanceOf(result.signedPreKey.signature, ArrayBuffer);
101 | return validateResultSignedKey(result.signedPreKey);
102 | });
103 | });
104 | describe("the second time", function() {
105 | var result;
106 | before(function() {
107 | var accountManager = new api.AccountManager(
108 | USERNAME,
109 | PASSWORD,
110 | protocolStore
111 | );
112 | return accountManager.generateKeys(count).then(function(res) {
113 | result = res;
114 | });
115 | });
116 | for (var i = 1; i <= 2 * count; i += 1) {
117 | itStoresPreKey(i);
118 | }
119 | itStoresSignedPreKey(1);
120 | itStoresSignedPreKey(2);
121 | it("result contains " + count + " preKeys", function() {
122 | assert.isArray(result.preKeys);
123 | assert.lengthOf(result.preKeys, count);
124 | for (var i = 0; i < count; i += 1) {
125 | assert.isObject(result.preKeys[i]);
126 | }
127 | });
128 | it("result contains the correct keyIds", function() {
129 | for (var i = 1; i <= count; i += 1) {
130 | assert.strictEqual(result.preKeys[i - 1].keyId, i + count);
131 | }
132 | });
133 | it("result contains the correct public keys", function() {
134 | return Promise.all(result.preKeys.map(validateResultKey));
135 | });
136 | it("returns a signed prekey", function() {
137 | assert.strictEqual(result.signedPreKey.keyId, 2);
138 | assert.instanceOf(result.signedPreKey.signature, ArrayBuffer);
139 | return validateResultSignedKey(result.signedPreKey);
140 | });
141 | });
142 | describe("the third time", function() {
143 | var result;
144 | before(function() {
145 | var accountManager = new api.AccountManager(
146 | USERNAME,
147 | PASSWORD,
148 | protocolStore
149 | );
150 | return accountManager.generateKeys(count).then(function(res) {
151 | result = res;
152 | });
153 | });
154 | for (var i = 1; i <= 3 * count; i += 1) {
155 | itStoresPreKey(i);
156 | }
157 | itStoresSignedPreKey(2);
158 | itStoresSignedPreKey(3);
159 | it("result contains " + count + " preKeys", function() {
160 | assert.isArray(result.preKeys);
161 | assert.lengthOf(result.preKeys, count);
162 | for (var i = 0; i < count; i += 1) {
163 | assert.isObject(result.preKeys[i]);
164 | }
165 | });
166 | it("result contains the correct keyIds", function() {
167 | for (var i = 1; i <= count; i += 1) {
168 | assert.strictEqual(result.preKeys[i - 1].keyId, i + 2 * count);
169 | }
170 | });
171 | it("result contains the correct public keys", function() {
172 | return Promise.all(result.preKeys.map(validateResultKey));
173 | });
174 | it("result contains a signed prekey", function() {
175 | assert.strictEqual(result.signedPreKey.keyId, 3);
176 | assert.instanceOf(result.signedPreKey.signature, ArrayBuffer);
177 | return validateResultSignedKey(result.signedPreKey);
178 | });
179 | });
180 | });
181 |
--------------------------------------------------------------------------------
/test/helpers_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var assert = require("chai").assert;
4 | var helpers = require("../src/helpers.js");
5 | var assertEqualArrayBuffers = require("./_test.js").assertEqualArrayBuffers;
6 |
7 | describe("Helpers", function() {
8 | describe("ArrayBuffer->String conversion", function() {
9 | it("works", function() {
10 | var b = new ArrayBuffer(3);
11 | var a = new Uint8Array(b);
12 | a[0] = 0;
13 | a[1] = 255;
14 | a[2] = 128;
15 | assert.equal(helpers.getString(b), "\x00\xff\x80");
16 | });
17 | });
18 |
19 | describe("stringToArrayBuffer", function() {
20 | it("returns ArrayBuffer when passed string", function() {
21 | var anArrayBuffer = new ArrayBuffer(1);
22 | var typedArray = new Uint8Array(anArrayBuffer);
23 | typedArray[0] = "a".charCodeAt(0);
24 | assertEqualArrayBuffers(helpers.stringToArrayBuffer("a"), anArrayBuffer);
25 | });
26 | it("throws an error when passed a non string", function() {
27 | var notStringable = [{}, undefined, null, new ArrayBuffer()];
28 | notStringable.forEach(function(notString) {
29 | assert.throw(function() {
30 | helpers.stringToArrayBuffer(notString);
31 | }, Error);
32 | });
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/test/message_receiver_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const assert = require("chai").assert;
3 | const Blob = require("node-blob");
4 | const ByteBuffer = require("bytebuffer");
5 | const MockServer = require("mock-socket").Server;
6 | const crypto = require("../src/crypto.js");
7 | const storage = require("./InMemorySignalProtocolStore.js");
8 | const ProtocolStore = require("../src/index.js").ProtocolStore;
9 | const MessageReceiver = require("../src/index.js").MessageReceiver;
10 | const WebCrypto = require("node-webcrypto-ossl");
11 | const webcrypto = new WebCrypto();
12 | const protobuf = require("../src/protobufs.js");
13 | const DataMessage = protobuf.lookupType("signalservice.DataMessage");
14 | const Envelope = protobuf.lookupType("signalservice.Envelope");
15 | const WebSocketMessage = protobuf.lookupType("signalservice.WebSocketMessage");
16 |
17 | describe("MessageReceiver", () => {
18 | const protocolStore = new ProtocolStore(new storage());
19 | protocolStore.load();
20 | const number = "+19999999999";
21 | const uuid = "AAAAAAAA-BBBB-4CCC-9DDD-EEEEEEEEEEEE";
22 | const deviceId = 1;
23 | const signalingKey = crypto.getRandomBytes(32 + 20);
24 | before(() => {
25 | protocolStore.setNumberAndDeviceId(number, deviceId, "name");
26 | protocolStore.setUuidAndDeviceId(number, deviceId);
27 | protocolStore.setPassword("password");
28 | protocolStore.setSignalingKey(signalingKey);
29 | });
30 |
31 | describe("connecting", () => {
32 | const attrs = {
33 | type: Envelope.Type.CIPHERTEXT,
34 | source: number,
35 | sourceUuid: uuid,
36 | sourceDevice: deviceId,
37 | timestamp: Date.now()
38 | };
39 | const websocketmessage = WebSocketMessage.create({
40 | type: WebSocketMessage.Type.REQUEST,
41 | request: { verb: "PUT", path: "/messages" }
42 | });
43 |
44 | before(done => {
45 | let signal = Envelope.create(attrs);
46 | signal = Envelope.encode(signal).finish();
47 |
48 | const aesKey = signalingKey.slice(0, 32);
49 | const macKey = signalingKey.slice(32, 32 + 20);
50 |
51 | webcrypto.subtle
52 | .importKey("raw", aesKey, { name: "AES-CBC" }, false, ["encrypt"])
53 | .then(key => {
54 | const iv = crypto.getRandomBytes(16);
55 | webcrypto.subtle
56 | .encrypt({ name: "AES-CBC", iv: new Uint8Array(iv) }, key, signal)
57 | .then(ciphertext => {
58 | webcrypto.subtle
59 | .importKey(
60 | "raw",
61 | macKey,
62 | { name: "HMAC", hash: { name: "SHA-256" } },
63 | false,
64 | ["sign"]
65 | )
66 | .then(innerKey => {
67 | webcrypto.subtle
68 | .sign({ name: "HMAC", hash: "SHA-256" }, innerKey, signal)
69 | .then(mac => {
70 | const version = new Uint8Array([1]);
71 | const message = ByteBuffer.concat([
72 | version,
73 | iv,
74 | ciphertext,
75 | mac
76 | ]);
77 | websocketmessage.request.body = message.toArrayBuffer();
78 | done();
79 | });
80 | });
81 | });
82 | });
83 | });
84 |
85 | it.skip("connects", done => {
86 | const mockServer = new MockServer(
87 | `ws://localhost:8080/v1/websocket/?login=${encodeURIComponent(
88 | uuid
89 | )}.1&password=password`
90 | );
91 |
92 | mockServer.on("connection", server => {
93 | server.send(new Blob([websocketmessage.toArrayBuffer()]));
94 | });
95 |
96 | const messageReceiver = new MessageReceiver(
97 | number.concat("." + deviceId.toString()),
98 | "password",
99 | signalingKey,
100 | protocolStore
101 | );
102 | messageReceiver.addEventListener("textsecure:message", ev => {
103 | const signal = ev.proto;
104 | const keys = Object.keys(attrs);
105 |
106 | for (let i = 0, max = keys.length; i < max; i += 1) {
107 | const key = keys[i];
108 | assert.strictEqual(attrs[key], signal[key]);
109 | }
110 | assert.strictEqual(signal.message.body, "hello");
111 | mockServer.close();
112 | done();
113 | });
114 | });
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/test/protocol_wrapper_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const assert = require("assert");
3 | const storage = require("./InMemorySignalProtocolStore.js");
4 | const api = require("../src/index.js");
5 | const protocolStore = new api.ProtocolStore(new storage());
6 | const libsignal = require("@throneless/libsignal-protocol");
7 |
8 | describe("Protocol Wrapper", function thisNeeded() {
9 | const identifier = "+5558675309";
10 |
11 | this.timeout(5000);
12 |
13 | before(done => {
14 | protocolStore
15 | .removeAllData()
16 | .then(() => api.KeyHelper.generateIdentityKeyPair())
17 | .then(key => protocolStore.saveIdentity(identifier, key.pubKey))
18 | .then(() => {
19 | done();
20 | });
21 | });
22 | describe("processPreKey", function() {
23 | it("rejects if the identity key changes", function() {
24 | var address = new libsignal.SignalProtocolAddress(identifier, 1);
25 | var builder = new libsignal.SessionBuilder(protocolStore, address);
26 | return builder
27 | .processPreKey({
28 | identityKey: api.KeyHelper.getRandomBytes(33),
29 | encodedNumber: address.toString()
30 | })
31 | .then(() => {
32 | throw new Error("Allowed to overwrite identity key");
33 | })
34 | .catch(e => {
35 | assert.strictEqual(e.message, "Identity key changed");
36 | });
37 | });
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/test/storage_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const assert = require("chai").assert;
3 | const assertEqualArrayBuffers = require("./_test.js").assertEqualArrayBuffers;
4 | const crypto = require("../src/crypto.js");
5 | const storage = require("./InMemorySignalProtocolStore.js");
6 | const Signal = require("../src/index.js");
7 |
8 | describe("SignalProtocolStore", () => {
9 | before(() => {
10 | store.removeAllData();
11 | });
12 | const store = new Signal.ProtocolStore(new storage());
13 | store.load();
14 | const identifier = "+5558675309";
15 | const identityKey = {
16 | pubKey: crypto.getRandomBytes(33),
17 | privKey: crypto.getRandomBytes(32)
18 | };
19 | const testKey = {
20 | pubKey: crypto.getRandomBytes(33),
21 | privKey: crypto.getRandomBytes(32)
22 | };
23 | it("retrieves my registration id", async () => {
24 | store.setLocalRegistrationId(1337);
25 |
26 | const reg = await store.getLocalRegistrationId();
27 | assert.strictEqual(reg, 1337);
28 | });
29 | it("retrieves my identity key", async () => {
30 | store.setIdentityKeyPair(identityKey);
31 | const key = await store.getIdentityKeyPair();
32 | assertEqualArrayBuffers(key.pubKey, identityKey.pubKey);
33 | assertEqualArrayBuffers(key.privKey, identityKey.privKey);
34 | });
35 | it("stores identity keys", async () => {
36 | await store.saveIdentity(identifier, testKey.pubKey);
37 | const key = await store.loadIdentityKey(identifier);
38 | assertEqualArrayBuffers(key, testKey.pubKey);
39 | });
40 | it("returns whether a key is trusted", async () => {
41 | const newIdentity = crypto.getRandomBytes(33);
42 | await store.saveIdentity(identifier, testKey.pubKey);
43 |
44 | const trusted = await store.isTrustedIdentity(identifier, newIdentity, 1);
45 | if (trusted) {
46 | throw new Error("Allowed to overwrite identity key");
47 | }
48 | });
49 | it("returns whether a key is untrusted", async () => {
50 | await store.saveIdentity(identifier, testKey.pubKey);
51 | const trusted = await store.isTrustedIdentity(
52 | identifier,
53 | testKey.pubKey,
54 | 1
55 | );
56 |
57 | if (!trusted) {
58 | throw new Error("Allowed to overwrite identity key");
59 | }
60 | });
61 | it("stores prekeys", async () => {
62 | await store.storePreKey(1, testKey);
63 |
64 | const key = await store.loadPreKey(1);
65 | assertEqualArrayBuffers(key.pubKey, testKey.pubKey);
66 | assertEqualArrayBuffers(key.privKey, testKey.privKey);
67 | });
68 | it("deletes prekeys", async () => {
69 | await store.storePreKey(2, testKey);
70 | await store.removePreKey(2, testKey);
71 |
72 | const key = await store.loadPreKey(2);
73 | assert.isUndefined(key);
74 | });
75 | it("stores signed prekeys", async () => {
76 | await store.storeSignedPreKey(3, testKey);
77 |
78 | const key = await store.loadSignedPreKey(3);
79 | assertEqualArrayBuffers(key.pubKey, testKey.pubKey);
80 | assertEqualArrayBuffers(key.privKey, testKey.privKey);
81 | });
82 | it("deletes signed prekeys", async () => {
83 | await store.storeSignedPreKey(4, testKey);
84 | await store.removeSignedPreKey(4, testKey);
85 |
86 | const key = await store.loadSignedPreKey(4);
87 | assert.isUndefined(key);
88 | });
89 | it("stores sessions", async () => {
90 | const testRecord = "an opaque string";
91 | const devices = [1, 2, 3].map(deviceId => [identifier, deviceId].join("."));
92 |
93 | await Promise.all(
94 | devices.map(async encodedNumber => {
95 | await store.storeSession(encodedNumber, testRecord + encodedNumber);
96 | })
97 | );
98 |
99 | const records = await Promise.all(
100 | devices.map(store.loadSession.bind(store))
101 | );
102 |
103 | for (let i = 0, max = records.length; i < max; i += 1) {
104 | assert.strictEqual(records[i], testRecord + devices[i]);
105 | }
106 | });
107 | it("removes all sessions for a number", async () => {
108 | const testRecord = "an opaque string";
109 | const devices = [1, 2, 3].map(deviceId => [identifier, deviceId].join("."));
110 |
111 | await Promise.all(
112 | devices.map(async encodedNumber => {
113 | await store.storeSession(encodedNumber, testRecord + encodedNumber);
114 | })
115 | );
116 |
117 | await store.removeAllSessions(identifier);
118 |
119 | const records = await Promise.all(
120 | devices.map(store.loadSession.bind(store))
121 | );
122 |
123 | for (let i = 0, max = records.length; i < max; i += 1) {
124 | assert.isUndefined(records[i]);
125 | }
126 | });
127 | it("returns deviceIds for a number", async () => {
128 | const testRecord = "an opaque string";
129 | const devices = [1, 2, 3].map(deviceId => [identifier, deviceId].join("."));
130 |
131 | await Promise.all(
132 | devices.map(async encodedNumber => {
133 | await store.storeSession(encodedNumber, testRecord + encodedNumber);
134 | })
135 | );
136 |
137 | const deviceIds = await store.getDeviceIds(identifier);
138 | assert.sameMembers(deviceIds, [1, 2, 3]);
139 | });
140 | it("returns empty array for a number with no device ids", async () => {
141 | const deviceIds = await store.getDeviceIds("foo");
142 | assert.sameMembers(deviceIds, []);
143 | });
144 | });
145 |
--------------------------------------------------------------------------------
/test/task_with_timeout_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var assert = require("chai").assert;
3 | var createTaskWithTimeout = require("../src/taskWithTimeout.js");
4 |
5 | describe("createTaskWithTimeout", () => {
6 | it("resolves when promise resolves", () => {
7 | const task = () => Promise.resolve("hi!");
8 | const taskWithTimeout = createTaskWithTimeout(task);
9 |
10 | return taskWithTimeout().then(result => {
11 | assert.strictEqual(result, "hi!");
12 | });
13 | });
14 | it("flows error from promise back", () => {
15 | const error = new Error("original");
16 | const task = () => Promise.reject(error);
17 | const taskWithTimeout = createTaskWithTimeout(task);
18 |
19 | return taskWithTimeout().catch(flowedError => {
20 | assert.strictEqual(error, flowedError);
21 | });
22 | });
23 | it("rejects if promise takes too long (this one logs error to console)", () => {
24 | let complete = false;
25 | const task = () =>
26 | new Promise(resolve => {
27 | setTimeout(() => {
28 | complete = true;
29 | resolve();
30 | }, 3000);
31 | });
32 | const taskWithTimeout = createTaskWithTimeout(task, this.name, {
33 | timeout: 10
34 | });
35 |
36 | return taskWithTimeout().then(
37 | () => {
38 | throw new Error("it was not supposed to resolve!");
39 | },
40 | () => {
41 | assert.strictEqual(complete, false);
42 | }
43 | );
44 | });
45 | it("resolves if task returns something falsey", () => {
46 | const task = () => {};
47 | const taskWithTimeout = createTaskWithTimeout(task);
48 | return taskWithTimeout();
49 | });
50 | it("resolves if task returns a non-promise", () => {
51 | const task = () => "hi!";
52 | const taskWithTimeout = createTaskWithTimeout(task);
53 | return taskWithTimeout().then(result => {
54 | assert.strictEqual(result, "hi!");
55 | });
56 | });
57 | it("rejects if task throws (and does not log about taking too long)", () => {
58 | const error = new Error("Task is throwing!");
59 | const task = () => {
60 | throw error;
61 | };
62 | const taskWithTimeout = createTaskWithTimeout(task, this.name, {
63 | timeout: 10
64 | });
65 | return taskWithTimeout().then(
66 | () => {
67 | throw new Error("Overall task should reject!");
68 | },
69 | flowedError => {
70 | assert.strictEqual(flowedError, error);
71 | }
72 | );
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/test/websocket-resources_test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const assert = require("chai").assert;
3 | const Blob = require("node-blob");
4 | const MockServer = require("mock-socket").Server;
5 | const WebSocket = require("websocket").w3cwebsocket;
6 | const protobuf = require("../src/protobufs.js");
7 | const WebSocketMessage = protobuf.lookupType("signalservice.WebSocketMessage");
8 | const assertEqualArrayBuffers = require("./_test.js").assertEqualArrayBuffers;
9 | const WebSocketResource = require("../src/WebSocketResource.js");
10 |
11 | describe("WebSocket-Resource", () => {
12 | describe("requests and responses", () => {
13 | it("receives requests and sends responses", done => {
14 | // mock socket
15 | const requestId = "1";
16 | const socket = {
17 | send(data) {
18 | const message = WebSocketMessage.decode(data);
19 | assert.strictEqual(message.type, WebSocketMessage.Type.RESPONSE);
20 | assert.strictEqual(message.response.message, "OK");
21 | assert.strictEqual(message.response.status, 200);
22 | assert.strictEqual(message.response.id.toString(), requestId);
23 | done();
24 | },
25 | addEventListener() {}
26 | };
27 |
28 | // actual test
29 | this.resource = new WebSocketResource(socket, {
30 | handleRequest(request) {
31 | assert.strictEqual(request.verb, "PUT");
32 | assert.strictEqual(request.path, "/some/path");
33 | assertEqualArrayBuffers(request.body, new Uint8Array([1, 2, 3]));
34 | request.respond(200, "OK");
35 | }
36 | });
37 |
38 | // mock socket request
39 | const message = WebSocketMessage.create({
40 | type: WebSocketMessage.Type.REQUEST,
41 | request: {
42 | id: requestId,
43 | verb: "PUT",
44 | path: "/some/path",
45 | body: new Uint8Array([1, 2, 3])
46 | }
47 | });
48 | socket.onmessage({
49 | data: new Uint8Array(WebSocketMessage.encode(message).finish()).buffer
50 | });
51 | });
52 |
53 | it("sends requests and receives responses", done => {
54 | // mock socket and request handler
55 | let requestId;
56 | const socket = {
57 | send(data) {
58 | const message = WebSocketMessage.decode(data);
59 | assert.strictEqual(message.type, WebSocketMessage.Type.REQUEST);
60 | assert.strictEqual(message.request.verb, "PUT");
61 | assert.strictEqual(message.request.path, "/some/path");
62 | assertEqualArrayBuffers(
63 | message.request.body,
64 | new Uint8Array([1, 2, 3])
65 | );
66 | requestId = message.request.id;
67 | },
68 | addEventListener() {}
69 | };
70 |
71 | // actual test
72 | const resource = new WebSocketResource(socket);
73 | resource.sendRequest({
74 | verb: "PUT",
75 | path: "/some/path",
76 | body: new Uint8Array([1, 2, 3]),
77 | error: done,
78 | success(message, status) {
79 | assert.strictEqual(message, "OK");
80 | assert.strictEqual(status, 200);
81 | done();
82 | }
83 | });
84 |
85 | // mock socket response
86 | const message = WebSocketMessage.create({
87 | type: WebSocketMessage.Type.RESPONSE,
88 | response: { id: requestId, message: "OK", status: 200 }
89 | });
90 | socket.onmessage({
91 | data: new Uint8Array(WebSocketMessage.encode(message).finish()).buffer
92 | });
93 | });
94 | });
95 |
96 | describe("close", () => {
97 | it.skip("closes the connection", done => {
98 | const mockServer = new MockServer("ws://localhost:8081");
99 | mockServer.on("connection", server => {
100 | server.on("close", done);
101 | });
102 | const resource = new WebSocketResource(
103 | new WebSocket("ws://localhost:8081")
104 | );
105 | resource.close();
106 | });
107 | });
108 |
109 | describe.skip("with a keepalive config", function thisNeeded() {
110 | this.timeout(60000);
111 | it("sends keepalives once a minute", done => {
112 | const mockServer = new MockServer("ws://localhost:8081");
113 | mockServer.on("connection", server => {
114 | server.on("message", data => {
115 | const message = WebSocketMessage.decode(data);
116 | assert.strictEqual(message.type, WebSocketMessage.Type.REQUEST);
117 | assert.strictEqual(message.request.verb, "GET");
118 | assert.strictEqual(message.request.path, "/v1/keepalive");
119 | server.close();
120 | done();
121 | });
122 | });
123 | this.resource = new WebSocketResource(
124 | new WebSocket("ws://localhost:8081"),
125 | {
126 | keepalive: { path: "/v1/keepalive" }
127 | }
128 | );
129 | });
130 |
131 | it("uses / as a default path", done => {
132 | const mockServer = new MockServer("ws://localhost:8081");
133 | mockServer.on("connection", server => {
134 | server.on("message", data => {
135 | const message = WebSocketMessage.decode(data);
136 | assert.strictEqual(message.type, WebSocketMessage.Type.REQUEST);
137 | assert.strictEqual(message.request.verb, "GET");
138 | assert.strictEqual(message.request.path, "/");
139 | server.close();
140 | done();
141 | });
142 | });
143 | this.resource = new WebSocketResource(
144 | new WebSocket("ws://localhost:8081"),
145 | {
146 | keepalive: true
147 | }
148 | );
149 | });
150 |
151 | it("optionally disconnects if no response", function thisNeeded1(done) {
152 | this.timeout(65000);
153 | const mockServer = new MockServer("ws://localhost:8081");
154 | const socket = new WebSocket("ws://localhost:8081");
155 | mockServer.on("connection", server => {
156 | server.on("close", done);
157 | });
158 | this.resource = new WebSocketResource(socket, { keepalive: true });
159 | });
160 |
161 | it("allows resetting the keepalive timer", function thisNeeded2(done) {
162 | this.timeout(65000);
163 | const mockServer = new MockServer("ws://localhost:8081");
164 | const socket = new WebSocket("ws://localhost:8081");
165 | const startTime = Date.now();
166 | mockServer.on("connection", server => {
167 | server.on("message", data => {
168 | const message = WebSocketMessage.decode(data);
169 | assert.strictEqual(message.type, WebSocketMessage.Type.REQUEST);
170 | assert.strictEqual(message.request.verb, "GET");
171 | assert.strictEqual(message.request.path, "/");
172 | assert(
173 | Date.now() > startTime + 60000,
174 | "keepalive time should be longer than a minute"
175 | );
176 | server.close();
177 | done();
178 | });
179 | });
180 | const resource = new WebSocketResource(socket, { keepalive: true });
181 | setTimeout(() => {
182 | resource.resetKeepAliveTimer();
183 | }, 5000);
184 | });
185 | });
186 | });
187 |
--------------------------------------------------------------------------------