Skip to main content

Signal: What the Source Shows About Message and Call Encryption

·7 mins

I checked four repositories together:

  • libsignal
  • Signal-Desktop
  • Signal-Android
  • ringrtc

The goal was narrow: verify from source whether Signal really encrypts messages on the client, whether the server lacks the message keys, and whether calls are really encrypted.

Main Findings #

  • Messages: yes. Signal-Desktop routes outgoing plaintext into @signalapp/libsignal-client.signalEncrypt(...) before network send, and libsignal derives message keys from local identity/session state stored on the client.
  • Message server knowledge: no message key in the clear. The server can relay ciphertext and public key material, but the message-encryption path in libsignal uses local private/session state, not a server-provided symmetric message key.
  • 1:1 calls: yes. ringrtc generates a local ephemeral secret, exchanges only public keys in signaling, derives SRTP keys locally with Diffie-Hellman + HKDF, then injects those SRTP keys into the call setup.
  • Group calls: yes, but differently. Group calls connect through an SFU, so transport is client-to-SFU. On top of that, ringrtc enables an extra frame-encryption layer and ratcheted media keys, so the SFU should not be able to decrypt the actual media frames.
  • Official client wiring is present. Signal-Desktop depends on @signalapp/libsignal-client and @signalapp/ringrtc, and Signal-Android depends on libsignal and ringrtc too.
  • Scope limit: this proves the open-source code path, not that a particular released binary was reproduced and matched bit-for-bit.
flowchart TD A[Signal client] --> B[libsignal local keys and session state] B --> C[Encrypt message on device] C --> D[Ciphertext sent to Signal server] D --> E[Server relays ciphertext] F[RingRTC 1:1 call] --> G[Exchange public keys in signaling] G --> H[Derive SRTP keys locally] H --> I[Encrypted media path] J[RingRTC group call] --> K[Derive transport keys with SFU] J --> L[Enable extra frame encryption] L --> M[Share media frame keys with participants over Signal call messages] K --> N[SFU relays encrypted transport] M --> O[Participants decrypt media frames] N --> O

How to Verify Yourself #

Run these from /home/user1/syslab-nosync/other.

1. Confirm the official clients depend on the same crypto/calling libraries #

rg -n "@signalapp/libsignal-client|@signalapp/ringrtc" Signal-Desktop/package.json
rg -n "implementation\(libs\.libsignal\.android\)|implementation\(libs\.signal\.ringrtc\)" Signal-Android/app/build.gradle.kts

2. Confirm Signal Desktop encrypts messages before sending #

rg -n "signalEncrypt\(|content = Bytes.toBase64\(ciphertextMessage\.serialize\(\)\)|sendMessages\(" \
  Signal-Desktop/ts/textsecure/OutgoingMessage.preload.ts

3. Confirm libsignal does the encryption locally #

rg -n "get_identity_key_pair|load_session|message_encrypt|aes_256_cbc_encrypt" \
  libsignal/rust/protocol/src/storage/traits.rs \
  libsignal/rust/protocol/src/session.rs \
  libsignal/rust/protocol/src/session_cipher.rs

4. Confirm 1:1 calls derive SRTP keys from client-side Diffie-Hellman #

rg -n "custom Diffie-Hellman|generate_local_secret_and_public_key|negotiate_srtp_keys|disable_dtls_and_set_srtp_key" \
  ringrtc/src/rust/src/core/signaling.rs \
  ringrtc/src/rust/src/core/connection.rs \
  ringrtc/src/rust/src/webrtc/sdp_observer.rs

5. Confirm group calls add end-to-end frame encryption on top of SFU transport #

rg -n "enable_frame_encryption|encrypt_media|decrypt_media|send_media_send_key_to_users_over_signaling|frame_crypto_context" \
  ringrtc/src/rust/src/core/group_call.rs \
  ringrtc/src/rust/src/webrtc/peer_connection_observer.rs

6. Confirm RingRTC sends call signaling back through Signal’s message layer #

rg -n "type: 'CallingMessage'|sendCallingMessage\(|sendContentMessageToGroup|Proto\.CallMessage" \
  Signal-Desktop/ts/services/calling.preload.ts \
  Signal-Desktop/ts/jobs/helpers/sendCallingMessage.preload.ts \
  Signal-Desktop/ts/textsecure/SendMessage.preload.ts

