Contents

Can You Self-Host the Signal Server?

Can You Self-Host the Signal Server? webp image

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.

signal-app-schema
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.

signal-self-host

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.

signal%20self-hosting%20atempt

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.

signal%20self-hosting%20atempt2

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 device
  • SignalMessage 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.

banner kotlin services

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.

Blog Comments powered by Disqus.