Skip to content

fix(totp): require a TOTP secret to be the full 20 bytes - #361

Open
Jaro-c wants to merge 1 commit into
developfrom
fix/totp-secret-length
Open

Jaro-c wants to merge 1 commit into
developfrom
fix/totp-secret-length

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 8, 2026

Copy link
Copy Markdown
Member

decodeSecret enforced no length. Its only size test was len(key) == 0, so any secret that decoded to at least one byte was accepted and used as an HMAC key. secretLen has been declared as 20 three functions away the whole time and nothing compared against it.

Measured on develop at 6396536:

input decoded accepted before
MZXW6YTB 5 bytes yes
AAAAAAAA 5 bytes yes
MZXW6YTBMZXW6YTB 10 bytes yes

A 5-byte secret is a 40-bit keyspace, and generateTOTP produced codes from it without complaint.

Enroll always mints secretLen bytes, so nothing this package generates was affected. The exposure is a secret arriving from outside: migrated from another TOTP implementation, pasted by a user, or restored from a store that truncated it.

The trap, because it is what the tests are built around

Short inputs did fail before this, and they failed for the wrong reason. The encoding was selected by len(s)%8 != 0, so a length that is not a multiple of 8 got the padded encoding and was rejected as malformed base32. That reads like a length control and is not one. Every length that is a multiple of 8 took the unpadded branch and decoded cleanly at whatever size it produced.

The four subtests that go red when the old len(key) == 0 check is restored are exactly the multiple-of-8 cases, which is what makes them a test of the length check rather than of the encoding heuristic.

The encoding branch is gone too

That is a consequence of the length check, not a separate decision, and I measured it before removing anything: 20 bytes is exactly four 40-bit base32 groups, so it encodes to 32 characters with no padding and the padded and unpadded forms of a valid secret are byte-identical. The branch could therefore no longer select an encoding for any input the length check would accept. Leaving it would have left a tolerance that tolerates nothing.

The encoded input is also capped before decoding rather than after.

Provenance

Found by a MiniMax-M3 pass over the package. Its conclusion was right and its reproduction was wrong: it offered MY as an accepted one-byte secret, and MY is rejected, by the padding heuristic above. Running its example alone would have closed this as a false positive.

Closes #357

decodeSecret enforced no length. Its only size test was len(key) == 0,
so any secret that decoded to at least one byte was accepted and used as
an HMAC key. "MZXW6YTB" decodes to 5 bytes, and a 40-bit TOTP secret is
enumerable. secretLen has been declared as 20 three functions away the
whole time and nothing compared against it.

Enroll always mints secretLen bytes, so nothing this package generates
was affected. The exposure is a secret arriving from outside: migrated
from another implementation, pasted by a user, or restored from a store
that truncated it.

Short inputs did fail before, which is what made this easy to miss, and
they failed for the wrong reason. The encoding was chosen by
len(s)%8 != 0, so a length that is not a multiple of 8 took the padded
encoding and was rejected as malformed. That reads like a length control
and is not one: every length that is a multiple of 8 took the unpadded
branch and decoded cleanly at any size. The tests are built around that
trap, and the four that go red under the old check are exactly the
multiple-of-8 cases.

The encoding branch is gone with it, and that is a consequence rather
than a separate decision. 20 bytes is exactly four 40-bit base32 groups,
so it encodes to 32 characters with no padding and the padded and
unpadded forms of a valid secret are byte-identical. Measured before
removing it: the branch could no longer select an encoding for anything
the length check would accept.

Also caps the encoded input before decoding rather than after.

Closes #357

Signed-off-by: Jose <75870284+Jaro-c@users.noreply.github.com>
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.

1 participant