Can You Self-Host the Signal Server?
Signal is an awesome messenger that prioritizes privacy and supports end-to-end encryption. On top of that, the app is open-sourced, which means you can check and audit the code – and perhaps even run it.
In this post, I would like to share with you my attempt of running Signal on my local machine.
Server
On official signal repository, you can find multiple components: server, android app, libsignal, contact discovery service, and more.
Although the exact system architecture has not been officially published, I was able to create some high-lever architecture overviews with the help of AI.
Note: This diagram is simplified and does not contain all elements.
As you can see, the main component that is definitely required is server, which is the part I started with. The repo is not well documented, but we can take a look at LocalWhisperServerService
. This class from the test
scope allows to run local instance of Signal Server, with a disclaimer that not all options are available. To start up this test server, I just ran it using my IDE.
It also points to default test config src/test/resources/config/test.yml
and secrets src/test/resources/config/test-secrets-bundle.yml
. You can find more details about required dependencies of the server. Just from looking at it, it's clear it is not as easy as running a few containers, but maybe we can try to connect a mobile app to LocalWhisperServerService
?
Mobile App
I downloaded the mobile app from this repository and used Android Studio
to navigate through the code easily.
After snooping around, I found app/build.gradle.kts
with a bunch of URLs that represent different services. I replaced all URLs under the keys SIGNAL_URL
with the IP address of my PC in the home network.
Then, I started an application in the emulator, and it was working, great success!
However, despite having a working app, it still couldn't connect with the locally running server. Let's investigate what might be the case.
Looking into output logs I was getting an error that only TLS connections are allowed, so I decided to try to get some domain and certificate. I found a "trick" that you can generate a valid certificate by getting duckdns subdomain and using Let's Encrypt
. To utilize the certificate, I ran nginx-proxy-manager
locally within the container, with proper redirects to LocalWhisperServerService
.
Second try and... It's not working again... Looks like the certificate comes from an untrusted CA, but the browser says everything is ok. It turns out the mobile app has its own trust store, and I needed to add root CA to app/src/main/res/raw/whisper.store
Third try and... It's working – I was able to "register" the fake phone number.
The first step was CAPTCHA verification. After filling it out correctly, I received an error, and I was redirected back to the screen where I had filled in the phone number.
Trying to register the phone again directed me to the screen where I needed to enter the pass code. Since I was not setting up any SMS gateways, and it was “TestServer”, I tried to pass the last 6 digits of the phone. After typing the code, loader started to spin, and after few minutes there were no results, so I decided to click Resend Code
and it worked. Later I found out that you can pass any code.
In the next step, I was asked to provide my first and last name. After that, I needed to pass a PIN number twice for my ‘account’. The procedure failed, with a pop-up error, but I was able to move forward, where I was redirected to the main page.
Let's start a second app and see if we can send some messages. The app starts up, but there comes another problem: to find another contact, you need contact discovery service.
Contact Discovery Service
Starting this service is not so easy. It uses a few native libraries that are specifically compiled for x86 architecture, which means there is no way to run it on MacOS.
It's all because Signal cares about our privacy, and the contact discovery service uses enclaves to protect RAM from leaking your data. This is achieved by encrypted and obfuscated RAM access patterns, so any malicious actors won’t be able to “guess” them. This is well explained in the following Signal's Blog.
Maybe it's possible to run it inside a container? Unfortunately it's not entirely possible. Again, service requires native libraries, and one of them needs to be compiled, which is done... via container.
After giving it a good few trials, I decided to try VM with the Ubuntu machine, and I was able to run it there.
Ok, so much trouble just to run to Contact Discovery Service, I should be able to connect to it, right?
I changed URLs pointing to Contact Discovery Service in the app/build.gradle.kts
in the mobile app repository (config fields with key SIGNAL_CDSI_URL
). I ran the application, but there was no trace of outgoing requests, and I could find error logs indicating that I was unable to connect to the service.
After looking around, I found that the connection to this service is managed by libsignal - a protocol-shared library. It also means that the connection URL is there (the ones from app/build.gradle.kts
seems to be unused). Fortunately, it is possible to build a mobile app with the local version of libsignal
, by uncommenting and filling libsignalClientPath
and org.gradle.dependency.verification
properties in the gradle.properties
file. libsignalClientPath
must point to the libsignal
repository location on the machine.
I changed the URLs in /rust/net/src/env.rs
in the libsignal
repository for DOMAIN_CONFIG_CDSI
properties, rebuilt the mobile app, loaded it, and... I was still getting the error that the connection failed.
At this point, I decided to give up my trial as it was just the tip of the iceberg, and debugging and discovering every component would take a lot of time
libsignal
So, now we know that it's really hard to self-host the Signal server (if possible at all), but what about the protocol and shared library? Maybe I can build my own server using this library?
I decided to give it a try, and so I built a simple PoC server that uses libsignal
and MySql
to store messages and device info for later retrieval.
The code is available in the following repository.
It is divided into two subprojects:
client
is a console app that is a protocol client that can encrypt, decrypt, receive, and send messages.server
is a spring boot server that saves all necessary information and messages, making the process asynchronous.
The communication between client
and server
is done via HTTP.
To run both, follow instructions from repository's README.md
Device registration
To be able to encrypt and decrypt messages, first we need to generate:
org.signal.libsignal.protocol.IdentityKeyPair
org.signal.libsignal.protocol.state.SignedPreKeyRecord
- a list of
org.signal.libsignal.protocol.state.PreKeyRecord
Generated data should be saved for later use in the implemented org.signal.libsignal.protocol.state.SignalProtocolStore
so your "credentials" are not lost, however, for simplicity, I used an InMemory
implementation available in libsignal
and saved data there. This means that each application run generates new credentials.
To allow other users to establish a session with us, we need to send the following data to the server during registration:
- IdentityKey (public)
- SignedPreKey (public) - part of
SignedPreKeyRecord
- PreKeySignature - part of
SignedPreKeyRecord
- PreKeys
Later from this data server can send PreKeyBundle
to the requesting device.
Here is a code snippet from the example repository:
println("Enter your username:")
val username = readln()
val serverClient = ServerClient(serverURL = URI("http://localhost:8080"))
val identityKeyPair = IdentityKeyPair.generate()
val protocolStore = InMemorySignalProtocolStore(identityKeyPair, 2137)
val preKeys = generatePreKeys(1, 100)
preKeys.forEach { protocolStore.storePreKey(it.id, it) }
val signedPreKey = generateSignedPreKey(identityKeyPair, 0)
protocolStore.storeSignedPreKey(signedPreKey.id, signedPreKey)
val deviceDTO = serverClient.registerUser(username, identityKeyPair, signedPreKey, preKeys) ?: return
Establishing session
A session can be established in two ways:
- by fetching
org.signal.libsignal.protocol.state.PreKeyBundle
from the server - by receiving
org.signal.libsignal.protocol.message.PreKeySignalMessage
- this is usually the first message from an other device.
The initiator needs to first fetch from a server PreKeyBundle
related to the receiver device. After that, it needs to create a org.signal.libsignal.protocol.SessionBuilder
and run process(PreKeyBundle)
method. This step allows to save all necessary information inside SignalProtocolStore
, which later will be used when encrypting and decrypting messages.
Here is the part responsible for that in the example repository:
val receiverInformation = getAndSaveUserInformation(serverClient, receiverName, otherUsers)
if (!protocolStore.containsSession(receiverInformation.toSignalProtocolAddress())) {
val userBundle = serverClient.getUserBundle(receiverName)
SessionBuilder(protocolStore, receiverInformation.toSignalProtocolAddress())
.process(userBundle.toPreKeyBundle(deviceDTO.deviceId))
}
Sending Messages
To send a message, you need to create an org.signal.libsignal.protocol.SessionCipher
passing SignalProtocolStore
and receivers SignalProtocolAddress
.
Note: For simplification purposes data required for SignalProtocolAddress
is generated by the server, and fetched whenever needed.
After obtaining SessionCipher
, you can simply call org.signal.libsignal.protocol.SessionCipher#encrypt(byte[])
method. The returned encrypted message is sent to the server, so it can be later fetched by the other device.
Example:
val cipher = SessionCipher(protocolStore, receiverInformation.toSignalProtocolAddress())
val ciphertextMessage = cipher.encrypt(message.toByteArray())
serverClient.sendMessage(username, receiverName, ciphertextMessage)
Receiving Messages
To receive messages, the device calls a server endpoint which returns all messages from the inbox together with information about the sender and message type. A message can be decrypted only once, and it is deleted from the server after being fetched.
To decrypt a message, you need to create (or obtain) SessionCipher
related to the message sender. To properly decrypt a message, it needs to be casted to the proper type:
PreKeySignalMessage
when it's the first message from the deviceSignalMessage
for other messages
Casting is controlled by messageType
that is saved and returned by the server.
Note: Although there are other message types available in libsignal
, I focused on only 2 for simplicity.
When PreKeySignalMessage
is decrypted, a session with a given device is established, so there is no need to fetch additional data from the server.
You can see the example here.
Comments
It's worth mentioning that libsignal
is published under the AGPL-3.0
license, which means that every software that uses it must be published with the same license.
The library is awesome and it allows you to create software with end-to-end encryption, however it's designed purely for the Signal ecosystem, and types and structures seem to be suited only for it.
The library is mainly implemented in rust
which makes debugging really hard, especially in Java / Kotlin clients.
Summary
Signal is a secure and complicated messaging platform. Self-hosting the entire service is almost impossible due to the documentation's limited availability, the stack's complexity, and the options to customize. On the other hand, writing a custom server is also a challenging task, even when using the available libsignal
. It requires open-sourcing your code, a good understanding of the Signal Protocol, and handling multiple edge cases that you don't think of every day.
Reviewed by Michał Ostruszka and Adam Warski.