fix(2fa): reject a totp= value that cannot be a one-time code#111
Merged
Conversation
Passing the TOTP *secret* where a code is expected produced no useful signal.
The SDK forwarded the 32-character base32 string verbatim and the only feedback
was the server's:
422 {"loc": ["body","totp_code"], "msg": "String should have at most 16
characters", "type": "string_too_long"}
on a field the caller never named. That reads as "the API is broken", not "you
passed the wrong thing" — and the mistake is easy, because in conversation both
values are "the TOTP".
_validate_totp_code() now runs on BOTH branches of _resolve_totp (the literal
and the callable — the callable form is the recommended one and therefore the
likelier place to wire the wrong value in and never notice).
Deliberately permissive about what it accepts:
* 6-8 digits — RFC 6238 permits all three lengths, so this is not hard-coded
to 6.
* 10-16 alphanumerics — a RECOVERY code. These go through the same
totp_code field, which is why the server's cap is 16 rather than 8. A
strict ^\d{6}$ rule would have rejected the one credential you need when
your authenticator is unavailable, and it would have failed at exactly
the moment you could least afford it.
and specific about what it names: a base32-looking value is rejected as "your
secret, not a code", with the fix in the message.
Verified by mutation: neutering the validator to `return code` turns 5 of the
new tests red. Full suite 1043 -> 1056 (13 added), all green, ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b
…leading zeros Two corrections from review, both of which the first draft got wrong. 1. THE HYPHEN WAS INVENTED. _RECOVERY_CODE_RE allowed `-`, and a test asserted "AB12-CD34-EF" was valid. I had never seen that format anywhere. Checking 40 real recovery codes across 5 accounts: every one is exactly 16 LOWERCASE HEX characters, no separators. Neither code type contains a non-alphanumeric. A test that pins an invented format is worse than no test — it freezes a guess into the suite and makes the guess look verified. The accepted range stays deliberately wider than the observation (10-16 alphanumerics, any case). The server owns its format; a client rule tighter than reality would reject a valid recovery code at the one moment TOTP is unavailable. But "wider than observed" is not licence to accept characters no code has ever contained. 2. FEWER THAN 6 DIGITS IS NEVER VALID — RFC 4226 sets 6 as the minimum — but it has exactly one realistic cause, and it is worth naming rather than lumping into the generic error. Codes are zero-padded, so str(int(code)) or passing an int turns "012345" into "12345". Measured over 20,000 generated codes: 9.9% begin with "0" and 1.0% with "00". So the bug fires on roughly one attempt in ten and reads as a flaky server rather than a client fault. Both the ValueError and the TypeError now say so. Also: whitespace is normalised away rather than rejected. Authenticators display "123 456"; the space is presentation, not data. "123 456 7" is therefore a valid 7-digit code, which corrected a test of mine that had listed it as a separator. Mutation-verified: neutering the validator turns 11 tests red. Suite 1043 -> 1062. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b
Jack, on review: "there should be no white space either. There is no display formatting here." Correct, and my justification was borrowed from the wrong context. I had argued that authenticator apps render codes as "123 456", so the space is presentation rather than data — which is true of a human retyping a code off a phone screen, and irrelevant here. This SDK is consumed by programs. Nothing in the path between a generator and `totp=` puts a space in, so a space means the value was assembled wrongly. Stripping it silently would repair the symptom and hide the defect — the same species of helpfulness that let a 32-character secret through as a code and produced this patch in the first place. So whitespace now raises, naming why it is not normalised. "123 456 7" moves back into the separator-rejection set where it belongs. Suite 1043 -> 1064. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The incident
While fixing a 2FA lockout on four agents, I passed the TOTP secret where a code was expected:
The SDK forwarded it verbatim. The only feedback was the server's:
on a field I had never named. That reads as "the API is broken", not "you passed the wrong thing" — and the mistake is easy, because in conversation both values are "the TOTP".
_validate_totp_code()now runs on both branches of_resolve_totp, the literal and the callable. The callable is the recommended form, so it is the likelier place to wire the wrong value in and never notice.What is accepted, and why it is not
^\d{6}$A strict 6-digit rule would reject the one credential you need when your authenticator is unavailable, failing at precisely the moment you cannot fall back. That is the trap this PR is built to avoid.
Evidence for the format: 40 real recovery codes across 5 accounts — every one exactly 16 lowercase hex characters, no separators. The accepted range is kept deliberately wider than that observation, because the server owns its format and a client rule tighter than reality fails at the worst moment. If the API ever issues a longer or differently-cased format,
_RECOVERY_CODE_REis the thing to widen.What is rejected, and what the message says
pyotpone-liner in the message-:_.strTypeErrorthat also names the leading-zero hazard, sinceintis the usual causeOn leading zeros — measured over 20,000 generated codes: 9.9% begin with
0, 1.0% with00. Sostr(int(code))or passing an int fails on roughly one attempt in ten. That intermittency is the point: it reads as a flaky server rather than a client bug, so the error names it explicitly.On whitespace — an earlier revision normalised it away, reasoning that authenticators display
123 456. That reasoning is borrowed from a human retyping a code off a phone and is irrelevant here: this SDK is consumed by programs, and nothing between a generator andtotp=inserts a space. Stripping it would repair the symptom and hide the defect — the same species of helpfulness that let a 32-character secret through in the first place.Review notes
This makes the SDK stricter. If anyone is currently passing something unusual that the server happens to accept, this turns a working call into a
ValueError. I think that is right — the alternative is what happened to me — but it is a behaviour change, not a pure addition.A correction worth seeing: the first revision allowed
-in recovery codes and shipped a test assertingAB12-CD34-EFwas valid. I had never observed that format anywhere; I invented it. A test that pins an invented format is worse than no test, because it freezes a guess into the suite and makes it look verified. Both are gone, and the replacement test's docstring explains why it uses the real observed shape.Verification
return codeturns 11 tests red.Not self-merging (TheColonyAI).