Signal Protocol Is a Cryptographic Layer, Not a Transport Protocol

A Go service can deliver messages over WebSocket, HTTP, WebRTC, MQTT, or a store-and-forward queue without any of those transports providing end-to-end encryption. Transport encryption such as TLS protects a connection between network peers. Signal Protocol addresses a different boundary: message content is encrypted at one endpoint and remains ciphertext until the receiving endpoint processes the corresponding cryptographic session.

That distinction determines where Signal Protocol belongs in an application. The relay can authenticate accounts, route envelopes, retain undelivered ciphertext, and enforce quotas, but it should not need the conversation keys required to recover message plaintext.

Session state lives above the network connection

A WebSocket connection has a lifecycle: connect, exchange frames, disconnect, reconnect. A Signal session has a cryptographic lifecycle that must survive those network events.

Treating both as the same state produces a fragile design. Reconnecting a socket must not silently reset the cryptographic ratchet, and changing from Wi-Fi to cellular transport must not require a new identity key.

A useful boundary is:

application message
       |
       v
cryptographic session
       |
       | encrypted envelope
       v
transport adapter
       |
       v
relay / message queue
       |
       v
transport adapter
       |
       | encrypted envelope
       v
cryptographic session
       |
       v
application message

The transport handles delivery. The session layer handles keys, message counters, ratchet state, and authenticated encryption.

For a Go codebase, this separation is more important than choosing a particular networking package. A transport interface can move opaque bytes without knowing how those bytes were produced.

type Transport interface {
    Send(ctx context.Context, recipient string, envelope []byte) error
}

type SessionStore interface {
    Load(ctx context.Context, peer string) ([]byte, error)
    Save(ctx context.Context, peer string, state []byte) error
}

These interfaces deliberately say nothing about a specific Signal implementation. Cryptographic state should be managed by the protocol implementation, not reconstructed from application fields.

Asynchronous messaging needs published key material

The first encrypted message presents a special problem: the recipient may be offline.

X3DH was designed for this asynchronous setting. A recipient publishes an identity key, a signed prekey, its signature, and a supply of one-time prekeys to a server. A sender can fetch a prekey bundle and establish a shared secret without requiring both devices to be online simultaneously.

The older X3DH construction is still useful for understanding this architecture:

Bob device
   |
   | identity public key
   | signed prekey
   | one-time prekeys
   v
prekey service
   ^
   | fetch bundle
   |
Alice device
   |
   | establish shared secret
   v
initial encrypted message

Current Signal specifications also define PQXDH, which adds a post-quantum component to asynchronous key agreement. An application should therefore avoid equating the entire Signal Protocol with one historical handshake. Session establishment and the ratcheting phase are separate mechanisms, and the supported construction depends on the protocol implementation and version being used.

The server’s role in the prekey exchange does not require possession of the corresponding private keys. Private identity keys, signed-prekey private material, one-time-prekey private material, and active session secrets belong at the endpoint.

Double Ratchet changes keys as messages move

After two endpoints have shared secret material, the Double Ratchet algorithm derives fresh keys as the conversation progresses.

Its state combines symmetric-key chains with Diffie-Hellman ratchet steps. Message keys are derived from chain keys, and the chain advances instead of repeatedly encrypting every message with one long-lived symmetric key. Diffie-Hellman outputs are periodically mixed into the root-key evolution when the parties exchange new ratchet public keys.

A simplified view is:

root key
   |
   +--> sending chain key --> message key 0
   |          |
   |          +-----------> message key 1
   |          |
   |          +-----------> message key 2
   |
   +--> receiving chain state

new remote ratchet public key
   |
   v
DH ratchet step
   |
   v
new root / chain state

The security consequence comes from key evolution and deletion. Later state should not provide a straightforward way to reconstruct message keys that were already consumed and erased. A later Diffie-Hellman ratchet can also inject new secret material, providing recovery properties after some forms of state compromise once uncompromised ratchet input is exchanged.

Those properties depend on correct state handling. Persisting every historical message key forever defeats part of the purpose of deleting obsolete key material.

Out-of-order delivery requires skipped message keys

Network delivery is not guaranteed to preserve conversational order. A device can receive message 12 before message 10 because of retries, multi-path delivery, temporary disconnection, or queue behavior.

The Double Ratchet specification handles this by allowing a receiver to derive and temporarily store skipped message keys while advancing a receiving chain. When the delayed message arrives, the receiver can use the stored key for its message number.

received:  8, 9, 12

derive key 10 -> store temporarily
derive key 11 -> store temporarily
derive key 12 -> decrypt message 12

later:
message 10 -> consume stored key 10
message 11 -> consume stored key 11

This state cannot grow without a bound. The specification defines a maximum number of skipped message keys that an implementation is willing to retain. Without such a limit, an attacker could send a message claiming an extreme counter and force excessive computation or storage.

