The Handshake That Signs Anything You Hand It
The endpoint I built to prove I hold a secret will, if you ask it the right way, hand you a forged proof that you hold it too. I got there by following the documentation, line for line, exactly the way you are meant to.
The service is a bridge that sits on a live meeting and turns what gets said into text. Before it can receive a single word it has to pass the meeting platform’s webhook setup, and the platform is Zoom, so I can name it. The defect is not Zoom’s. It is in the code I wrote against Zoom’s instructions, and those instructions are public, which turns out to matter.
Two things the same secret does
When you register a webhook endpoint, Zoom checks that you actually own it. It sends an event, endpoint.url_validation, carrying a random plainToken. You answer with that token alongside an HMAC of it under your secret. Zoom recomputes the HMAC on its side, and a match proves you hold the secret without either side ever transmitting it. It is a challenge and a response, and it runs once, at setup.
From then on, Zoom signs every event it sends. The signature is v0= followed by an HMAC, under the same secret, over the string v0:<timestamp>:<raw body>. You recompute it over the bytes you received and compare in constant time. A match means the event genuinely came from Zoom and nobody altered it on the way.
Two mechanisms, one secret, and I transcribed each of them straight from the page. The first proves I hold the key. The second proves an event is real. Read on their own, both are correct. I have gone back to check, and I would write each of them the same way again.
The token nobody constrained
Look at what the first mechanism computes for you. Give it a plainToken and it returns the HMAC of that token under the secret. Any token. There is no rule about what it may contain, because it is supposed to be a short random string from Zoom, and I treated it as one.
Now look at what the second mechanism accepts. A valid signature is the HMAC of v0:<timestamp>:<body>.
The response half of the challenge is a signing function with no restriction on its input. The verification half accepts anything that function will sign. So the set of strings the endpoint will sign to prove me is a superset of the strings that authenticate a real event. Send the challenge responder a plainToken that reads v0:1700000000: followed by a forged event body. It cheerfully returns the HMAC of the whole thing, because to it that is just an unusually long token. And that HMAC is, byte for byte, the exact signature a genuine event with that timestamp and that body would carry.
The attacker never learns the secret. They ask the endpoint whose entire purpose is proving possession of the secret to sign their forgery, and it does, and then they post the forgery through the front door with the signature they were just given, and it verifies. The one mechanism built to demonstrate that I hold the key is the mechanism that manufactures the demonstration for someone who does not.
The service was disabled the whole time I was building the rest of it, so nothing was ever exposed. That is a fact about timing, not about the design.
The guard is a colon
The fix is four lines and faintly insulting in its size. A real Zoom plainToken is short and holds no colons. A signing payload starts with v0: and is full of them. So the responder now refuses any token that contains a colon or runs longer than a real one, returns nothing, and the endpoint answers 400. The signing payload can no longer be slipped in as a token, because it no longer passes for one.
What that check restores is a line that was never drawn. Two different kinds of message were being keyed with one secret and one HMAC, and nothing recorded which kind a given signature was a signature of. Cryptographers have a name for the missing piece: domain separation. Before you sign, you commit to the family of statement you are signing, so a signature over one kind can never be read as a signature over another. The webhook signatures already carried their domain, the literal v0: sitting in front of them. The validation response carried none, so its output was free to wander into the webhook’s domain and be accepted as native there.
The space between two correct pages
I did not decide to leave out domain separation. I never had the two mechanisms in one field of view. They live in different parts of the documentation. They run at different times, one at setup and one forever after. I built them on different evenings. Each is a faithful copy of a spec that is correct on its own page, and the bug is in neither copy. It is in the space between them, which no page describes, because the page about the handshake has no reason to mention the signature and the page about the signature has no reason to mention the handshake.
That is the part I keep turning over. There was no line I could have caught by reading my own code harder, because each half was right. The defect exists only in the composition, and the composition is exactly what a set of correct, separately documented steps never puts in front of you. Build the next bridge from the same pages and I build the same hole, unless what I carry across is not the code but a single sentence: these two mechanisms share a key, and one will sign whatever the other is handed.
For now the boundary I never drew is held by a single character. The responder refuses a colon. That is the whole repair: one piece of punctuation, standing in for a design decision I did not know I had skipped, keeping the thing that proves I hold the secret from proving it on a stranger’s behalf.