The Time It Takes to Say No
Some of the most elegant attacks in security never touch the thing they are after. They ask a system a question it will answer without hesitation, then read the secret off a side effect of the reply - how long it took, which error came back, how it behaved under load. The category is called an oracle attack, and defending against it is less about closing a single hole than about a posture: building so that no single observable channel ever carries the secret. This is what an oracle attack is, why serious systems close them in layers, and how one of Privt's most sensitive features is built to give one nothing.
The questions a system answers by accident
A system can be entirely correct and still leak, because an attacker is often not reading its answer at all - they are reading a byproduct of how the answer was produced. The most familiar example lives behind countless login screens: type a username that does not exist and the rejection returns a fraction of a second sooner than it does for a real account, because a real account sends the server off to verify a password while a fake one is turned away at once. Nobody built that difference on purpose, and yet it quietly turns the login form into an oracle for a question it never meant to answer, which is whether a given account exists.
The pattern generalizes, and once its shape is clear it is visible almost everywhere. Whenever handling the sensitive case costs a system something an outsider can observe - a little more time, a different error, a different amount of work - that difference becomes a channel, and the channel answers the very question the design tried to withhold. The content of the reply can be flawless while the behavior around it does the talking.
Why one lock is never enough
The instinct, on finding a channel like that, is to close it. The discipline is to assume you have not found all of them. Defense in depth is the practice of building so that a secret survives the failure of any single control: the reply gives nothing away, and neither does its timing, and neither does its error behavior, and neither does whatever a stolen copy of the database would yield, each an independent layer, none of them trusted to be the only thing between an attacker and the answer. A design that leans on one protection is a single bypass away from exposure, while a design that layers several forces an attacker to defeat all of them at once, which is a far harder thing to ask.
A secret should never rest on a single lock holding.
A worked example: a code that destroys an account
Privt has a feature we call burn, a single memorable phrase that destroys an account's encryption keys, meant to be used when you believe you have been compromised. Precisely because of what it does and when it is used, it is the kind of feature an attacker would probe from the outside in the hope of an oracle, some way to learn whether a phrase was real, whether an account exists, or whether a destruction has just happened. So it is designed to answer none of those questions, on several independent channels at once.
In the words it returns. Every submission answers {ok:true}, whether the phrase was correct, wrong, or belonged to no account at all, so the response body resolves nothing.
In what a breach would expose. An armed code is stored only as HMAC-SHA-256(pepper, "privt/burn/v3" ‖ code), where the pepper is a Worker secret that never enters the database, so a stolen database is inert and cannot be turned into an oracle for the codes it holds.
In whether it can be guessed at scale. The public endpoint sits behind a Cloudflare Turnstile challenge, and the codes are single-use, so the patient automated search a guessing oracle depends on never gets going.
And in the time it takes to answer. This is the subtle channel, and the one worth walking through. The obvious way to build the destruction is to do the work and then reply, and the work for a real account is larger than for a wrong guess: keys to shred, a tombstone to write, and then the account's stored items to delete, which grows with how much it held. Built the obvious way, the reply's timing would answer the question the words had refused, since a real code would return later than a miss, and a fuller account later than an empty one.
Figure 1 - The naive shape
The naive shape. If the endpoint replied only after the destruction had run and been awaited, a wrong code would return after a single lookup while a real code returned much later, since the largest part of the work, deleting the account's stored items, grows with how much the account held. The gap between the two reply times would confirm the code was real and estimate the account's size. This is the oracle a constant-time design sets out to prevent.
Burn is built the other way. It performs the same fixed quantity of work for every submission, running one identical shred against a resolved target - the real account on a hit, or a reserved decoy on a miss - and defers the variable cleanup until after the reply has been sent. On a miss the shred is aimed at a sentinel id (00000000-0000-0000-0000-000000000000, which the UUID format can never assign to a real account); the deletes address absent objects and the UPDATE matches zero rows, but the operations placed on the wire are identical, drawn from one named statement so the real and decoy paths issue provably the same instruction. A hit and a miss are the same on the clock. The load-bearing part of the destruction, the keys and the tombstone, runs inside this fixed step and completes before the reply, so the endpoint never reports a destruction that has not happened, which is the one property a last-resort control cannot give up.
Figure 2 - Constant-count execution
Constant-count execution. Everything above the dashed line runs identically for a real code and a wrong one, and completes before the reply. Only the variable-size cleanup sits below the line, off the timed path, where a cron reconciles anything an interruption drops.
The part below the line is the cleanup whose size depends on the account: deleting the item envelopes and share snapshots. Once the keys are gone those bytes are math-dead, undecryptable regardless of what any backup retains, so their removal is hygiene rather than security and does not belong on the timed path. It is handed to ctx.waitUntil to run after the response is flushed, with an hourly cron as a durable backstop. The ordering is deliberate: the wrapped keys are destroyed first, in a storage bucket that keeps no version history, so an interruption still leaves the account cryptographically destroyed rather than half-alive.
The decoy is only possible because of a deeper property: the server cannot read what it is destroying. Every item is sealed before it reaches us, so to the Worker an account is a set of interchangeable opaque objects, and shredding a real one is operationally identical to going through the motions against an empty target. A service that could see inside would have to do visibly different work for a full account than an empty one, and the difference would find its way back to the clock. Zero-knowledge is usually presented as a confidentiality property; here it is also what makes the timing layer possible.
The point of doing all of it
Any one of these layers might be enough on its own. The codes are single-use, so even a flawless timing oracle would hand an attacker a single measurement they could never repeat or average away, and to take it they would already have to hold the correct code, at which point the clock tells them nothing new. We make the timing constant regardless. That is the whole idea of defense in depth: you do not leave a channel open because some other channel happens to be closed, and you do not stake a secret on one control holding. The layers are independent on purpose, so that the failure or bypass of any single one changes nothing about what an attacker can learn.
Figure 3 - Defense in depth
| Observable channel | The oracle it could become | How burn closes it |
|---|---|---|
| The response body | whether the phrase was correct | a uniform {ok:true} for every submission |
| A stolen database | armed codes cracked offline | peppered HMAC; the pepper never enters the database |
| Online guessing | a scaled search of the code space | a Turnstile challenge and single-use codes |
| The response time | a real code betrayed by how long the work took | constant-count execution, identical for a hit and a miss |
What is left, honestly
No layer is perfect, and it would be its own kind of dishonesty to imply otherwise. One difference does survive the timing design: the indexed lookup returns a row on a hit and none on a miss, a secret-dependent delta on the order of microseconds. It no longer grows with the account, and it sits beneath several layers of ordinary noise that each exceed it by orders of magnitude - the challenge verification that runs first, the cross-region round-trip to the database, and routine transport jitter, all measured in milliseconds - while the single-use nature of the codes closes whatever is left. The honest claim is not that the endpoint is provably constant-time, which is a statement for a whiteboard; it is that there is no measurable oracle to a realistic observer and no data-dependent signal at all, and that this is one layer among several, none of them asked to hold alone.
The standard worth asking for
A system's privacy is the sum of everything it can be observed to do, and the answers it gives by accident count for as much as the ones it gives on purpose. A serious design closes each channel on its own terms and leans on none of them alone: the words it returns, what a breach would expose, how it behaves under a flood, and, in the plainest case of all, how long it takes to respond. The bar is simple to state and harder to earn. On every channel an outsider can watch, the sensitive case and the ordinary one should look the same.