OTP Cryptography Internals · Chapter 2 of 5
HOTP from the RFC: counter, HMAC-SHA1, dynamic truncation
Walk RFC 4226 byte by byte: encode the counter, compute the HMAC, pick the offset, extract 31 bits and reduce to six digits.
RFC 4226 is twelve pages of normative text and a reference implementation, and the algorithm itself fits on an index card. That brevity hides a few decisions that are easy to get wrong when you port it: the byte order of the counter, which bits of the HMAC are kept, and why the high bit is masked. This chapter walks the algorithm with the RFC’s own test vectors so you can check any implementation against them.
Step 1: the counter is eight bytes, big-endian
The moving factor C is an unsigned 64-bit integer. It must be serialised as exactly eight bytes, most significant byte first. A counter of 1 becomes 00 00 00 00 00 00 00 01. Implementations that write a 32-bit integer, or use the platform’s little-endian order, produce codes that look plausible but never match a real token. In JavaScript the trap is that bitwise operators work on 32-bit signed integers, so building the high word requires Math.floor(c / 2**32) rather than c >> 32.
Both sides start at zero (or at whatever value was agreed at provisioning) and increment the counter each time a code is generated on the token or accepted by the server. HOTP has no notion of time at all: a code generated today and typed in a month later is still valid if neither side moved its counter in between. That property makes HOTP suitable for devices without clocks and unsuitable for anything that needs codes to go stale on their own.
Step 2: HMAC-SHA1 over the counter
Compute HS = HMAC-SHA-1(K, C). The output is 20 bytes. The key K is the raw secret bytes — not its base32 text, not its hex text. Decoding the provisioning string into bytes before calling HMAC is the single most frequent porting bug we see on the bench. RFC 4226 recommends a key of at least 128 bits and suggests 160; chapter 4 explains why 160 is the sensible default.
Step 3: dynamic truncation
Twenty bytes are far too many to type, so the RFC defines a function called DT that selects four of them. Take the low four bits of the last byte, HS[19] & 0x0f. That gives an offset between 0 and 15. Read the four bytes starting at that offset as a big-endian 32-bit integer, then clear the top bit with & 0x7fffffff. The result is a 31-bit non-negative integer.
Why dynamic rather than always taking the first four bytes? The designers wanted the selected bytes to depend on the HMAC output itself, so that no fixed region of the digest is privileged. Why mask the top bit? Because signed and unsigned 32-bit arithmetic disagree about numbers with the high bit set, and the RFC authors wanted every implementation — Java, C, firmware — to land on the same value without caring about signedness.
The RFC’s own example uses the digest 1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a. The last byte is 0x5a, whose low nibble is 0xa, so the offset is 10. Bytes 10 to 13 are 50 ef 7f 19. As an integer that is 0x50ef7f19 = 1,357,872,921; the top bit is already clear. Reduced modulo 1,000,000 it gives 872921, the six-digit code.
async function hotp(key: Uint8Array, counter: number, digits = 6) {
const msg = new DataView(new ArrayBuffer(8));
msg.setUint32(0, Math.floor(counter / 2 ** 32)); // high word
msg.setUint32(4, counter >>> 0); // low word
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
const hs = new Uint8Array(await crypto.subtle.sign("HMAC", k, msg.buffer));
const off = hs[19] & 0x0f;
const bin = ((hs[off] & 0x7f) << 24) | (hs[off + 1] << 16) | (hs[off + 2] << 8) | hs[off + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}hs[off] & 0x7f before shifting is equivalent to masking the 32-bit result.Step 4: reduce and pad
The final code is the 31-bit integer modulo 10 to the power of the digit count, left-padded with zeros. Forgetting the padding is a classic bug: a code of 012345 rendered as 12345 will fail comparison or, worse, pass a lenient numeric comparison that also accepts other strings. Always compare codes as fixed-length strings, and compare them in constant time even though the practical timing risk is small.
A subtle point: 2 to the 31 is not a multiple of one million, so the modulo slightly favours low codes. The bias is roughly one part in two thousand for the lowest 483,648 values and is irrelevant at six digits. At ten digits it would matter, which is one reason the RFC caps the recommendation at eight.
Check yourself against the test vectors
RFC 4226 Appendix D uses the ASCII key 12345678901234567890 (twenty bytes). Counter 0 yields 755224, counter 1 yields 287082, counter 2 yields 359152 and counter 9 yields 520489. If your implementation reproduces those four values, it is almost certainly correct; if it reproduces the first but not the others, you probably have a counter encoding bug.
- Counter
0→755224 - Counter
1→287082 - Counter
2→359152 - Counter
9→520489
Counter synchronisation
Because the token increments its counter every time the user presses the button — whether or not the code is submitted — the token’s counter drifts ahead of the server’s. RFC 4226 handles this with a look-ahead window: the server accepts a code that matches any counter in [C_server, C_server + s] and then sets its counter to one past the match. The RFC suggests a resynchronisation procedure that requires two consecutive codes when the gap exceeds the normal window. Chapter 5 turns these ideas into concrete verifier policy.
It is worth reading the reference implementation in RFC 4226 Appendix C once, even if you never write Java. It shows that the authors expected implementers to work at the byte level, and its comments explain several choices in this chapter. If your library hides those bytes behind a high-level interface, the test vectors are the only way to be sure it matches the specification.
On the bench: HOTP, byte by byte
The workbench below implements everything in this chapter with the browser’s own HMAC. Switch to HOTP, step the counter and watch the offset move. The test-vector panel recomputes RFC 4226 Appendix D on load; every row must be green.
// bench check
0/3 correct · 0 answered
1. An HOTP port reproduces 755224 for counter 0 but fails for counter 1. The most likely bug is…
2. Why does dynamic truncation clear the top bit of the 32-bit value?
3. What is the key passed to HMAC when provisioning used a base32 string?