Detailed Findings #

1. Official clients really use libsignal and ringrtc #

  • Signal-Desktop/package.json:118-120 declares @signalapp/libsignal-client and @signalapp/ringrtc as dependencies.
  • Signal-Android/app/build.gradle.kts:677-679 includes libs.libsignal.android and libs.signal.ringrtc.
  • libsignal/README.md:19-23 states that the repository is used by Signal Android, iOS, and Desktop.

That is enough to justify using libsignal as the shared message-crypto implementation and ringrtc as the shared calling implementation for the clients checked here.

2. Message encryption happens on the client before network send #

In Signal-Desktop, the send path imports signalEncrypt directly from @signalapp/libsignal-client:

  • Signal-Desktop/ts/textsecure/OutgoingMessage.preload.ts:7-18

Then it encrypts the plaintext before building the network payload:

  • Signal-Desktop/ts/textsecure/OutgoingMessage.preload.ts:377-400 calls signalEncrypt(...)
  • Signal-Desktop/ts/textsecure/OutgoingMessage.preload.ts:514-531 serializes the resulting ciphertext and stores it in content
  • Signal-Desktop/ts/textsecure/OutgoingMessage.preload.ts:308-336 sends that JSON payload with sendMessages(...) or sendMessagesUnauth(...)

So the Desktop app-side proof is straightforward: plaintext becomes ciphertext first, and only then is the payload handed to the server API.

3. libsignal derives message keys from client-held state, not from the server #

The core evidence is in libsignal.

  • libsignal/rust/protocol/src/storage/traits.rs:49-52 requires the local IdentityKeyStore to provide the client’s identity key pair, including the private key.
  • libsignal/rust/protocol/src/storage/traits.rs:150-159 shows sessions are loaded from a local SessionStore.
  • libsignal/rust/protocol/src/session.rs:180-230 creates a session from the remote party’s public prekey bundle plus the local identity key pair and a freshly generated local base key.
  • libsignal/rust/protocol/src/session_cipher.rs:19-27 defines message_encrypt(...).
  • libsignal/rust/protocol/src/session_cipher.rs:36-45 derives message keys from the current sender chain state.
  • libsignal/rust/protocol/src/session_cipher.rs:62-63 encrypts plaintext with aes_256_cbc_encrypt(...).
  • libsignal/rust/protocol/src/session_cipher.rs:93-161 wraps the ciphertext into SignalMessage or PreKeySignalMessage and stores updated session state locally.

The important point is not the exact cipher alone. The important point is where the encryption key material comes from: local identity/private/session state and ratchet state, not a server-held symmetric key.

4. 1:1 calls use client-side Diffie-Hellman to derive SRTP keys #

The direct call path is in ringrtc.

  • ringrtc/src/rust/src/core/signaling.rs:24-30 states that V3/V4 signaling replaced DTLS with a custom Diffie-Hellman exchange to derive SRTP keys.
  • ringrtc/src/rust/src/core/connection.rs:635-641 generates a local secret/public key pair and places the public key into the outgoing offer.
  • ringrtc/src/rust/src/core/connection.rs:822-858 does the same for the answer path.
  • ringrtc/src/rust/src/core/connection.rs:2231-2285 defines negotiate_srtp_keys(...), performs Diffie-Hellman, runs HKDF, and outputs SRTP keys using AeadAes256Gcm.
  • ringrtc/src/rust/src/core/connection.rs:730-743 and :828-838 apply those derived keys to offer/answer via disable_dtls_and_set_srtp_key(...).
  • ringrtc/src/rust/src/webrtc/sdp_observer.rs:174-189 is the implementation of disable_dtls_and_set_srtp_key(...).
  • ringrtc/src/rust/src/native.rs:454-459 creates the 1:1 PeerConnectionObserver with enable_frame_encryption set to false, which means 1:1 calls rely on the SRTP path rather than the separate frame-encryption layer used in group calls.

For 1:1 calls, that means the media path is encrypted with SRTP keys that are derived from endpoint-controlled ephemeral secrets. A signaling relay can see public keys and call metadata, but not the private secret required to derive the SRTP keys.

