Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Cryptography

Tags:
  • go
  • cryptography
  • double-ratchet
  • signal-protocol
  • x25519
  • forward-secrecy

Signal's Double Ratchet in Go: A Toy Forward-Secret Messenger

Suppose someone gets hold of a single symmetric key from your messaging app, maybe pulled out of process memory, maybe recovered from a swap file during a forensic image. If that app reuses one key for the whole conversation, game over: every message you have ever sent or received in that thread is readable, and so is everything you send tomorrow until you rotate the key manually. Signal's protocol is built specifically to make that scenario boring. Compromise a key and, at worst, you get the handful of messages sent around that point. Messages before it stay unreadable because the keys that protected them no longer exist anywhere to be recovered. Messages after it become unreadable again within a round trip or two, because the protocol keeps injecting fresh randomness that the attacker never saw.

Those are two separate properties with two separate names. Forward secrecy means a key compromise today does not expose yesterday's messages. Post-compromise security (sometimes called future secrecy or "healing") means a key compromise today does not necessarily expose tomorrow's messages either, provided the two parties keep exchanging fresh key material. The double ratchet gets both from two mechanisms running side by side: a symmetric-key ratchet that burns through a chain of HMAC-derived keys one per message, and a Diffie-Hellman ratchet that replaces the chain itself with a freshly-keyed one every time the conversation changes direction.

We're going to build a working version of this in Go, using nothing beyond the standard library: crypto/ecdh for X25519, crypto/hmac and crypto/sha256 for the key derivation, and crypto/aes plus crypto/cipher for AES-256-GCM as the actual message cipher. It is a toy in some specific, deliberate ways covered at the end, most importantly it assumes the two parties already share an initial secret (real Signal gets that from the X3DH handshake) and it does not handle out-of-order or dropped messages. What it does correctly is the ratchet itself: the state machine that makes forward secrecy and post-compromise security actually happen.

The symmetric-key ratchet

Within a single sending direction, each message needs its own key, derived from the last one in a way that cannot be run backwards. HMAC is exactly the tool for this: feed a chain key into HMAC twice with two different one-byte labels, and you get a new chain key and a message key that are computationally unrelated to each other and to the chain key that produced them.

func hmacSum(key, data []byte) []byte {
	mac := hmac.New(sha256.New, key)
	mac.Write(data)
	return mac.Sum(nil)
}

// kdfCK advances a sending or receiving chain by one step, returning
// the next chain key and the message key for the current step.
func kdfCK(chainKey []byte) (nextChainKey, messageKey []byte) {
	nextChainKey = hmacSum(chainKey, []byte{0x02})
	messageKey = hmacSum(chainKey, []byte{0x01})
	return
}

Because HMAC is a one-way function, knowing chain key number 40 tells you nothing about chain key number 39. That single property is where forward secrecy inside a chain comes from: once you have derived a message key and used it, you throw the old chain key away (in a real client you would explicitly zero it), and there is no path back to it.

The Diffie-Hellman ratchet

A symmetric chain on its own only gets you forward secrecy for as long as nobody ever recovers the current chain key itself, at which point every future message on that chain is exposed too. Post-compromise security needs something that periodically injects secret material the attacker never had a chance to see, and the only way to do that between two parties who are not currently in the same room is a fresh Diffie-Hellman exchange.

So every time a party switches from receiving to sending, it generates a brand new X25519 key pair and does a DH exchange with the last public key it received from the other side. The result gets mixed into the root key via a small HKDF-style construction (extract-then-expand, as per RFC 5869), which forks off both a new root key and a new chain key:

func hkdf(salt, ikm, info []byte, length int) []byte {
	extractor := hmac.New(sha256.New, salt)
	extractor.Write(ikm)
	prk := extractor.Sum(nil)

	var t, okm []byte
	for i := byte(1); len(okm) < length; i++ {
		expander := hmac.New(sha256.New, prk)
		expander.Write(t)
		expander.Write(info)
		expander.Write([]byte{i})
		t = expander.Sum(nil)
		okm = append(okm, t...)
	}
	return okm[:length]
}

// kdfRK folds a new DH output into the root key, producing a fresh
// root key and a fresh chain key for the direction that just changed.
func kdfRK(rootKey, dhOutput []byte) (newRootKey, chainKey []byte) {
	out := hkdf(rootKey, dhOutput, []byte("go-double-ratchet-toy"), 64)
	return out[:32], out[32:]
}

The reason this buys post-compromise security is that the new key pair generated for this step is fresh randomness, never derived from anything old. Even if an attacker has the entire previous root key and every chain key in flight, they cannot predict the new private key, so they cannot compute the new DH output, so they cannot derive the new root or chain keys either. The compromise heals itself as soon as one more round trip happens.

Putting the two ratchets together

The state a party needs to track is small: its own current DH key pair, the other side's last known public key, the root key, and the current sending and receiving chain keys.

type Ratchet struct {
	dhSelf   *ecdh.PrivateKey
	dhRemote *ecdh.PublicKey

	rootKey   []byte
	chainSend []byte
	chainRecv []byte
}

func newRatchetAlice(sharedSecret []byte, bobPublic *ecdh.PublicKey) (*Ratchet, error) {
	aliceKeyPair, err := ecdh.X25519().GenerateKey(rand.Reader)
	if err != nil {
		return nil, err
	}
	dhOut, err := aliceKeyPair.ECDH(bobPublic)
	if err != nil {
		return nil, err
	}
	rootKey, chainSend := kdfRK(sharedSecret, dhOut)
	return &Ratchet{
		dhSelf:    aliceKeyPair,
		dhRemote:  bobPublic,
		rootKey:   rootKey,
		chainSend: chainSend,
	}, nil
}

func newRatchetBob(sharedSecret []byte, bobKeyPair *ecdh.PrivateKey) *Ratchet {
	return &Ratchet{dhSelf: bobKeyPair, rootKey: sharedSecret}
}

Alice starts with a sending chain because she already knows Bob's public key when the conversation begins. Bob starts with neither chain: he only gets one once Alice's first message arrives and tells him which public key she used, which is exactly how the real protocol works too. Encrypting is a chain step followed by an AEAD seal:

func seal(key, plaintext []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

func open(key, ciphertext []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	if len(ciphertext) < gcm.NonceSize() {
		return nil, errors.New("ciphertext too short")
	}
	nonce, ct := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
	return gcm.Open(nil, nonce, ct, nil)
}

func (r *Ratchet) Encrypt(plaintext []byte) (*ecdh.PublicKey, []byte, error) {
	var messageKey []byte
	r.chainSend, messageKey = kdfCK(r.chainSend)
	ciphertext, err := seal(messageKey, plaintext)
	if err != nil {
		return nil, nil, err
	}
	return r.dhSelf.PublicKey(), ciphertext, nil
}

Decrypting is where the DH ratchet actually fires, triggered by noticing the sender used a public key you have not seen before:

func (r *Ratchet) Decrypt(senderPublic *ecdh.PublicKey, ciphertext []byte) ([]byte, error) {
	if r.dhRemote == nil || !senderPublic.Equal(r.dhRemote) {
		if err := r.dhRatchetStep(senderPublic); err != nil {
			return nil, err
		}
	}
	var messageKey []byte
	r.chainRecv, messageKey = kdfCK(r.chainRecv)
	return open(messageKey, ciphertext)
}

func (r *Ratchet) dhRatchetStep(theirPublic *ecdh.PublicKey) error {
	r.dhRemote = theirPublic

	dhOut, err := r.dhSelf.ECDH(theirPublic)
	if err != nil {
		return err
	}
	r.rootKey, r.chainRecv = kdfRK(r.rootKey, dhOut)

	newSelf, err := ecdh.X25519().GenerateKey(rand.Reader)
	if err != nil {
		return err
	}
	r.dhSelf = newSelf

	dhOut, err = r.dhSelf.ECDH(theirPublic)
	if err != nil {
		return err
	}
	r.rootKey, r.chainSend = kdfRK(r.rootKey, dhOut)
	return nil
}

Notice the ratchet step does two root-key derivations, not one: first it uses the old key pair against the newly-arrived public key to derive the receiving chain (so it can decrypt this message with keys the sender actually used), then it generates a brand new key pair for itself and derives the sending chain from that. That second half is what makes the whole thing self-healing: from this point on, replying uses a key pair the other side has never seen, and any future messages need it to be broken fresh.

Running it

func main() {
	sharedSecret := make([]byte, 32) // pretend X3DH already produced this
	if _, err := rand.Read(sharedSecret); err != nil {
		panic(err)
	}

	bobKeyPair, err := ecdh.X25519().GenerateKey(rand.Reader)
	if err != nil {
		panic(err)
	}

	alice, err := newRatchetAlice(sharedSecret, bobKeyPair.PublicKey())
	if err != nil {
		panic(err)
	}
	bob := newRatchetBob(sharedSecret, bobKeyPair)

	pub, ct, err := alice.Encrypt([]byte("hey, you free later?"))
	if err != nil {
		panic(err)
	}
	got, err := bob.Decrypt(pub, ct)
	if err != nil || string(got) != "hey, you free later?" {
		panic("round trip failed")
	}
	fmt.Printf("bob read:   %q\n", got)

	pub, ct, err = bob.Encrypt([]byte("depends, what did you have in mind"))
	if err != nil {
		panic(err)
	}
	got, err = alice.Decrypt(pub, ct)
	if err != nil || string(got) != "depends, what did you have in mind" {
		panic("round trip failed")
	}
	fmt.Printf("alice read: %q\n", got)
}

Run that and it prints both messages back correctly, but the interesting part is invisible: every one of those four HMAC and DH operations used a key that existed nowhere else a moment before and gets discarded a moment later. Dump the process memory after the second exchange and you will find Alice and Bob's current key pairs and chain keys, which is enough to read the next message or two, but nothing that lets you derive the message key that protected "hey, you free later?". That key came from a chain that has already advanced twice and a root key that has already been replaced by two DH ratchet steps.

What the toy leaves out

This covers the actual ratchet, which is the part people usually mean when they say "Signal's double ratchet", but a production implementation needs more:

  • X3DH to establish the initial shared secret and Bob's first key pair asynchronously, without either party needing to be online at the same time.
  • Storage for skipped message keys, so that a message arriving out of order or a message that never arrives at all does not desynchronise the chains permanently.
  • Header encryption, so that the DH public keys and message counters travelling alongside the ciphertext do not leak metadata about the conversation's shape.
  • Actual key zeroing rather than letting old byte slices sit around for the garbage collector, and defence against a peer who sends an implausible number of DH ratchet steps to force excessive key generation.

Signal's own specification covers all of this in detail, and it is worth reading even if you never implement it yourself, because it is one of the rare cryptographic designs where the state machine is the hard part, not the primitives. HMAC, X25519 and AES-GCM are all decades-old, well-understood building blocks. Nearly every real-world bug in double ratchet implementations has come from the bookkeeping around them: getting the skipped-key storage wrong, mixing up which chain belongs to which direction, or forgetting that Bob starts the conversation with no sending chain at all.