Multi-factor authentication
Published
A one-time code library will tell you whether a code is arithmetically correct for a moment in time. It will not tell you whether that code has already been used, because only your application has somewhere to keep that. The replay check is therefore a pair of functions you hand in, and the acceptance window is a number you pin - two property names holding two of the three things that make a second factor a second factor. We protected a file that supplies both, renamed the option names a group at a time, and re-presented a code that had already been spent.
What the sample actually does
The file configures a one-time code verifier with seven options and puts nine attempts through it. A genuine sign-in is accepted. The same code is then presented a second time - the shape of a code that was shoulder-surfed, phished in real time or captured in a log - and is refused by the replay cache. A code from the previous 30-second step, 45 seconds stale, is refused by the pinned window. Then six wrong guesses arrive from one account, the sixth of which is refused by the attempt limit rather than by the arithmetic.
Every guard therefore fires exactly once in the unprotected run, which is the arrangement this series has learned to insist on. A fixture where one guard catches everything reports the other guards as unchanged for reasons that have nothing to do with renaming.
The verifier is copied in unprotected. Its defaults are the ones this family of libraries documents: a window of one step either side, thirty-second steps, six digits, SHA-1, no replay cache at all, and no attempt limit. The clock is fixed so the fixture reproduces itself; a verifier fixture that drifts produces a different answer on a second run and every comparison against it is worthless.
Protection alone was applied first on five presets. All five behaved identically to the unprotected file.
The replay cache disappeared, and either half was enough
The application supplies two functions: one that asks whether a code has been spent, one that records that it has. Renaming either of them alone was sufficient. The verifier requires both to be present before it does a replay check, so losing one is exactly as complete as losing both.
On all three of those arms - the ask function alone, the record function alone, and the pair - the replayed code went from refused with the reason code-already-used to accepted with the reason current-step. The verifier's own report of whether it was checking for replay went from true to false. Accepted attempts went from one in nine to two in nine.
There is no default to revert to here, which is what separates this from most results in this series. No library in this family ships a replay cache, because the cache has to outlive the process and only the application knows where that lives. So the option does not degrade to a weaker check; the check simply does not happen, and the verifier is behaving exactly as documented for a caller who did not ask for one.
The specification is unambiguous about who is responsible: the verifier must not accept a second use of the same code within its validity period. That requirement lands on your application, and in the measured build it landed on two property names.
What makes it quiet is that nothing about a normal sign-in changes. Every user's first use of every code is accepted exactly as before. The behaviour only differs on the second use, and the population presenting a code twice is small and not composed of your users.
The window widened from 30 seconds to 90
This application pins the window to zero: only the current step is accepted. Renaming window restored the library default of one step either side, and the verifier's own report of its acceptance range went from one step and 30 seconds to three steps and 90 seconds. The 45-second-old code the application had been refusing was accepted, with the reason step-offset--1 naming the previous step.
Whether that is a problem depends on what you pinned zero for. Most applications do not, and one step either side is a reasonable trade against clock drift on the user's phone. An application that has pinned zero has usually done it for a specific high-value action, and the difference between a code that is good for thirty seconds and one good for ninety is the difference between a real-time relay attack having to be automated and being possible by hand.
Renaming attemptLimit is the other rate-shaped result: the sixth wrong guess went from being refused with too-many-attempts to reaching the verifier and being refused on the arithmetic instead. The account was never locked. Six digits is a million possibilities and a limit of five per window is what keeps that number meaningful; without a limit the attacker's problem becomes bandwidth rather than entropy.
Rename all seven options together and the three effects stack: the replay is accepted, the stale code is accepted, and the guess counter never fires. Accepted attempts go from one in nine to three in nine, and the verifier reports success on every one of them.
The three controls, and the arm that locks everybody out
Three options are pinned to values identical to the library defaults - thirty-second steps, six digits, SHA-1 - and renaming each changed nothing, on both output targets. The prediction held, as it has for seven consecutive passes.
Their caveat is unusually concrete here. An application that had enrolled its users at eight digits or on SHA-256 would find these same renames reverting the verifier to six digits and SHA-1 while every authenticator app in the field kept generating the enrolled format. That is total, immediate, universal failure to sign in - the loudest possible outcome, found within minutes, and by a long way the least damaging arm in this article. The dangerous arms are the ones where the sign-in still works.
Renaming the fields of the attempt objects the sample passes in - the code, the secret, the timestamp and the user - locks everybody out for a different reason: the verifier reads no code, so it matches no step, and accepted attempts go to zero in nine. Fails closed, which is the right direction.
Renaming the fields of the verdict the verifier hands back is the quiet direction. The verifier kept working; the application could not read its answers. The replay assertion and the stale-code assertion both read undefined, and the sample's check on whether the sixth guess reached the verifier flipped to true purely because a comparison against a missing string is not equal. An application whose next line is a truthiness test on the verdict would treat every verification as a failure; one written as an equality test against a specific rejection reason would treat every verification as a success.
What to do about it
The mechanism is the same one this series keeps arriving at, and none of it is a defect in the obfuscator. Member renaming rewrites property names in the code it is given. A verifier installed from a registry is not in that code; it reads the names it has always read, and a name it cannot find means the documented default applies. One-time code verification is a particularly bad place for that because two of its three real controls - replay and rate - are not library features at all. They are things your application hands in, and anything you hand in is addressed by name.
Scope the renaming so the verifier's options object sits outside the MemberRegexp. Better still, take the replay check out of the options object entirely: look up whether the code has been spent in your own code before you call the verifier, and record it after. A check written as a statement cannot be lost by renaming a key, and it is easier to reason about than a callback the library may or may not invoke.
The test that catches the worst arm fits in four lines. Verify a code, assert success; verify the identical code again, assert failure. Run it against the protected artifact rather than the source. A test that only verifies a fresh code passes on every arm measured in this article, which is why almost every multi-factor test suite in existence would have reported this build healthy.
Frequently asked questions
Does protecting my JavaScript break one-time code verification on its own?
Not in this measurement. Protection alone was applied on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming aimed at the option names.
Why does the library not prevent replay itself?
Because preventing replay means remembering which codes have been spent, and that memory has to outlive the process and be shared across instances. Only your application knows where that lives, so the library takes the two functions from you. The specification puts the requirement on the verifier, which in practice means on you.
Why was renaming one of the two replay functions as bad as renaming both?
The verifier requires both to be present before it will do a replay check at all. With one missing it skips the check entirely, so losing either is complete. It also reports that it is not checking, which is the one signal available.
Is a 90-second acceptance window actually dangerous?
It depends what you pinned zero for. One step either side is a reasonable default against phone clock drift. An application that deliberately narrowed to a single step has usually done so for a high-value action, and tripling the window is exactly the margin a real-time relay needs.
What does losing the attempt limit cost?
Six digits is a million possibilities, and a limit of five guesses per window is what keeps that number meaningful. Without a limit the attacker's constraint becomes how fast they can send requests, and the account is never locked.
Our digits and algorithm match the library defaults. Are those safe?
They measured inert, and only because of the values you pinned. An application enrolled at eight digits or SHA-256 would find the same rename reverting the verifier while every authenticator app kept producing the enrolled format - a total sign-in outage. Loud, immediate, and the least damaging arm here.
What is the cheapest test?
Verify a code and assert success, then verify the identical code again and assert failure. Four lines, run against the protected build. A suite that only ever verifies a fresh code passes on every arm in this article.
Related reading