5. Group calls are encrypted too, but the model is different #

Group calls are not plain peer-to-peer. They are SFU-based. The code shows two separate encryption layers:

  1. client-to-SFU transport encryption
  2. participant-to-participant frame encryption over that transport

Transport layer evidence:

  • ringrtc/src/rust/src/core/group_call.rs:170-198 defines SrtpKeys for the group call transport.
  • ringrtc/src/rust/src/core/group_call.rs:470-509 derives those SRTP keys from a Diffie-Hellman exchange with the server public key.
  • ringrtc/src/rust/src/core/group_call.rs:3454-3478 sets local and remote group-call session descriptions using srtp_keys.client and srtp_keys.server.
  • ringrtc/src/rust/src/core/group_call.rs:2992-3057 starts the peer connection to the SFU using that transport setup.

End-to-end frame layer evidence:

  • ringrtc/src/rust/src/core/group_call.rs:4966-4971 creates the group call PeerConnectionObserver with enable_frame_encryption set to true.
  • ringrtc/src/rust/src/webrtc/peer_connection_observer.rs:159-185 defines the frame-encryption callbacks.
  • ringrtc/src/rust/src/webrtc/peer_connection_observer.rs:450-548 shows WebRTC calling back into Rust for media encryption and decryption.
  • ringrtc/src/rust/src/core/group_call.rs:3801-3915 implements encrypt_media(...) and decrypt_media(...), including a ratchet counter, frame counter, and MAC.
  • ringrtc/src/rust/src/core/group_call.rs:3566-3597 advances the media send key ratchet.
  • ringrtc/src/rust/src/core/group_call.rs:3601-3623 installs received media keys for decrypting remote frames.
  • ringrtc/src/rust/src/core/group_call.rs:3656-3681 sends media frame keys to participants over signaling.

So the precise claim for group calls is:

  • the SFU is in the transport path
  • the transport is encrypted
  • the actual media frames also get an extra application-level encryption layer in the client
  • the frame keys are rotated and shared between participants, not exposed as plain media to the SFU

That is the key nuance. Group calls are not “just SRTP to the SFU”. The source shows an extra frame-encryption layer intended to keep the SFU from seeing call contents.

flowchart LR A[Participant A] --> C[Encrypt frame locally] C -->|SRTP carrying frame-encrypted media| D[SFU relay] D -->|SRTP carrying frame-encrypted media| E[Decrypt frame locally] E --> B[Participant B] A -. Signal call messages carry frame keys .-> B

6. Group-call key exchange is routed back through Signal’s messaging layer #

This matters because ringrtc itself decides when to send media keys, but the app decides how to transport those call messages.

Desktop evidence:

  • Signal-Desktop/ts/services/calling.preload.ts:617-639 wires RingRTC callback handlers into the app.
  • Signal-Desktop/ts/services/calling.preload.ts:3288-3347 takes RingRTC group-call signaling bytes, wraps them as Proto.CallMessage, and queues a CallingMessage job.
  • Signal-Desktop/ts/jobs/helpers/sendCallingMessage.preload.ts:95-115 sends group call messages through sendContentMessageToGroup(...).
  • Signal-Desktop/ts/jobs/helpers/sendCallingMessage.preload.ts:118-131 sends direct call messages through messaging.sendCallingMessage(...).
  • Signal-Desktop/ts/textsecure/SendMessage.preload.ts:2411-2444 wraps a direct call message inside Proto.Content and sends it through sendMessageProtoAndWait(...).

That ties the call-signaling path back to Signal’s normal encrypted message transport rather than a plain unauthenticated side channel.

7. What this does and does not prove #

What this source review supports:

  • Signal messages are encrypted on the client using libsignal.
  • The message server does not appear to hold the message-encryption keys used in that path.
  • Direct 1:1 calls derive SRTP keys endpoint-side from Diffie-Hellman inputs.
  • Group calls add an extra frame-encryption layer over SFU transport.

What this review does not prove by itself:

  • that every released binary exactly matches the audited source
  • that iOS was checked in this pass with the same depth as Desktop and Android
  • that Signal’s production servers are configured exactly the same as the open-source default expectations

But as a source-based proof of implementation intent and code path, the evidence is strong.