When building Chatter, a real-time chat platform with voice/video calling and AI integrations, security wasnât an afterthoughtâit was the foundation. I chose to implement the Signal Protocol, widely regarded as the gold standard for end-to-end encrypted messaging.
This post explores the cryptographic principles behind Signal and how weâve implemented them in Chatterâs Go backend.
Why Signal Protocol?
The Signal Protocol powers encrypted messaging in WhatsApp, Signal, Facebook Messenger, and now Chatter. It provides:
- Perfect Forward Secrecy (PFS): Past messages remain secure even if current keys are compromised
- Post-Compromise Security (PCS): Future messages are protected even after a key compromise
- Asynchronous communication: Recipients can be offline when you send encrypted messages
- Deniable authentication: You can verify messages came from the sender, but canât prove it to third parties
The Core Components
1. The Double Ratchet Algorithm
At the heart of Signal lies the Double Ratchet Algorithm, which combines two ratcheting mechanisms:
Symmetric-Key Ratchet (KDF Chain)
Each message derives a new encryption key from the previous one using a Key Derivation Function (KDF):
Keyâ â KDF â Keyâ â KDF â Keyâ â ...
Once a key encrypts a message, itâs deleted. An attacker capturing Keyâ cannot compute Keyâ or Keyâ backward. This provides Perfect Forward Secrecy.
Asymmetric-Key Ratchet (DH Ratchet)
Periodically, both parties perform a new Diffie-Hellman (DH) key exchange, generating a fresh shared secret:
Alice's DH key: a
Bob's DH key: b
Shared secret: g^(ab)
Each DH exchange âratchetsâ the symmetric key chain forward with new entropy. Even if an attacker compromises a key, the next DH exchange generates a completely independent key chain, providing Post-Compromise Security.
2. X3DH: Extended Triple Diffie-Hellman
The Signal Protocol uses X3DH (Extended Triple Diffie-Hellman) for initial key agreement, especially for asynchronous messaging where the recipient is offline.
The Key Types
Each user maintains three types of keys:
- Identity Key (IK): Long-term public key for the account (Curve25519)
- Signed Prekey (SPK): Medium-term key signed by the identity key, rotated periodically
- One-Time Prekeys (OTPKs): Single-use keys consumed during key agreement
How X3DH Works
When Alice wants to message Bob for the first time:
-
Bob publishes a prekey bundle to the server:
- Identity Key (IK_B)
- Signed Prekey (SPK_B) + signature
- One-Time Prekeys (OTPK_Bâ, OTPK_Bâ, âŠ)
-
Alice fetches Bobâs bundle and performs four DH operations:
DHâ = DH(IK_A, SPK_B) DHâ = DH(EK_A, IK_B) DHâ = DH(EK_A, SPK_B) DHâ = DH(EK_A, OTPK_B) // if availableWhere
EK_Ais Aliceâs ephemeral key for this session. -
Shared secret:
SK = KDF(DHâ || DHâ || DHâ || DHâ)
This triple (or quadruple) DH exchange provides mutual authentication and forward secrecy.
3. PQXDH: Post-Quantum X3DH
To prepare for quantum computers, Signal introduced PQXDH, which adds a post-quantum Key Encapsulation Mechanism (KEM) to X3DH.
Instead of relying solely on elliptic curve DH (vulnerable to Shorâs algorithm), PQXDH combines:
- Classical X3DH (Curve25519)
- Kyber KEM (NIST-selected post-quantum algorithm)
The final shared secret: SK = KDF(X3DH_secret || Kyber_secret)
Even if a quantum computer breaks Curve25519, the Kyber component keeps messages secure.
How Chatter Implements Signal Protocol
Now letâs see how these cryptographic primitives translate into code.
Prekey Bundle Management
In Chatter, the Auth Service manages prekey bundles. When a user registers, they upload their identity key, signed prekey, and a batch of one-time prekeys.
Uploading Prekeys
// internal/services/auth/handlers/signal_prekeys.go
func (h *SignalPreKeysHandler) UploadPreKeyBundle(
ctx context.Context,
req *authpb.UploadPreKeyBundleRequest,
) (*authpb.UploadPreKeyBundleResponse, error) {
// Validate the signed prekey signature
if !crypto.VerifySignature(
req.IdentityKey,
req.SignedPreKey.PublicKey,
req.SignedPreKey.Signature,
) {
return nil, status.Error(codes.InvalidArgument, "invalid signature")
}
// Store the bundle
err := h.service.StorePreKeyBundle(ctx, &auth.PreKeyBundle{
UserID: userID,
DeviceID: req.DeviceId,
IdentityKey: req.IdentityKey,
SignedPreKey: req.SignedPreKey,
OneTimePreKeys: req.OneTimePreKeys,
KyberPreKey: req.KyberPreKey, // Post-quantum component
})
return &authpb.UploadPreKeyBundleResponse{}, nil
}
The prekeys are stored in PostgreSQL with atomic operations to ensure each one-time prekey is consumed exactly once.
Fetching Prekeys with Idempotency
When Alice wants to message Bob, she fetches his prekey bundle. The challenge: one-time prekeys must be truly one-time to prevent replay attacks.
Chatter uses an idempotency key with atomic PostgreSQL updates:
// internal/services/auth/service/signal_protocol.go
func (s *serviceImpl) GetPreKeyBundleWithIdempotency(
ctx context.Context,
targetUserID string,
deviceID uint32,
idempotencyKey string,
) (*auth.PreKeyBundle, error) {
// Atomic: claim a one-time prekey OR return cached bundle for idempotency key
query := `
WITH claimed AS (
DELETE FROM signal_one_time_prekeys
WHERE user_id = $1 AND device_id = $2
AND key_id = (
SELECT key_id FROM signal_one_time_prekeys
WHERE user_id = $1 AND device_id = $2
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING key_id, public_key
)
INSERT INTO prekey_bundle_cache (idempotency_key, user_id, device_id, otpk_id, otpk_key)
SELECT $3, $1, $2, key_id, public_key FROM claimed
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING otpk_id, otpk_key
`
var otpk OneTimePreKey
err := s.db.QueryRow(ctx, query, targetUserID, deviceID, idempotencyKey).Scan(&otpk.ID, &otpk.PublicKey)
// Fetch identity key and signed prekey
bundle := s.getBasePreKeyBundle(ctx, targetUserID, deviceID)
bundle.OneTimePreKey = &otpk
return bundle, nil
}
Why idempotency matters: If Aliceâs request times out, she might retry. Without idempotency, sheâd consume multiple one-time prekeys, causing desynchronization. The idempotency key ensures retries return the same prekey.
Sealed Sender: Hiding Metadata
The Signal Protocol doesnât just encrypt message contentâit also hides who sent the message from the server. This is called Sealed Sender.
Instead of the server knowing âAlice sent a message to Bob,â it only sees âsomeone sent an encrypted blob to Bob.â
How Sealed Sender Works
- Certificate issuance: Alice requests a Sender Certificate from the server
- Message encryption: Alice encrypts the message with Bobâs identity key
- Certificate attachment: Alice attaches her certificate (proving sheâs authorized) to the encrypted message
- Server relay: Server relays the blob to Bob without knowing who sent it
- Bobâs verification: Bob decrypts, extracts the certificate, and validates it against the serverâs certificate chain
Certificate Generation in Chatter
Chatter uses a three-tier certificate chain:
Root CA (Vault KMS)
â signs
Intermediate CA (deterministic key)
â signs
Sender Certificate (user's identity key + metadata)
Hereâs how we generate the intermediate certificate:
// internal/services/auth/service/sealed_sender.go
func (s *serviceImpl) getOrGenerateServerCertificate(
ctx context.Context,
) (*signalv1.ServerCertificate, []byte, error) {
// Check cache
if cached := s.serverCertCache.Get(); cached != nil {
return cached.cert, cached.key, nil
}
// Derive intermediate key deterministically from Vault KMS
rootKeyID := "chatter-signal-root-ca"
intermediateKeySeed := s.vault.DeriveKey(ctx, rootKeyID, "intermediate-ca-v1")
intermediatePrivKey := ed25519.NewKeyFromSeed(intermediateKeySeed[:32])
intermediatePubKey := intermediatePrivKey.Public().(ed25519.PublicKey)
// Create intermediate certificate
intermediateCert := &signalv1.ServerCertificate{
Id: 1,
Key: intermediatePubKey,
NotBefore: timestamppb.New(time.Now().Add(-24 * time.Hour)),
NotAfter: timestamppb.New(time.Now().Add(365 * 24 * time.Hour)),
}
// Sign with root key using XEdDSA (Curve25519 â Ed25519 conversion)
certBytes, _ := proto.Marshal(intermediateCert)
rootSignature := s.vault.SignWithXEdDSA(ctx, rootKeyID, certBytes)
intermediateCert.Signature = rootSignature
// Cache globally
s.serverCertCache.Set(intermediateCert, intermediatePrivKey)
return intermediateCert, intermediatePrivKey, nil
}
XEdDSA: Bridging Curve25519 and Ed25519
Signal Protocol uses Curve25519 for DH key exchange but needs Ed25519 for signatures. XEdDSA is a signature scheme that lets you sign with a Curve25519 key by converting it to Ed25519 coordinates.
This is critical for Sealed Sender because:
- User identity keys are Curve25519 (for X3DH)
- Certificates need Ed25519 signatures (for verification)
The conversion happens in Chatterâs crypto layer, interfacing with Vault KMS for the root signing key.
Issuing Sender Certificates
When a user requests a certificate:
func (s *serviceImpl) RequestSenderCertificate(
ctx context.Context,
userID string,
deviceID uint32,
validityPeriod *durationpb.Duration,
) (*auth.RequestSenderCertificateResponse, error) {
// Fetch user's identity key
identityKey := s.getUserIdentityKey(ctx, userID, deviceID)
// Get intermediate certificate and key
intermediateCert, intermediateKey, err := s.getOrGenerateServerCertificate(ctx)
if err != nil {
return nil, err
}
// Create sender certificate
senderCert := &signalv1.SenderCertificate{
Sender: &signalv1.SenderCertificate_Sender{
UserId: userID,
DeviceId: deviceID,
IdentityKey: identityKey,
},
Expires: timestamppb.New(time.Now().Add(validityPeriod.AsDuration())),
ServerCert: intermediateCert,
}
// Sign with intermediate key
certBytes, _ := proto.Marshal(senderCert.Sender)
senderCert.Signature = ed25519.Sign(intermediateKey, certBytes)
return &auth.RequestSenderCertificateResponse{
Certificate: senderCert,
}, nil
}
Privacy by design: Sender certificates are never stored server-side. Theyâre issued on-demand and the client holds them. The server canât retroactively de-anonymize messages.
Message Service Integration
The Message Service validates Sealed Sender certificates when relaying messages:
func (s *MessageService) SendSealedMessage(
ctx context.Context,
encryptedMessage []byte,
recipientUserID string,
) error {
// Extract sender certificate from message envelope
envelope := &signalv1.SealedSenderMessage{}
proto.Unmarshal(encryptedMessage, envelope)
// Validate certificate chain
if !s.validateCertificateChain(envelope.SenderCertificate) {
return ErrInvalidCertificate
}
// Check certificate expiration
if envelope.SenderCertificate.Expires.AsTime().Before(time.Now()) {
return ErrCertificateExpired
}
// Relay to recipient without knowing who sent it
s.relay(recipientUserID, encryptedMessage)
return nil
}
The server only knows:
- The message is for Bob
- The certificate is valid
- It does not know itâs from Alice
Post-Quantum Readiness
Chatterâs Signal Protocol implementation supports Kyber prekeys (NISTâs post-quantum KEM standard).
When uploading prekeys, clients can optionally include a Kyber public key:
type PreKeyBundle struct {
IdentityKey []byte // Curve25519
SignedPreKey *SignedPreKey // Curve25519 + Ed25519 signature
OneTimePreKeys []*OneTimePreKey // Curve25519
KyberPreKey *KyberPreKey // Post-quantum KEM
}
During X3DH, if a Kyber prekey is available:
Classical_Secret = X3DH(IK, SPK, OTPK)
PQ_Secret = Kyber_Decapsulate(ciphertext, private_key)
Final_Secret = KDF(Classical_Secret || PQ_Secret)
This hybrid approach provides:
- Security today with Curve25519
- Security tomorrow against quantum computers with Kyber
Key Rotation and Hygiene
Chatter implements best practices for key lifecycle management:
Signed Prekey Rotation
Signed prekeys should be rotated periodically (every 7-30 days) to limit exposure:
func (s *serviceImpl) RotateSignedPreKey(ctx context.Context, userID string, deviceID uint32) error {
// Keep old signed prekey for a grace period
oldSPK := s.getCurrentSignedPreKey(ctx, userID, deviceID)
// Mark old key as deprecated (still valid for 7 days)
s.deprecateSignedPreKey(ctx, oldSPK.ID, time.Now().Add(7*24*time.Hour))
// New signed prekey becomes active
newSPK := s.generateNewSignedPreKey(userID, deviceID)
s.storeSignedPreKey(ctx, newSPK)
return nil
}
One-Time Prekey Replenishment
Clients monitor their OTPK pool and replenish when running low:
func (h *SignalPreKeysHandler) RefreshPreKeys(
ctx context.Context,
req *authpb.RefreshPreKeysRequest,
) (*authpb.RefreshPreKeysResponse, error) {
// Add new one-time prekeys
err := h.service.AddOneTimePreKeys(ctx, &auth.AddOneTimePreKeysParams{
UserID: userID,
DeviceID: req.DeviceId,
OneTimePreKeys: req.NewOneTimePreKeys,
})
return &authpb.RefreshPreKeysResponse{}, nil
}
Clients can check their prekey stats:
stats, err := client.GetPreKeyStats(ctx, &authpb.GetPreKeyStatsRequest{
DeviceId: myDeviceID,
})
if stats.OneTimePreKeysRemaining < 10 {
// Generate and upload 100 new OTPKs
newKeys := generateOneTimePreKeys(100)
client.RefreshPreKeys(ctx, &authpb.RefreshPreKeysRequest{
DeviceId: myDeviceID,
NewOneTimePreKeys: newKeys,
})
}
Security Properties Achieved
By implementing the Signal Protocol, Chatter provides:
| Property | Mechanism |
|---|---|
| Perfect Forward Secrecy | Symmetric key ratchet (KDF chain) |
| Post-Compromise Security | Asymmetric key ratchet (DH ratchet) |
| Asynchronous messaging | X3DH prekey bundles |
| Metadata privacy | Sealed Sender certificates |
| Quantum resistance | Hybrid Kyber + Curve25519 |
| Deniable authentication | XEdDSA signatures (no third-party proof) |
Design Decisions and Trade-offs
Why Not TLS?
TLS provides transport security (server â client), but the server can still read messages. Signal Protocol provides end-to-end encryptionâeven Chatterâs servers canât decrypt messages.
Vault KMS for Root CA
We use HashiCorp Vault for the root signing key instead of storing it in the database:
- Security: Key never leaves Vaultâs HSM
- Auditability: All signing operations are logged
- Rotation: Can rotate root CA without code changes
Stateless Certificates
Sender certificates are not stored server-side. This means:
- Privacy: Canât retroactively de-anonymize senders
- Scalability: No database writes for certificate issuance
- Trade-off: Canât revoke individual certificates (rely on expiration)
For revocation, we use short validity periods (24-48 hours) and rely on clients refreshing certificates regularly.
Conclusion: Cryptographic Elegance Meets Practical Engineering
The Signal Protocol is a masterclass in applied cryptography: it combines DH key exchange, symmetric ratcheting, KDF chains, and signature schemes into a coherent system thatâs both secure and usable.
Implementing it in Chatter required:
- Deep understanding of X3DH and Double Ratchet
- Careful key lifecycle management (generation, storage, rotation, deletion)
- Atomic database operations for one-time prekey consumption
- Certificate chain validation with XEdDSA
- Post-quantum readiness with Kyber
The result: end-to-end encrypted messaging with metadata privacy, forward secrecy, and quantum resistanceâall while supporting asynchronous communication and multi-device accounts.
Key Takeaways:
- Signal Protocol = X3DH + Double Ratchet + Sealed Sender
- Perfect Forward Secrecy from symmetric key ratcheting
- Post-Compromise Security from DH ratcheting
- Metadata privacy from Sealed Sender certificates
- Quantum resistance from hybrid Kyber + Curve25519
- Idempotency is critical for one-time prekey consumption
- Vault KMS for root certificate signing keys
- Stateless certificates for privacy and scalability
Cryptography isnât just mathâitâs about building systems that protect users even when everything else fails.