Imagine you live in an apartment building, and the resident’s association has spent the past two years saving for a new elevator. The fund is sitting in a bank account, and to protect it, the association sets one rule: no money leaves that account unless all three executives approve. The chairman, the secretary and the treasurer.
Let’s call the Chairman “Frank”, the Secretary is “Carol”, and the treasurer is “Oscar”.
Checking three separate approvals on every withdrawal is slow and keeping three sets of approval records for every transaction eats up valuable storage. So the bank offers the association a shortcut. Each executive picks a secret PIN and registers a public number derived from the PIN with the bank. The bank adds the three public numbers together and stores only the total. If the executives registered 3, 3 and 3, the bank stores 9 and forgets the rest. This 9 becomes the number the bank cares about moving forward when it comes to making a withdrawal from the account, and because of the way the maths works, a proof or figure that matches that total can only be produced when all three executives use their PINs together.
From then on, a withdrawal is approved if someone presents proof that matches the total number stored by the bank and this becomes one check instead of three and one stored record instead of three. Everyone agrees, because it is faster and the maths looks sound.
Frank registers his public number first as 8, Carol registers hers as 5.
Oscar registers last.
Oscar has been watching, Frank’s and Carol’s numbers are public, because public numbers are meant to be public as the name implies. Oscar also has a secret PIN of his own whose public number which is 12, and he can prove ownership of 12 any time he likes. So instead of registering 12, he does a small piece of arithmetic here:
12 - 8 - 5 = -1 Now Oscar registers -1 as his public number with the bank.
The bank adds everything up the way it always does.
8 + 5 + (-1) = 12 The total the bank now expects is 12. Oscar walks in alone, proves he can sign for 12, and the withdrawal goes through. The bank’s records show that all three executives approved it but Frank and Carol never signed. Their numbers are still in the system, they just no longer do anything and Oscar controls everything and can move the funds locked in the bank at his will, whenever he likes.
Nobody broke the bank’s maths. Nobody guessed a PIN. Oscar simply registered last and chose a number that cancelled everyone else out.
That, almost exactly, is the Rogue Key Attack on BLS signature aggregation. In this article, the building, the elevator and the bank are made up but the arithmetic is not.
Why Anyone Adds Keys Together
BLS signatures have a property that most signature schemes don’t. You can take signatures from thousands of different signers, add them together and end up with a single signature that is still only 96 bytes. A verifier checks that one object once, and it knows the whole group signed.
That is a big deal for any system where many parties need to agree on something often and cheaply. Consensus protocols, threshold wallets and multi-party custody setups all want it.
To see where the trouble comes from, you only need three rules.
Private key: s (a secret number) Public key: P = s·G (G is a fixed, public starting point on the curve) Signature: σ = s·H(m) (H turns the message m into a point on the curve) Where does m come from? It's whatever the group is agreeing to, written out as bytes. In our story, it's the withdrawal slip itself with the message "Withdraw $40,000 from the elevator fund". Every signer and the verifier start from the exact same bytes, and H turns those bytes into a point on the curve so it can be multiplied like everything else. Change a single character on the slip, even a comma in the amount, and H(m) lands somewhere completely different, and every signature over the old version stops matching.
Your public key is your secret multiplied by a known point. Your signature is the same secret multiplied by the message. Nobody can divide the secret back out, which is what keeps it secret.
To check a signature, you use a pairing, written e(·,·). A pairing is a function with one useful trick where it lets a secret multiplier move from one side of the check to the other. So the verifier tests:
e(G, σ) == e(P, H(m)) If the signer really used s, both sides come out to the same value. The verifier confirms the same secret was used in the key and in the signature, without ever learning what it was.
Now back to the building, before Oscar tried anything. Say all three executive sign the same message honestly, each with their real private keys. Frank’s is 8, Carol’s is 5 and Oscar’s is 12. (Real private keys are around 77 digits long, small numbers just keep the arithmetic readable).
Frank: private key 8, public key 8·G, signature 8·H(m) Carol: private key 5, public key 5·G, signature 5·H(m) Oscar: private key 12, public key 12·G, signature 12·H(m) Add the keys. Add the signatures.
Aggregate key: 8·G + 5·G + 12·G = 25·G Aggregate signature: 8·H(m) + 5·H(m) + 12·H(m) = 25·H(m) And one check covers all three of them:
e(G, 25·H(m)) == e(25·G, H(m)) We have three signers, one check. Ten thousand signers, still one check. That is the whole appeal, and it is also exactly the shortcut the bank took with the elevator fund. It stored one total and stopped looking at the individuals behind it.
In the honest version, the total the bank stores is 25, and the only way to produce a matching signature is for all three executives to sign. Oscar’s trick is to make that total 12 instead which is the one number he can sign for alone.
So the question becomes: what stops one of those individuals from choosing their number after seeing everyone else's?
With naive aggregation, nothing does.
The Same Attack, on a Real Curve
Everything Oscar did with the bank works the same way with curve points, because curve points can be added and subtracted just like the bank’s numbers.
R = X - F - C Aggregate key = F + C + R = F + C + (X - F - C) = X F and C are Frank’s and Carol’s public keys. X is a key Oscar genuinely owns, with private key x. He publishes R as his public key. Once everything is summed, the aggregate is X, and he signs alone:
σ = x·H(m) e(G, σ) == e(F + C + R, H(m)) e(G, x·H(m)) == e(X, H(m)) ✓ The check passes.
You might wonder what happens if Frank’s and Carol's keys are already much "bigger" than Oscar’s target. Say Frank’s public number is 900 and Carol’s is 750. Together they make 1,650, far past the 12 Oscar wants. The large number doesn’t help them, Oscar just does the same subtraction:
R = 12 - 900 - 750 = -1638 900 + 750 + (-1638) = 12 His rogue key is allowed to be negative, a negative key isn’t a problem. On a curve, negating a point is easy in the sense that you just flip it across the x-axis. In fact, curve arithmetic doesn’t really have negative numbers at all, it wraps around, a bit like a clock. Picture a toy clock with 101 positions instead of 12, when you count past 100 and you land back at 0. On that clock, -1638 lands on position 79, because -1638 + (17 x 101) = 79. So Oscar registers a perfectly ordinary-looking 79
900 + 750 + 79 = 1729 1729 on 101-position clock = 1729 - (17 x 101) = 12 Exactly his target. Real BLS12-381 arithmetic runs on a clock with a 77-digit number of positions instead of 101, but the idea is identical. However large Frank’s and Carol’s keys are, there is always some valid point that lands the total exactly where Oscar wants it. Size never protects anyone and no combination of honest keys is large enough to block him.
This isn't a whiteboard hypothetical either. Here it is running against py_ecc, a Python implementation of BLS12-381:
from py_ecc.bls import G2ProofOfPossession as bls from py_ecc.bls.g2_primitives import pubkey_to_G1, G1_to_pubkey from py_ecc.optimized_bls12_381 import add, neg frank_sk, carol_sk, oscar_sk = 8, 5, 12 F = bls.SkToPk(frank_sk) C = bls.SkToPk(carol_sk) X = bls.SkToPk(oscar_sk) # Rogue key: R = X - F - C R = G1_to_pubkey(add(pubkey_to_G1(X), neg(add(pubkey_to_G1(F), pubkey_to_G1(C))))) msg = b"withdraw $40,000 from the elevator fund" forged = bls.Sign(oscar_sk, msg) # Oscar signs alone print(bls.FastAggregateVerify([F, C, R], msg, forged)) # True It prints True. The forgery verifies.
The library isn't broken. FastAggregateVerify is documented to assume that every key passed into it has already been verified by a proof of possession check, you skip that check and this is what you get. The bank, in other words, was supposed to ask Oscar a question at registration, and it didn't.
There is one detail in all of this that matters more than everything else. Oscar knows the public value of -1, because he calculated it. He does not know the private key behind it. That private key would be 12 - f - c, where f and c are Frank’s and Carol’s secrets. He has never seen those and never will.
Every defense against this attack is built on that one gap.
The First Fix: Proof of Possession
The mistake wasn't adding the numbers. The mistake was accepting Oscar’s number without asking him to use it. If the clerk had said, "Before we register -1, sign this slip with the PIN that goes with -1,". Oscar would have been stuck, because the PIN for -1 is 12 - f - c and he can't work it out as it depends on Frank’s and Carol’s secret, so he can't sign, and the bank refuses the registration and the fund is safe.
That is a proof of possession. Before a key is allowed anywhere near an aggregate, its owner signs the key itself, this way an honest signer can do it in a second and a rogue key/signer can't do it at all.
Two extra lines in the script show it:
print(bls.PopVerify(R, bls.PopProve(oscar_sk))) # False: rogue key rejected print(bls.PopVerify(F, bls.PopProve(frank_sk))) # True: honest key accepted The nice part is how cheap this is once it's done. You verify each key once, at registration, and remember the result, after that, adding keys is safe forever, and every aggregate is still a single check. Some proof-of-stake networks work exactly this way, where the signature a validator submits when it joins covers its own public key, so it doubles as the proof.
There is one subtle rule. A proof of possession has to be signed under a different domain tag from ordinary signatures. If both used the same tag, a proof over your key's bytes could be replayed as a normal signature over any message that happened to match those bytes. Well built BLS libraries keep the two tags separate for you. If you're gluing curve operations together yourself, that job is on you.
The Second Fix: Distinct Messages
There is another way to stop the cancellation, you make sure the keys never get added in the first place.
The attack only works because everyone signs the same message, which is what allows the keys to be summed. If each signer signs something different, there is no sum to manipulate.
There are two ways to get there. The basic scheme simply refuses to aggregate unless every message is different, while message augmentation goes further by making sure each signer automatically signs their own public key joined to the message, P || m, rather than just m.
def sign(sk, message): pk = sk_to_pk(sk) augmented_message = pk + message return core_sign(sk, augmented_message) Back at the bank, that's like every executive signing a slip with their own name printed at the top. Oscar’s slip would have to read -1 || withdraw, and signing that still needs the PIN for -1, which he still doesn't have.
The price is speed. Since every signer's message is now unique, the verifier can't fold the keys into one. Checking n signers takes n + 1 pairings instead of 2. You keep the small signature, but you lose the one-check shortcut that made aggregation attractive in the first place.
The Third Fix: Key Coefficients
The last approach keeps the single check and doesn't need a registration step. It changes how the keys are added.
Instead of blindly summing up raw keys, the system passes the keys through a hash function first to generate a unique multiplier for each person. This way, you multiply each key by a number derived from a hash of the whole group first:
aᵢ = H(i, {every public key in the group}) Aggregate key = a₁·P₁ + a₂·P₂ + a₃·P₃ + ... The hash function derives random looking multipliers for each person. So for instance, the hash gives Frank a weight of 2, Carol 3 and Oscar 4. Oscar registers -1, exactly as before:
(2 × 8) + (3 × 5) + (4 × -1) = 16 + 15 - 4 = 27 He wanted 12 but he got 27.
So he tries a different rogue key, but the weights come from a hash of every key in the group, including his. The moment he changes his key, every weight changes, his own included. He is aiming at a target that moves each time he adjusts his aim. Real weights are 128-bit or larger hash outputs, not small numbers like 3, 2 and 4, so there is no practical way to land on his target.
This is the approach Boneh, Drijvers and Neven proposed for BLS in 2018, and it's the same idea that MuSig and MuSig2 use for Schnorr signatures. It costs one extra multiplication per key during aggregation, and in exchange, groups can come together without anyone registering anything beforehand.
Where This Gets Messy
On paper, the fixes are clean. In real systems, the attack usually sneaks back in through the gaps between components rather than through the maths itself.
The most common failure is mixing paths. A system checks proofs of possession during normal registration, but then some other route, maybe an admin import, a migration script or a test fixture that made it to production, puts keys into the pool without that check. It doesn't matter how carefully the main path is guarded. If one door lets an unproven key in, Oscar only needs that door and everything is back in his control once again.
Choosing your own building blocks is the next trap. BLS libraries usually label their ciphersuites by the defence they use: _NUL_ for the basic scheme, _AUG_ for augmentation and _POP_ for proof of possession. Each comes with its own domain tags and rules. Taking raw curve operations and assembling aggregation yourself is how this bug usually ships, because none of the protections come along by default.
Validating keys matters too. Beyond the rogue key attack, a public key should never be the identity point (the curve's equivalent of zero) and should always sit in the correct subgroup. Most libraries include a KeyValidate step that checks both. A key that fails either check should never be stored, let alone aggregated.
Finally, an aggregate proves that the group signed. It says nothing about which members, if your system needs to know who participated, you carry that separately, usually as a participation bitfield where a row of ones and zeros sit next to the signature, marking which keys to include.
If you want one practical takeaway, it's this. Write a test that builds R = X - sum(other keys), tries to get it into your system, and asserts that the system refuses. The script above is most of that test already. If the test passes when it should fail, you have found your elevator fund problem before Oscar did, congrats!
Not Just a BLS Problem
Let’s go back to our building one more time here.
The bank's shortcut wasn't foolish. Storing one total instead of checking three signatures really is faster, and in a world where everyone registers honestly, it works perfectly. The flaw was quieter than that. The system trusted each executive's number without asking them to show they could actually use it, and it let the last person to register see everyone else's numbers before choosing their own.
That flaw isn't specific to BLS. It appears in any signature scheme where keys and signatures can be added together, and Schnorr signatures have that same property. When Bitcoin adopted Schnorr through Taproot, combining keys into one was never meant to be a matter of simply adding them. Wallets that aggregate keys use MuSig2 (BIP327), which applies the same weighting defense described above.
The same thinking applies to Ecash systems built on secp256k1, where multisig spending conditions currently list each public key and check each signature on its own, so there is no single aggregate key for a rogue key to cancel into. But the day someone proposes merging those keys into one to save space, the fixes in this piece stop being background reading and become requirements.
The general lesson fits in one question. If your system ever adds public keys together, ask what stops a participant from registering a key built by subtracting everyone else's. If the answer is "nothing," then somewhere in your system there is an Oscar who can sign for the whole group.
Adding keys was never the mistake. Adding keys you haven't checked is.
