├── .gitignore ├── package.json ├── injectables └── send-message.js ├── README.md └── signal-hook.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signal-bot", 3 | "version": "1.0.0", 4 | "description": "A Signal bot that utilizes the Chrome DevTools protocol to hook the Signal Electron Desktop app for automation.", 5 | "main": "signal-hook.js", 6 | "dependencies": { 7 | "chrome-remote-interface": "^0.28.1" 8 | }, 9 | "devDependencies": {}, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "mandatory (Matthew Bryant)", 14 | "license": "MIT" 15 | } 16 | -------------------------------------------------------------------------------- /injectables/send-message.js: -------------------------------------------------------------------------------- 1 | (async function() { 2 | var SEALED_SENDER = { 3 | UNKNOWN: 0, 4 | ENABLED: 1, 5 | DISABLED: 2, 6 | UNRESTRICTED: 3, 7 | }; 8 | 9 | // Number we're sending the message to. 10 | var id = JSON.parse('{{RECEIVER_PHONE_NUMBER}}'); 11 | 12 | // Pull our own number 13 | var ourNumber = textsecure.storage.user.getNumber(); 14 | 15 | async function getSendOptions(options = {}) { 16 | const senderCertificate = storage.get('senderCertificate'); 17 | const numberInfo = await getNumberInfo(options); 18 | 19 | return { 20 | senderCertificate, 21 | numberInfo, 22 | }; 23 | }; 24 | 25 | async function getNumberInfo(options = {}) { 26 | const { 27 | syncMessage, 28 | disableMeCheck 29 | } = options; 30 | 31 | if (!ourNumber) { 32 | return null; 33 | } 34 | 35 | // START: this code has an Expiration date of ~2018/11/21 36 | // We don't want to enable unidentified delivery for send unless it is 37 | // also enabled for our own account. 38 | const me = ConversationController.getOrCreate(ourNumber, 'private'); 39 | if (!disableMeCheck && 40 | me.get('sealedSender') === SEALED_SENDER.DISABLED 41 | ) { 42 | return null; 43 | } 44 | // END 45 | 46 | // Get the access Key 47 | const c = await ConversationController.getOrCreateAndWait(id, 'private'); 48 | await c.deriveAccessKeyIfNeeded(); 49 | const numberInfo = c.getNumberInfo({ 50 | disableMeCheck: true 51 | }) || {}; 52 | const getInfo = numberInfo[c.id] || {}; 53 | 54 | const accessKey = getInfo.accessKey; 55 | //const sealedSender = this.get('sealedSender'); 56 | const sealedSender = 1; 57 | 58 | // We never send sync messages as sealed sender 59 | if (syncMessage && id === ourNumber) { 60 | return null; 61 | } 62 | 63 | // If we've never fetched user's profile, we default to what we have 64 | if (sealedSender === SEALED_SENDER.UNKNOWN) { 65 | return { 66 | [id]: { 67 | accessKey: accessKey || 68 | window.Signal.Crypto.arrayBufferToBase64( 69 | window.Signal.Crypto.getRandomBytes(16) 70 | ), 71 | }, 72 | }; 73 | } 74 | 75 | if (sealedSender === SEALED_SENDER.DISABLED) { 76 | return null; 77 | } 78 | 79 | return { 80 | [id]: { 81 | accessKey: accessKey && sealedSender === SEALED_SENDER.ENABLED ? 82 | accessKey : 83 | window.Signal.Crypto.arrayBufferToBase64( 84 | window.Signal.Crypto.getRandomBytes(16) 85 | ), 86 | }, 87 | }; 88 | }; 89 | 90 | const options = await getSendOptions(); 91 | 92 | return textsecure.messaging.sendMessageToNumber( 93 | id, 94 | JSON.parse('{{MESSAGE_BODY}}'), 95 | [], 96 | null, 97 | [], 98 | undefined, 99 | null, 100 | Math.floor(Date.now()), 101 | JSON.parse('{{DELETE_TTL}}'), 102 | storage.get('profileKey'), 103 | options 104 | ); 105 | })(); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signal Messenger Bot by [@IAmMandatory](https://twitter.com/IAmMandatory) 2 | ## _Signal Desktop Required, Not An Official Signal Client_ 3 | 4 | ## What is it? 5 | 6 | This is a Node script to automate the [Signal Desktop client](https://signal.org/download/). You can use it to write Signal bots for secure automated messaging (no more unsafe SMS!). 7 | 8 | It's also meant to serve as a commented reference for how to hook and orchestrate Electron & web apps with the Chrome DevTools Protocol (see [`Why use the Chrome DevTools Protocol?`](#why-use-the-chrome-devtools-protocol) below). 9 | 10 | ## How do I use it? 11 | 12 | * Install the [Signal Desktop client](https://signal.org/download/) if you don't already have it. 13 | * [Install the latest Node version](https://nodejs.org/en/download/) 14 | * Clone this repo `git clone https://github.com/mandatoryprogrammer/signal-bot` 15 | * Install the dependencies `cd signal-bot && npm install` 16 | * Start your Signal Desktop app with the `--remote-debugging-port` flag, like so: 17 | 18 | ``` 19 | # Note you must set it to port 9222, make sure you have no other debugging sessions 20 | # set up so there's no conflictions. 21 | $ ./Signal --remote-debugging-port=9222 22 | 23 | # For OS X this is at /Applications/Signal.app/Contents/MacOS/Signal your OS may vary. 24 | ``` 25 | * Modify `signal-hook.js` to do whatever you want. 26 | * Run this script: `node signal-hook.js` and it will hook into Signal Desktop and automate it! 27 | 28 | ## Code Examples 29 | 30 | Send a Signal message to another Signal user with a custom [Disappearing Message](https://support.signal.org/hc/en-us/articles/360007320771-Set-and-manage-disappearing-messages) time: 31 | 32 | ```javascript 33 | async function main(client) { 34 | // This is an example of sending a message to someone 35 | // using the Signal app. 36 | await send_message( 37 | client, // The Chrome debugging session client 38 | "+12345678910", // The Signal user's phone number you want to message (must be in E.164 format) 39 | "Your custom message here.", // Body of the message you want to send 40 | ( 60 * 60 * 24 ) // The number of seconds before the message should be deleted (Disappearing Message time) 41 | ); 42 | } 43 | ``` 44 | 45 | Process messages sent to you via Signal: 46 | 47 | ```javascript 48 | /* 49 | This function is called when a message is 50 | received in the Signal desktop app. 51 | 52 | Fields you'll probably want: 53 | 54 | message.timestamp: Timestamp of message in microseconds (e.g: 1581845656358) 55 | message.id: Unique UUID for the message 56 | message.source: Phone number of the user who sent the message (e.g.: +12345678910) 57 | message.expireTimer: Number of seconds the message should be kept for before being 58 | deleted (e.g. the Disappearing Messages time). 59 | message.body: The body of the message (e.g: Hello!) 60 | message.sent_at: Timestamp of when the message was sent in microseconds 61 | (e.g: 1581845656358) 62 | message.received_at: Timestamp of when the message was received in microseconds 63 | (e.g: 1581845656358) 64 | */ 65 | async function message_received(client, message) { 66 | // Write some code here to do something with the message. 67 | console.log(`Message from '${message.source}' (expiration ${message.expireTimer} second(s)) received: ${message.body}`); 68 | } 69 | ``` 70 | 71 | ## How does this work? 72 | 73 | This is a Node script which utilizes [Chrome Remote Debugging](https://blog.chromium.org/2011/05/remote-debugging-with-chrome-developer.html)/the [DevTools protocol](https://chromedevtools.github.io/devtools-protocol/) to hook into the Signal Desktop app and automate its functionality. This is possible because the Signal Desktop client is an Electron app, so it supports the `--remote-debugging-port` flag. This allows us to use the Chrome DevTools protocol to hook into the app's functionality and inject JavaScript, set breakpoints, etc. 74 | 75 | ## Why use the Chrome DevTools Protocol? 76 | 77 | This project is meant to be an example of how to hook and orchestrate an Electron app, web app, or similar using the Chrome DevTools Protocol. This is quite useful because it lets you hook and call existing functions in apps to get what you want done without having to reverse engineer protocols, APIs, and the rest of the nitty-gritty low level stuff. It is also much more scalable if you have an app that changes often to hook it at a high level. Even if the developers completely change the underlying protocols and specs, you can easily adapt by just calling the higher-level functions which already implement it. 78 | 79 | In this case study of Signal Desktop for example, I didn't have to learn anything about the underlying cryptography or write a (likely insecure) client implementing it. Instead I just hook into the already-working Signal Desktop app and call its existing functions to send messages and hook inbound messages. 80 | 81 | **Note**: The downside is that the Chrome DevTools Protocol is extremely grainy and complex. This code is meant to serve as an example of how to tame some of it. Sadly there's not a lot of good reference code for Chrome DevTools automation outside of [Puppeteer](https://github.com/puppeteer/puppeteer), which was another reason I wanted to open source this example. 82 | 83 | -------------------------------------------------------------------------------- /signal-hook.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const util = require('util'); 3 | const readFile = util.promisify(fs.readFile); 4 | const CDP = require('chrome-remote-interface'); 5 | 6 | function wait(ms) { 7 | return new Promise((resolve) => { 8 | setTimeout(resolve, ms); 9 | }); 10 | } 11 | 12 | /* 13 | This establishes a DevTools remote debugging session with the Electron app. 14 | 15 | Once this is established successfully, the main() function will be called. 16 | 17 | Note you must have already run the Signal desktop app with the appropriate 18 | flag set before running this script, for example: 19 | ./Signal --remote-debugging-port=9222 20 | */ 21 | CDP(async(client) => { 22 | main(client); 23 | }).on('error', (err) => { 24 | console.error('Fatal error while trying to hook into Signal desktop:') 25 | console.error(err); 26 | }); 27 | 28 | /* 29 | This sends a message to another Signal user. 30 | 31 | Parameters: 32 | 33 | @client: This is the Chrome CDP client session (passed to main as `client`). 34 | @to_number: This is the number of the signal user you want to send the message to. 35 | Note: This MUST be formatted in the E.164 format, e.g: +11234567890 36 | @message_body: The textual body of the message. e.g: "Hi" 37 | @delete_ttl: An integer number of second(s) for the message to exist until it is 38 | automatically deleted. Note that you don't have to use one of the 39 | in-app numbers (e.g. 1 minute, 1 day). You can use any arbitrary 40 | number of seconds which the app will object (although the GUI may 41 | round it off, however it will still be respected accurately). 42 | */ 43 | async function send_message(client, to_number, message_body, delete_ttl) { 44 | const { 45 | Runtime 46 | } = client; 47 | 48 | console.log(`Sending message '${message_body.trim()}' to '${to_number}'...`); 49 | 50 | // Get payload off filesystem 51 | const payload_template = await readFile('injectables/send-message.js'); 52 | var payload = payload_template.toString(); 53 | 54 | // String replace our injected JavaScript to include 55 | // the appropriate parameters for our message send. 56 | payload = payload.replace( 57 | '{{RECEIVER_PHONE_NUMBER}}', 58 | JSON.stringify(to_number) 59 | ); 60 | payload = payload.replace( 61 | '{{MESSAGE_BODY}}', 62 | JSON.stringify(message_body) 63 | ); 64 | payload = payload.replace( 65 | '{{DELETE_TTL}}', 66 | JSON.stringify(delete_ttl) 67 | ); 68 | 69 | //payload = `window.location.toString()`; 70 | 71 | return Runtime.evaluate({ 72 | expression: payload, 73 | awaitPromise: true, 74 | returnByValue: true 75 | }); 76 | } 77 | 78 | async function sanity_check(client) { 79 | const { Runtime } = client; 80 | const result = await Runtime.evaluate({ 81 | expression: 'window.location.toString()', 82 | awaitPromise: true, 83 | returnByValue: true 84 | }); 85 | console.log(result); 86 | } 87 | 88 | /* 89 | This is where the main bot logic lives, customize it at will. 90 | */ 91 | async function main(client) { 92 | // This is an example of sending a message to someone 93 | // using the Signal app. 94 | await send_message( 95 | client, // The Chrome debugging session client 96 | "+REPLACE_ME_WITH_TO_NUMBER", // The Signal user's phone number you want to message (must be in E.164 format) 97 | "Your custom message here.", // Body of the message you want to send 98 | ( 60 * 60 * 24 ) // The number of seconds before the message should be deleted (Disappearing Message time) 99 | ); 100 | 101 | // This hooks incoming Signal messages and 102 | // calls the callback with the contents and 103 | // metadata of the messages. 104 | await hook_incoming_messages( 105 | client, 106 | message_received 107 | ); 108 | } 109 | 110 | /* 111 | This function is called when a message is 112 | received in the Signal desktop app. 113 | 114 | Fields you'll probably want: 115 | 116 | message.timestamp: Timestamp of message in microseconds (e.g: 1581845656358) 117 | message.id: Unique UUID for the message 118 | message.source: Phone number of the user who sent the message (e.g.: +12345678910) 119 | message.expireTimer: Number of seconds the message should be kept for before being 120 | deleted (e.g. the Disappearing Messages time). 121 | message.body: The body of the message (e.g: Hello!) 122 | message.sent_at: Timestamp of when the message was sent in microseconds 123 | (e.g: 1581845656358) 124 | message.received_at: Timestamp of when the message was received in microseconds 125 | (e.g: 1581845656358) 126 | */ 127 | async function message_received(client, message) { 128 | // Write some code here to do something with the message. 129 | console.log(`Message from '${message.source}' (expiration ${message.expireTimer} second(s)) received: ${message.body}`); 130 | } 131 | 132 | async function hook_incoming_messages(client, callback) { 133 | const { Debugger } = client; 134 | 135 | console.log('Setting debugging breakpoint to hook inbound Signal messages...'); 136 | const breakpoint_response = await Debugger.setBreakpointByUrl({ 137 | // TODO: This is brittle, need to write a function to find the lineNo 138 | // and automatically fill it in so it can be somewhat stable in between 139 | // Signal versions. 140 | lineNumber: 2220, 141 | urlRegex: 'file:\/\/\/[a-zA-Z\.\/]+conversations\.js$' 142 | }); 143 | 144 | Debugger.paused(async (params) => { 145 | // This is just the first call frame on the debugger stack 146 | const callFrameTargetId = params.callFrames[0].callFrameId; 147 | const evalResult = await Debugger.evaluateOnCallFrame({ 148 | "callFrameId": callFrameTargetId, 149 | "expression": "messageJSON", 150 | "generatePreview": true, 151 | "includeCommandLineAPI": true, 152 | "throwOnSideEffect": true, 153 | "timeout": 500 154 | }); 155 | const results = await convertAllChromeSerializedIntoVars( 156 | client, 157 | [evalResult.result] 158 | ); 159 | const result = results[0]; 160 | 161 | try { 162 | await callback( 163 | client, 164 | result 165 | ); 166 | } catch ( e ) { 167 | Debugger.resume(); 168 | throw e; 169 | return 170 | } 171 | 172 | Debugger.resume(); 173 | }); 174 | await Debugger.enable(); 175 | } 176 | 177 | // These are from a separate personal project, but this is for recursively 178 | // pulling/deserializing objects and other variables via the Chrome Devtool 179 | // protocol. This is such a PITA to do, but anyways this will give you a sane 180 | // data structure back (takes an array, if you need a single item just set 181 | // inputItems to be an array with one item, e.g: [inputItem]). 182 | async function convertAllChromeSerializedIntoVars(client, inputItems) { 183 | const max_depth = 3; 184 | return Promise.all(inputItems.map(inputItem => { 185 | return _convertChromeSerializedIntoVars( 186 | client, 187 | inputItem, 188 | max_depth 189 | ); 190 | })); 191 | } 192 | 193 | function isUnserializableItem(inputItem) { 194 | const valid_types = [ 195 | 'object', 196 | 'array', 197 | 'string', 198 | 'function', 199 | 'undefined', 200 | 'number', 201 | 'boolean' 202 | ]; 203 | 204 | // Is it an object? 205 | if(!(typeof(inputItem) === 'object') ) { 206 | return false; 207 | } 208 | 209 | // Is it null (null is an object)? 210 | if(inputItem === null) { 211 | return false; 212 | } 213 | 214 | // Does it have .type? Is the .type a valid value? 215 | if(!( 'type' in inputItem) || !valid_types.includes(inputItem.type) ) { 216 | return false; 217 | } 218 | 219 | return true; 220 | } 221 | 222 | async function _convertChromeSerializedIntoVars(client, inputItem, remaining_depth) { 223 | // Quit out if we're too deep 224 | if(remaining_depth <= 0) { 225 | return inputItem; 226 | } 227 | remaining_depth--; 228 | 229 | // If the item we've been passed is not unserializable 230 | // we just return it in it's immediate format. 231 | if(!isUnserializableItem(inputItem)) { 232 | return inputItem; 233 | } 234 | 235 | const deserializedItem = await convertChromeSerializedIntoVars( 236 | client, 237 | inputItem, 238 | remaining_depth 239 | ); 240 | 241 | // Check if the immediately deserializedItem is yet another item 242 | // that needs to be deserialized. 243 | if(isUnserializableItem(deserializedItem)) { 244 | return _convertChromeSerializedIntoVars( 245 | client, 246 | inputItem 247 | ); 248 | } 249 | 250 | // Check if there's still some serialized shit in it. If so, we'll 251 | // have to recurse further to resolve those 252 | if(typeof(deserializedItem) === 'object' && deserializedItem !== null) { 253 | var newObject = {}; 254 | const objectKeys = Object.keys(deserializedItem); 255 | await Promise.all(objectKeys.map(async objectKey => { 256 | newObject[objectKey] = await _convertChromeSerializedIntoVars( 257 | client, 258 | deserializedItem[objectKey], 259 | remaining_depth 260 | ); 261 | })); 262 | return newObject; 263 | } 264 | 265 | return deserializedItem; 266 | } 267 | 268 | async function convertChromeSerializedIntoVars(client, inputItem) { 269 | // JavaScript is war against developers 270 | if( inputItem.type === 'object' && inputItem.subtype === 'null' ) { 271 | return null; 272 | } else if(inputItem.type === 'object') { 273 | return getObject(client, inputItem.objectId ); 274 | } else if ( inputItem.type === 'array') { 275 | return getArray( client, inputItem.objectId ); 276 | } else if ( inputItem.type === 'string' ) { 277 | return inputItem.value; 278 | } else if( inputItem.type === 'function' ) { 279 | return inputItem.description; 280 | } else if ( inputItem.type === 'undefined' ) { 281 | return undefined; 282 | } else if ( inputItem.type === 'number' ) { 283 | return inputItem.value; 284 | } else if ( inputItem.type === 'boolean' ) { 285 | return inputItem.value; 286 | } 287 | 288 | return inputItem; 289 | } 290 | 291 | async function getArray(client, objectId) { 292 | const evalObject = await client.Runtime.getProperties({ 293 | objectId: objectId 294 | }); 295 | 296 | const objectNumericKeys = evalObject.result.filter(objectItem => { 297 | return !isNaN(objectItem.name); 298 | }); 299 | 300 | return objectNumericKeys.map(objectItem => { 301 | return objectItem.value; 302 | }); 303 | } 304 | 305 | async function getObject(client, objectId) { 306 | const objectValues = await client.Runtime.getProperties({ 307 | objectId: objectId 308 | }); 309 | 310 | var return_object = {}; 311 | 312 | objectValues.result.map(objectValue => { 313 | return_object[ objectValue.name ] = objectValue.value; 314 | }); 315 | 316 | return return_object; 317 | } --------------------------------------------------------------------------------