A Go implementation should not replace this mechanism with an unbounded map simply because a map is convenient. The skipped-key limit is part of the protocol’s resource-safety boundary.

Persistence must be atomic with ratchet advancement

Ratchet state is mutable. Encrypting or decrypting a message can advance counters, replace chain keys, add skipped keys, consume skipped keys, or perform a Diffie-Hellman ratchet.

That makes persistence a correctness problem as well as a storage problem.

Consider an outbound path:

load session
    |
derive message key
    |
advance sending chain
    |
encrypt message
    |
persist new session state
    |
enqueue ciphertext

If the process crashes between state mutation and durable storage, retry behavior depends on the protocol library and application transaction boundary. Reusing cryptographic state that was intended to advance can be dangerous; advancing state without retaining the ciphertext can also create operational gaps.

The application therefore needs a clearly defined atomic boundary around protocol-state updates and message-queue operations. The exact transaction strategy depends on the library and storage architecture, but “save it later” is not a safe generic rule for ratcheting state.

The same issue appears on receive. A successfully authenticated message may consume a skipped key or advance the receiving chain. Reprocessing the same envelope against stale state is not equivalent to processing it once.

Go concurrency can corrupt a logically serial session

Go makes concurrent message processing easy. A cryptographic session is not automatically safe to mutate concurrently.

Two goroutines that load the same session snapshot can derive state from the same counters before either stores its update:

goroutine A                    goroutine B
-----------                    -----------
load state N                   load state N
derive next key                derive next key
advance to N+1                 advance to N+1
save state                     save state

A mutex around one process can prevent local races, but it does not solve concurrent mutation across multiple processes or replicas. A distributed service needs a stronger serialization boundary, such as transactional compare-and-swap, row locking, an actor-style owner for each session, or another mechanism compatible with the selected storage system.

The important invariant is narrower than “make the database thread-safe”: updates to one cryptographic session must observe a coherent order.

This is also a reason not to place endpoint Signal sessions casually inside a horizontally scaled relay. If the relay is only routing ciphertext, it does not need to mutate those endpoint sessions at all.

Identity verification is separate from ciphertext confidentiality

Successful decryption proves that a message is valid under the keys associated with the current session state. It does not by itself tell a human that the remote identity key belongs to the person they intended to contact.

Signal-style systems expose an identity-verification mechanism so users or devices can compare authenticated identity information through another trusted context. Applications that omit this distinction can provide encrypted communication while still leaving initial key substitution as a separate trust problem.

The server can also influence which public prekey material a client receives. Protocol specifications account for this trust boundary, but an application still needs a policy for identity changes: whether to accept them automatically, warn, block until confirmation, or apply a product-specific trust model.

That policy belongs above the raw ratchet implementation.

The official libsignal surface is not a Go SDK

Signal’s current libsignal repository implements core functionality in Rust and exposes supported platform-facing APIs for Java, Swift, and TypeScript used by Signal’s own clients and servers. Its repository explicitly states that use outside Signal is unsupported and that bridge APIs can change.

There is no equivalent officially supported Go API surface in that repository.

That matters for architecture. A Go application has several very different choices: maintain a carefully audited native implementation, bind to another implementation across a stable boundary, isolate cryptographic operations in a service written against a supported library surface, or use a third-party Go implementation after evaluating its protocol version, maintenance status, interoperability, persistence semantics, and security review.

These choices are not interchangeable. A package named after Signal Protocol can implement an older protocol generation and still compile perfectly.

For security-sensitive deployments, protocol compatibility should be demonstrated with test vectors and cross-implementation tests rather than inferred from package names.

The relay should remain boring

A clean server-side design gives the Go relay as little cryptographic authority as possible:

sender device
  plaintext
     |
     v
Signal session
     |
     | ciphertext envelope
     v
Go relay
  - authenticate account/device
  - route envelope
  - queue while recipient is offline
  - enforce size/rate policy
     |
     | ciphertext envelope
     v
recipient device
     |
     v
Signal session
     |
     v
  plaintext

TLS is still necessary between devices and the relay. It protects connection metadata and application traffic from ordinary network observers, authenticates the server under the application’s TLS model, and prevents transport-level tampering. It simply protects a different boundary from end-to-end message encryption.

WebSocket versus HTTP also becomes an operational decision rather than a cryptographic one. A system can switch transports without redesigning the Signal session as long as the encrypted envelope and delivery semantics preserve what the protocol layer requires.

The hard state is at the endpoints: identity keys, prekeys, active ratchets, skipped message keys, and the rules for updating them without reuse or races. Keeping that state separate from Go’s networking layer is what allows the transport to reconnect, retry, scale, and even change protocols without silently changing the end-to-end trust boundary.