Skip to content

fix(2fa): reject a totp= value that cannot be a one-time code#111

Merged
jackparnell merged 3 commits into
mainfrom
fix/validate-totp-code
Jul 21, 2026
Merged

fix(2fa): reject a totp= value that cannot be a one-time code#111
jackparnell merged 3 commits into
mainfrom
fix/validate-totp-code

Conversation

@ColonistOne

@ColonistOne ColonistOne commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

The incident

While fixing a 2FA lockout on four agents, I passed the TOTP secret where a code was expected:

ColonyClient(api_key=..., totp=secret)   # 32-char base32 string

The SDK forwarded it verbatim. The only feedback was the server's:

422 {"loc": ["body","totp_code"], "msg": "String should have at most 16 characters"}

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}$

accepted reasoning
6-8 digits RFC 6238 permits all three lengths. Hard-coding 6 would be a guess.
10-16 alphanumerics Recovery codes go through this same field — which is why the server caps at 16, not 8.

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_RE is the thing to widen.

What is rejected, and what the message says

input why
a base32-looking string "looks like your TOTP secret (32 base32 characters), not a one-time code", with the pyotp one-liner in the message
fewer than 6 digits never valid (RFC 4226 minimum), and named as its one realistic cause: a stripped leading zero
whitespace rejected, never normalised
- : _ . no Colony code of either kind contains a non-alphanumeric
a non-str TypeError that also names the leading-zero hazard, since int is the usual cause

On leading zeros — measured over 20,000 generated codes: 9.9% begin with 0, 1.0% with 00. So str(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 and totp= 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 asserting AB12-CD34-EF was 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

  • Mutation-tested: neutering the validator to return code turns 11 tests red.
  • Suite 1043 -> 1064 (21 added) — measured against a stashed baseline, not inferred from the total.
  • Exercised against live values: a real TOTP code and a real 16-char recovery code both pass; the secret, a stripped-zero code, whitespace and separators all raise.
  • ruff clean and formatted.

Not self-merging (TheColonyAI).

ColonistOne and others added 3 commits July 21, 2026 20:11
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
@jackparnell
jackparnell merged commit 4eb65ee into main Jul 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants