Why Reusing a Signing Key for Encryption Breaks Both
In 2017 researchers signed an arbitrary message with Facebook's TLS private key. They never stole the key. They abused a server that decrypted RSA messages and leaked, through its error behaviour, whether the padding was valid. Same keypair, two jobs, and a flaw in one job became a forgery in the other.
That attack was called ROBOT, and it is the cleanest argument I know for a boring rule: one key, one purpose. This post covers why the rule exists, where people break it by accident, and how to follow it without keeping a pile of secrets.
Why would anyone share a key?
Because it looks tidy. One keypair, one fingerprint to publish, one thing to back up. PGP users have done this for decades, and plenty of home-grown systems do it too: "here is my public key, encrypt to it and verify my signatures with it".
The trouble is that signing and decrypting are different protocols that happen to use the same private number. Security proofs for each one assume the key is used for that one job only. Mix them and the proofs no longer apply, and sometimes there is a real attack in the gap.
RSA: a decryption oracle is a signing oracle
Textbook RSA decryption and signing are the same operation: raise something to the power d modulo n. The only difference is the padding wrapped around the input. If an attacker can learn something about decryptions, they can learn how to produce signatures.
This is what Bleichenbacher-style attacks do. Send many tweaked ciphertexts, watch whether the server complains about the padding, and you slowly work out the private operation on a value of your choosing. Choose the value to be a correctly padded signature input for a message of your choice, and you have a forged signature.
- ROBOT (2017) used a decryption oracle in TLS servers to sign with their private keys.
- DROWN (2016) used servers still speaking SSLv2 to attack TLS sessions that shared the same RSA key.
In both cases the vulnerable feature might have been something the operator thought was switched off or irrelevant. The shared key is what turned it into a problem for everything else.
Quick detour: Ed25519 and X25519 are the same curve
Hang on, this one is interesting. Ed25519 (signatures) and X25519 (key agreement) live on curves that are birationally equivalent, so you can convert an Ed25519 public key into an X25519 one. libsodium even ships functions for it, and Signal's XEdDSA does something similar on purpose.
So can you do it safely? In specific, carefully analysed constructions, yes. The published analysis I am aware of covers particular combinations, not "use it however you like". If you are not following a published design to the letter, treat the conversion as a trap that looks like a feature.
The non-cryptographic reasons are just as good
Even if the maths never bit you, the lifecycles of the two keys differ:
- An encryption key often needs to be backed up or escrowed, because losing it means losing data. A signing key should usually exist in one place only, because a copy is a forgery risk.
- When you rotate a signing key, old signatures stay verifiable. When you rotate an encryption key, you must keep the old one to read old data.
- If a signing key is compromised, you revoke it. If you have also been encrypting to it, you now have to re-encrypt everything.
- Signatures can serve as proof of who did something. If someone else (a backup service, a recovery agent) can hold the same key for decryption, that proof gets weaker.
Split keys let you give each one the handling it needs. A shared key forces you to pick the worst of both.
Deriving two keys from one secret in Go
You do not need two secrets to look after. Keep one master seed and derive a key per purpose with HKDF, using a distinct info label for each. The labels keep the outputs independent, which is the whole point.
package main
import (
"crypto/ecdh"
"crypto/ed25519"
"crypto/hkdf"
"crypto/sha256"
"fmt"
"log"
)
func derive(master []byte, label string) []byte {
k, err := hkdf.Key(sha256.New, master, nil, label, 32)
if err != nil {
log.Fatal(err)
}
return k
}
func main() {
master := make([]byte, 32) // load from your real secret store, never zeros
sigKey := ed25519.NewKeyFromSeed(derive(master, "example.com/v1/signing"))
encKey, err := ecdh.X25519().NewPrivateKey(derive(master, "example.com/v1/encryption"))
if err != nil {
log.Fatal(err)
}
fmt.Printf("sign pub: %x\n", sigKey.Public())
fmt.Printf("enc pub: %x\n", encKey.PublicKey().Bytes())
}
The crypto/hkdf package arrived in Go 1.24; on older versions use golang.org/x/crypto/hkdf, which has a different API. The two public keys are unrelated, so publishing both leaks nothing about either.
What this does and does not fix
- It stops a flaw in the encryption path from helping an attacker forge signatures, and the reverse.
- It lets you rotate or escrow one key without touching the other, if you also keep the labels versioned (hence
v1). - It does not help if the master seed itself leaks: everything derived from it falls together. If you want the keys to have separate lifecycles, store them separately instead of deriving them from one seed.
The derivation route is a convenience for small systems. For anything with real recovery or compliance requirements, generate the keys independently and give each its own storage and its own backup policy.
Check your own systems
Look for the places a key quietly does double duty: an RSA key used for both TLS decryption and code signing, a PGP key with the sign and encrypt capabilities on the primary key, or a single "identity key" in a custom protocol. If you find one, the fix is to issue new keys, not to hope the flaw stays theoretical.