That rejected sign-in was the first time I really stared at six digits. My phone had already rolled to a new value while the page was still working out the previous one, so the form turned it away. What the page never tells you is where those tens of seconds went, and there is no field for how far behind my clock is.
One Slot Every 30 Seconds, and Only the Slot Boundary Counts
The code is a function of the slot numbered floor(sec/30) and nothing else. At sec=1700000000 the counter is 56666666 and the code comes out 921300, with only 10 seconds left in the running period: tick forward through 30 consecutive beats and you get exactly two values, 921300 for the first 10 seconds and 732303 for the remaining 20. I walked that run of offsets one second at a time on the TOTP generator page, and the flip never cared how far my clock was off, only whether the counter crossed into the next slot.
The time factor has two sources only, Date.now() and the setInterval(tpTick, 1000) heartbeat, and there is no calibration entry point of any kind: the number of assignment sites for TP.clockMs is 0, there is no box for seconds and no dropdown for a time zone. The display layer runs three mutually unequal readings side by side, the bar width through toFixed(1), the caption through Math.floor and the ring through the constant 100.53. At the period start sec=1699999980 the bar reads 100.0% and the ring dashoffset reads 0.00, both full, while the caption still says 「本周期已过 0%」. The honest case, a period really 100.0% gone, is the one this page never draws.
A ±1 Step Back-Check Walks Back At Most 60 Seconds
The back-check works in one direction only, backwards: you type the six digits showing on your phone and it hunts for them through the neighbouring periods of every stored account, with a hard ceiling of ±2 steps. The baseline below is sec=1700000000 across 3 accounts, one configured for an 8 digit code.
| Back-check window | k=-2 | k=-1 | k=0 | k=+1 | k=+2 |
|---|---|---|---|---|---|
| Real code for that period | 713364 | 276857 | 921300 | 732303 | 136087 |
| 1 step each side | 0 | 1 | 1 | 1 | 0 |
| 2 steps each side | 1 | 1 | 1 | 1 | 1 |
At a 60 second offset the 1 step each side row, fed 713364, scores 0 hits and the page answers 「全部 3 个账户在 ±1 期里都没有这个码」. Only widening the search to 2 steps brought back the useful reading, that my clock is 60 seconds fast. Those seconds are not measured but computed as offset multiplied by period, which is why an account on a 60 second period hitting within ±1 step is announced as 60 seconds. The fallback line 「多半是密钥抄错了」, probably a mistyped secret, is not a conclusion: an account whose digit count disagrees and an account whose secret will not decode are both swallowed by the same continue, and skipped=1 reports two different kinds of skip as one number.
The Vector Gate Cleared Only the SHA-1 Row
The self-test table is hard-coded to 16 entries: tpSelfTest() returns items=16 pass=16 fail=0 and the banner reads "all 16 official vectors matched". For SHA-1 that holds, every one of the six lines hits, with T=59 producing 94287082 and T=20000000000 producing 65353130, and the 10 HOTP lines come back character for character identical.
There is no row for SHA-256 or SHA-512, the template ships tpSha1 and tpHmacSha1, and the passage about algorithms at L319–L326 has no control you could select. Against RFC 6238 appendix B the floor drops out: the six SHA-256 vectors score 0 of 6, since T=59 expects 46119246 while this page hands back 97599872, and SHA-512 likewise scores 0 of 6, expecting 90693936 and getting 14779409. A link declaring algorithm=SHA256 still creates the account on SHA-1, appending one fixed sentence that the tool always computes with SHA-1. The strength gap itself is the subject of the SHA-256 versus MD5 note.
How Lenient Base32 Is: Dirty Spellings Share a Code, and So Does the Last Character
The dirty spellings get through. One and the same 32 character secret, written all lowercase, with a space every 4 characters, hyphenated into groups of 8, and with 8 padding characters welded to the tail, that is 39, 35 and 40 characters long, and all four forms decode to the identical 20 bytes. The 8 digit code at T=1111111111 is 14050471 in every case, and nine characters are skipped as whitespace. Only the alphabet is rejected: a zero, a one, an eight, a nine, an underscore and any Chinese character raise an error carrying the position, and nothing outside A–Z plus the digits 2 through 7 passes. TP_B32 at L390 is ABCDEFGHIJKLMNOPQRSTUVWXYZ234567, where I sits at position 9 and L at position 12, so the only characters ever dropped are those four digits; the reassuring line 「去掉了 0O1IL」 is the opposite of what the code does.
The nominal character count is not the strength either. n characters decode to floor(n*5/8) bytes, so the 20 character preset reports its bits field as n multiplied by 5, which is 100 bits, while the secret lands back as 12 bytes, that is 96 bits, and the option label reading "20 characters (128 bit)" overstates by 28 bits. Both the 20 and the 16 character preset trip the page's own short-key warning, and only the 32 character one, a full 160 bits, stays quiet. Nor is the random source uniform: L1151–L1156 carry a Math.random() fallback branch.
Discarding the tail character is the subtlest case. In the 20 character preset, changing only the last character collapses 32 possible values into 2 distinct byte strings, so a secret ending in GEZA and one ending in GEZB are both 12 bytes and both produce the code 228934. Only lengths that divide by 8 exactly, that is 8, 16, 24 and 32 characters, give 32 genuinely different secrets. Characters in the middle behave normally: flipping position 5 of a 32 character secret from G to H turns 921300 into 188215.
Digit Count, Period and Counter: A Silent Fallback Is Not a Rejection
The digit count field is barely guarded. Everything from 1 to 10 is accepted, an unusable value silently falls back to 6, 6.9 is read as 6 and 8abc as 8, and the same counter at 0 yields one 30 digit integer, 1284755224, which simply loses leading digits as the field shrinks.
- At 1, 4, 6, 8 and 10 digits the code is 4, 5224, 755224, 84755224 and 1284755224, and only the 10 digit form throws nothing away.
- The period goes through parseInt, so
1e9is read as 1 second and86401is capped to 86400; a link carrying digits=12 quietly becomes 6 with an empty warn array, while the text still promises the value is treated as 6 digits.
The counter has no truncation problem: tpCounterBytes writes 8 bytes big endian by taking the remainder after dividing by 256, and 9007199254740991 comes out as 891307. It wraps in silence only once a value passes 2^64, landing back on counter 0 and the code 755224, and the time factor would have to climb to 270215977642229760 seconds, about 8.56 billion years, before this page and the specification disagree for the first time.
Q: My phone and my laptop are one period apart, which side can this page adjust?
Neither. There is no field where I could type an offset of seconds and no time zone selector, the back-check only walks backwards, and a hit on the next period is read as my local clock being roughly 30 seconds slow.
Q: If I copy one character short from the secret, will it fail forever?
Drop a character in the middle and it fails every time. Drop the last one and you may hold a different secret that still produces 228934. Invalid lengths are a separate story: 11 characters raise the complaint that 3 characters remain at the tail and cannot make a whole byte, 13 characters still compute 78121305 without a fuss, and 12 characters give 93818264 with a warning that the trailing 4 bits are not zero.
Q: How many accounts can one otpauth link carry away?
One at a time. TP.linkId is a scalar, so 100 accounts means 100 separate clicks through the copy flow, and clicking an account whose secret will not decode raises an uncaught exception. Nothing leaves the browser: fetch and XMLHttpRequest each appear 0 times, accounts live only in localStorage under the key u2-totp-accounts, and the word qrcode appears 0 times.
Which brings me back to that rejected sign-in. Fixing the clock means the operating system, and this page offers two moves: fill the code while 921300 still has its 10 seconds, or fill the next period's 732303. The wording is the other half of the bill, and there the arithmetic comes out clean: of 62 claims read against the interface, 40 are marked consistent, 16 partially conflicting and 6 outright conflicting, and the sentences measured wrong are the ones that would have helped most.