Back to Feed
article 3m read

Password hash made four times slower in the same commit that stopped sign-in running it twice

The gateway's admin sign-in ran a deliberately slow password hash twice just to pick a label. The commit that cut it to once also raised the hash cost, and it never tested whether the new cost fit the runtime's memory cap.

Article DepthPractice

Flow

Decide once, then pass the answer along

  1. 1Compare to the shared key

    Cheap. A match returns shared-key and skips the hash.

  2. 2Check the stored password

    The slow hash runs here, once, only after the cheap check missed.

  3. 3Return one answer

    Shared key, password, or none.

  4. 4Admit and label from that answer

    No second hash to work out the label.

Every correct password made the gateway's admin sign-in run the password hash twice. The commit that cut the second call also made each call about four times slower.

Two ways in, one of them slow

One of the API gateways in my stack has an admin side. You get in with a shared bootstrap key from config, which is quick to check. Or you use an admin password I'd added the day before, stored as a scrypt hash. Scrypt is slow on purpose, so a stolen hash is hard to guess against. But my server pays that cost too, every time it checks. So it matters how many times one request can make it run.

I did a security pass with an agent, and it went in as one commit. It raised the scrypt cost to the usual published minimum, about four times the old work. It also cut a second scrypt call from sign-in.

The yes-or-no answer that caused it

The admin check only said yes or no. Then the code that labels the request needed to know which secret had matched. The check had thrown that away, so the label code ran the slow hash again to find out.

So a correct password cost two hashes. A correct bootstrap key cost one wasted hash. A wrong secret cost one, which is the only one I ever wanted. The admin web page's login had the same double call.

One answer, used twice

The check now says which kind of secret it saw, or none. The caller uses that one answer to let the request in and to label it:

kind = which_secret(secret)   # "shared-key", "password", or None

Now it's at most one hash per request, and none when the bootstrap key matched.

What the stronger hash costs now

A correct password used to cost two normal hashes. Now it's one hash at about four times the cost. That's still about double the old price, which I'm fine with. Paying it twice was the problem.

The same commit closed some smaller gaps. The rotation route now compares secrets in constant time. Admin routes got a rate limit before any parsing or login work. Error replies stopped echoing the caller's address. Logs stopped saving full URLs with query strings. And bad percent-encoding in a route now gets a client error.

A memory cap the new cost probably trips

The commit had no tests, and nothing shows a password sign-in running on the real runtime. Scrypt uses more memory as the cost goes up, and the runtime has a memory cap per call. My reading of the docs is that the new cost needs about twice that cap, so I expect the call would throw. The bootstrap key skipped scrypt after this commit, so checking with it would have looked fine. A few weeks later I shipped a one-line change that raised the cap.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward