## File: README.md # signal-cli signal-cli is a commandline interface for the [Signal messenger](https://signal.org/). It supports registering, verifying, sending and receiving messages. signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java), extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service). For registering you need a phone number where you can receive SMS or incoming calls. signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc)) and D-BUS interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)). For the JSON-RPC interface there's also a simple [example client](https://github.com/AsamK/signal-cli/tree/master/client), written in Rust. signal-cli needs to be kept up-to-date to keep up with Signal-Server changes. The official Signal clients expire after three months and then the Signal-Server can make incompatible changes. So signal-cli releases older than three months may not work correctly. ## Installation You can [build signal-cli](#building) yourself or use the [provided binary files](https://github.com/AsamK/signal-cli/releases/latest), which should work on Linux, macOS and Windows. There's also a [docker image and some Linux packages](https://github.com/AsamK/signal-cli/wiki/Binary-distributions) provided by the community. System requirements: - at least Java Runtime Environment (JRE) 25 - native library: libsignal-client The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other systems/architectures see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal) ### Install system-wide on Linux [ JVM build ] See [latest version](https://github.com/AsamK/signal-cli/releases). ```sh VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//') curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}".tar.gz sudo tar xf signal-cli-"${VERSION}".tar.gz -C /opt sudo ln -sf /opt/signal-cli-"${VERSION}"/bin/signal-cli /usr/local/bin/ ``` ### Install system-wide on Linux [ GraalVM native build ] ```sh VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//') curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}"-Linux-native.tar.gz sudo tar xf signal-cli-"${VERSION}"-Linux-native.tar.gz -C /opt sudo ln -sf /opt/signal-cli /usr/local/bin/ ``` You can find further instructions on the Wiki: - [Quickstart](https://github.com/AsamK/signal-cli/wiki/Quickstart) ## Usage For a complete usage overview please read the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/AsamK/signal-cli/wiki). Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.) * Link to an existing account If you have an existing Signal account associated with a number, you can link signal-cli to it with: signal-cli link * Register a number (with SMS verification) Alternatively, if you don't have an existing Signal account, you can register one from signal-cli. Note that this will unregister any existing client associated with the same number. signal-cli -a ACCOUNT register You can register Signal using a landline number. In this case, you need to follow the procedure below: * Attempt a SMS verification process first (`signal-cli -a ACCOUNT register`) * You will get an error `400 (InvalidTransportModeException)`, this is normal * Wait 60 seconds * Attempt a voice call verification by adding the `--voice` switch and wait for the call: ```sh signal-cli -a ACCOUNT register --voice ``` Registering may require solving a CAPTCHA challenge: [Registration with captcha](https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha) * Verify the number using the code received via SMS or voice, optionally add `--pin PIN_CODE` if you've added a pin code to your account signal-cli -a ACCOUNT verify CODE * Send a message ```sh signal-cli -a ACCOUNT send -m "This is a message" RECIPIENT ``` * Send a message to a username, usernames need to be prefixed with `u:` ```sh signal-cli -a ACCOUNT send -m "This is a message" u:USERNAME.000 ``` * Pipe the message content from another process. uname -a | signal-cli -a ACCOUNT send --message-from-stdin RECIPIENT * Receive messages signal-cli -a ACCOUNT receive **Hint**: The Signal protocol expects that incoming messages are regularly received (using `daemon` or `receive` command). This is required for the encryption to work efficiently and for getting updates to groups, expiration timer and other features. ## Storage The password and cryptographic keys are created when registering and stored in the current users home directory: $XDG_DATA_HOME/signal-cli/data/ $HOME/.local/share/signal-cli/data/ ## Building This project uses [Gradle](http://gradle.org) for building and maintaining dependencies. If you have a recent gradle version installed, you can replace `./gradlew` with `gradle` in the following steps. 1. Checkout the source somewhere on your filesystem with git clone https://github.com/AsamK/signal-cli.git 2. Execute Gradle: ./gradlew build 2a. Create shell wrapper in *build/install/signal-cli/bin*: ./gradlew installDist 2b. Create tar file in *build/distributions*: ./gradlew distTar 2c. Create a fat tar file in *build/libs/signal-cli-fat*: ./gradlew fatJar 2d. Compile and run signal-cli: ```sh ./gradlew run --args="--help" ``` ### JSON Schemas for the JSON-RPC mode 1. Generate [JSON Schema](https://json-schema.org/) files for all the JSON-RPC data classes (`src/main/java/org/asamk/signal/json`): ```sh ./gradlew jsonSchemas ``` 2. The generated files can be found in the `build/generated/META-INF/schemas` folder. ### Building a native binary with GraalVM (EXPERIMENTAL) It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not work in all situations. 1. [Install GraalVM and setup the environment](https://www.graalvm.org/docs/getting-started/#install-graalvm) 2. Execute Gradle: ./gradlew nativeCompile The binary is available at *build/native/nativeCompile/signal-cli* ## FAQ and Troubleshooting For frequently asked questions and issues have a look at the [wiki](https://github.com/AsamK/signal-cli/wiki/FAQ). ## License This project uses libsignal-service-java from Open Whisper Systems: https://github.com/WhisperSystems/libsignal-service-java Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html --- ## File: docs/CALL_TUNNEL.md # Voice Call Support ## Overview signal-cli supports voice calls by spawning a subprocess called `signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and audio transport. signal-cli communicates with the tunnel over its stdin/stdout using newline-delimited JSON messages, relaying signaling between the tunnel and the Signal protocol. ``` signal-cli signal-call-tunnel | | |-- spawn --------------------------->| |-- config JSON on stdin ------------>| | | |-- commands on stdin --------------->| |<-- events on stdout ----------------| | | WebRTC | signaling relay | audio I/O | | | (stderr: tunnel logging) -------->| (captured by signal-cli) ``` Each call gets its own tunnel process. When the call ends, signal-cli closes stdin and destroys the process. Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings returned by the tunnel in its `ready` message. signal-cli passes them through to JSON-RPC clients, which use them to connect audio via platform APIs. --- ## Spawning the Tunnel For each call, signal-cli: 1. Spawns `signal-call-tunnel` 2. Writes config JSON followed by a newline to stdin 3. Keeps stdin open for subsequent control messages 4. Reads control events from stdout 5. Captures stderr for logging The `signal-call-tunnel` binary is located by searching (in order): 1. `SIGNAL_CALL_TUNNEL_BIN` environment variable 2. `/bin/signal-call-tunnel` (detected from jar location) 3. `signal-call-tunnel` on `PATH` ### Config JSON The first line written to the tunnel's stdin: ```json { "call_id": 12345, "is_outgoing": true, "local_device_id": 1, "input_device_name": "signal_input", "output_device_name": "signal_output" } ``` | Field | Type | Description | |----------------------|-------------------------|-----------------------------------------------| | `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) | | `is_outgoing` | boolean | Whether this is an outgoing call | | `local_device_id` | integer | Signal device ID | | `input_device_name` | string (optional) | Requested input audio device name | | `output_device_name` | string (optional) | Requested output audio device name | If `input_device_name` or `output_device_name` are omitted, the tunnel chooses default names. On Linux, these are per-call unique names (e.g., `signal_input_`). On macOS, these are the fixed names `signal_input` and `signal_output`, which must match the pre-installed BlackHole drivers. --- ## Control Protocol Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout (tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent lines are control messages. ### signal-cli -> Tunnel (stdin) | Type | When | Fields | |----------------------|----------------------------|---------------------------------------------------------------------------------------------------| | `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` | | `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` | | `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | | `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | | `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) | | `accept` | User accepts incoming call | *(none)* | | `hangup` | End the call | *(none)* | ### Tunnel -> signal-cli (stdout) | Type | When | Fields | |---------------|---------------------------------------------|------------------------------------------------------| | `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` | | `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` | | `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` | | `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) | | `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` | | `sendBusy` | Line is busy | `callId` | | `stateChange` | Call state transition | `state`, `reason` (optional) | | `error` | Something went wrong | `message` | Opaque blobs and identity keys are base64-encoded. ICE servers use the format: ```json { "urls": [ "turn:example.com" ], "username": "u", "password": "p" } ``` --- ## Startup Sequence ``` signal-cli signal-call-tunnel | | |-- spawn process ------------------> | |-- config JSON + newline on stdin ---->| | | parse config | | initialize audio | | |<-------- ready (on stdout) -----------| | {"type":"ready", | | "inputDeviceName":"...", | | "outputDeviceName":"..."} | | | |-- control messages on stdin --------->| |<-- control events on stdout ----------| ``` --- ## Call Flows ### Outgoing call ``` signal-cli signal-call-tunnel Remote Phone | | | |-- spawn + config ------->| | |<-- ready ----------------| | |-- createOutgoingCall --->| | |-- proceed (TURN) ------->| | | | create offer | |<-- sendOffer ------------| | |-- offer via Signal -------------------------------->| |<-- answer via Signal -------------------------------| |-- receivedAnswer ------->| (+ identity keys) | |<-- sendIce --------------| | |-- ICE via Signal -------------------------------> | |<-- ICE via Signal -------------------------------- | |-- receivedIce ---------->| | | | ICE connects | |<-- stateChange:Connected | | ``` ### Incoming call ``` signal-cli signal-call-tunnel Remote Phone | | | |<-- offer via Signal --------------------------------| |-- spawn + config ------->| | |<-- ready ----------------| | |-- receivedOffer -------->| (+ identity keys) | |-- proceed (TURN) ------->| | | | process offer | |<-- sendAnswer -----------| | |-- answer via Signal -------------------------------->| |<-- sendIce --------------| | |-- ICE via Signal ------------------------------> | |<-- ICE via Signal -------------------------------- | |-- receivedIce ---------->| | | | ICE connecting... | | | | | (user accepts call) | | | Java defers accept | | | | | |<-- stateChange:Ringing --| (tunnel ready to accept)| |-- accept --------------->| (deferred accept sent) | | | accept | |<-- stateChange:Connected | | ``` ### JSON-RPC client perspective An external application (bot, UI, test script) interacts via JSON-RPC only. **Important:** Call event notifications are not sent by default. Clients must call `subscribeCallEvents` before initiating or receiving calls. Without this, incoming calls are silently ignored (no tunnel is spawned). ``` JSON-RPC Client signal-cli daemon | | |-- subscribeCallEvents() ------------>| (required: enables call support) | | |-- startCall(recipient) ------------->| |<-- {callId, state, -| | inputDeviceName, | | outputDeviceName} | | | |<-- callEvent: RINGING_OUTGOING ------| | ... remote answers ... | |<-- callEvent: CONNECTED -------------| | | | connect to audio devices | | (via platform audio APIs) | | | |-- hangupCall(callId) --------------->| (or: receive callEvent ENDED) |<-- callEvent: ENDED -----------------| | disconnect from audio devices | ``` For incoming calls: ``` JSON-RPC Client signal-cli daemon | | |-- subscribeCallEvents() ------------>| (if not already subscribed) | | |<-- callEvent: RINGING_INCOMING ------| (includes callId, device names) | | |-- acceptCall(callId) --------------->| |<-- {callId, state, -| | inputDeviceName, | | outputDeviceName} | | | |<-- callEvent: CONNECTING ------------| |<-- callEvent: CONNECTED -------------| | | | connect to audio devices | | (via platform audio APIs) | ``` To stop receiving call events, call `unsubscribeCallEvents`. --- ## State Machine Call states as seen by JSON-RPC clients: ``` startCall() | v +----- RINGING_OUTGOING ----+ RINGING_INCOMING -----+ | | | | | | (timeout | (answered) | (rejected) | acceptCall() | (timeout | ~60s) | | | | ~60s) v v v v v ENDED CONNECTED ENDED CONNECTING ENDED | | | v | CONNECTED | | | (hangup/error) | (hangup/error) v v ENDED ENDED ``` For outgoing calls, `CONNECTED` fires directly when the tunnel reports `Connected` state -- there is no intermediate `CONNECTING` event. For incoming calls, `CONNECTING` is set by Java when the user calls `acceptCall()`, before the tunnel completes ICE negotiation. Both directions have a 60-second ring timeout. Reconnection (ICE restart): ``` CONNECTED --> RECONNECTING --> CONNECTED (ICE restart succeeded) | v ENDED (ICE restart failed) ``` `RECONNECTING` maps from the tunnel's `Connecting` state, which is emitted during ICE restarts (not during initial connection). --- ## CallManager.java `lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java` Manages the call lifecycle from the Java side: 1. Spawns `signal-call-tunnel` and writes config JSON to stdin 2. Keeps stdin open as the control write channel; reads stdout for control events 3. Captures stderr for tunnel logging 4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready` message and includes them in `CallInfo` 5. Translates tunnel state changes into `CallInfo.State` values and fires `callEvent` JSON-RPC notifications to connected clients 6. Defers the `accept` message for incoming calls until the tunnel reports `Ringing` state (sending earlier causes the tunnel to drop it) 7. Schedules a 60-second ring timeout for both incoming and outgoing calls 8. On hangup: sends hangup message, closes stdin, and destroys the process --- ## Implementation Notes ### Peer ID consistency The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual remote peer UUID (e.g., `senderAddress.toString()`). The tunnel rejects ICE candidates if the peer ID doesn't match across calls, causing "Ignoring peer-reflexive ICE candidate because the ufrag is unknown." ### sendHangup semantics `sendHangup` from the tunnel is a request to send a hangup message via Signal protocol. It is **not** a local state change -- local state transitions come exclusively from `stateChange` events. For single-device clients, ignore `AcceptedOnAnotherDevice`, `DeclinedOnAnotherDevice`, and `BusyOnAnotherDevice` hangup types in the `hangupType` field -- sending these to the remote peer causes it to terminate the call prematurely. ### Call ID serialization Call IDs can exceed `Long.MAX_VALUE` in Java. Use `Long.toUnsignedString()` when serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In the config JSON, `call_id` should also use unsigned representation. ### Incoming hangup filtering When receiving hangup messages via Signal protocol, only honor `NORMAL` type hangups. `ACCEPTED`, `DECLINED`, and `BUSY` types are multi-device coordination messages and should be ignored by single-device clients. ### JSON-RPC call ID types JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger, Integer). Use `Number.longValue()` rather than direct casting when extracting call IDs from JSON-RPC parameters. ### Identity key format Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw 32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). If the 33-byte serialized form is used instead, SRTP key derivation produces different keys on each side, causing authentication failures. --- ## File: reproducible-builds/README.md # Reproducible builds This process lets you verify that the version of signal-cli that was downloaded from the Github Releases matches the source code in the public repository. This is achieved by replicating the build environment as Docker images. Currently, only the following binaries are reproducible: - [x] JAR package (`signal-cli-XXX.tar.gz`) - [ ] Native binary (`signal-cli-XXX-Linux-native.tar.gz`) - [x] Rust client binary (`signal-cli-XXX-Linux-client.tar.gz`) In the following section, we will use signal-cli version 0.14.2 as the reference example. Simply replace all occurrences of 0.14.2 with the version number you are about to verify. ## Step-by-step instructions ### 0. Prerequisites Before you begin, ensure you have the following installed: - git - docker (or podman) ### 1. Verifying reproducibility ```bash git clone --depth 1 --branch v0.14.2 https://github.com/AsamK/signal-cli cd ./signal-cli ./reproducible-builds/verify.sh ``` If each one ends with `... matches!` for every binary (except the native one for now), you're good to go! You've successfully verified that the Github Release binaries were built from exactly the same code as is in the signal-cli git repository. If you get `... doesn't match!`, it means something went wrong (except for the native one for now). Please [open an issue](https://github.com/AsamK/signal-cli/issues/new/choose).