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:

  1. Bob publishes a prekey bundle to the server:

    • Identity Key (IK_B)
    • Signed Prekey (SPK_B) + signature
    • One-Time Prekeys (OTPK_B₁, OTPK_B₂, 
)
  2. 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 available

    Where EK_A is Alice’s ephemeral key for this session.

  3. 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

  1. Certificate issuance: Alice requests a Sender Certificate from the server
  2. Message encryption: Alice encrypts the message with Bob’s identity key
  3. Certificate attachment: Alice attaches her certificate (proving she’s authorized) to the encrypted message
  4. Server relay: Server relays the blob to Bob without knowing who sent it
  5. 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:

PropertyMechanism
Perfect Forward SecrecySymmetric key ratchet (KDF chain)
Post-Compromise SecurityAsymmetric key ratchet (DH ratchet)
Asynchronous messagingX3DH prekey bundles
Metadata privacySealed Sender certificates
Quantum resistanceHybrid Kyber + Curve25519
Deniable authenticationXEdDSA 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:

  1. Signal Protocol = X3DH + Double Ratchet + Sealed Sender
  2. Perfect Forward Secrecy from symmetric key ratcheting
  3. Post-Compromise Security from DH ratcheting
  4. Metadata privacy from Sealed Sender certificates
  5. Quantum resistance from hybrid Kyber + Curve25519
  6. Idempotency is critical for one-time prekey consumption
  7. Vault KMS for root certificate signing keys
  8. Stateless certificates for privacy and scalability

Cryptography isn’t just math—it’s about building systems that protect users even when everything else fails.