Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Cryptography

Tags:
  • go
  • totp
  • hotp
  • rfc6238
  • hmac-sha1
  • mfa

TOTP From Scratch in Go: Implementing RFC 6238 Without a Library

Every authenticator app does the same trick: you scan a QR code once, and from then on it produces a new six-digit number every thirty seconds that somehow matches what the server expects, with no network round trip. It looks like magic until you read RFC 6238, at which point it turns out to be about forty lines of HMAC and some bit-shuffling. There's no library needed and, honestly, pulling in one for this is usually a bad trade: the whole point is that the algorithm is small, fixed and worth understanding rather than trusting blind.

TOTP (Time-based One-Time Password) is defined in RFC 6238, but it's really a thin wrapper around HOTP (HMAC-based One-Time Password) from RFC 4226. Build HOTP first and TOTP falls out almost for free.

HOTP: an HMAC and a counter

HOTP takes a shared secret key and a counter, runs HMAC-SHA1 over them, and truncates the result down to a short decimal code. Both sides, client and server, hold the same secret and the same counter, so they always compute the same code.

package totp

import (
	"crypto/hmac"
	"crypto/sha1"
	"encoding/binary"
	"fmt"
)

func hotp(key []byte, counter uint64, digits int) string {
	buf := make([]byte, 8)
	binary.BigEndian.PutUint64(buf, counter)

	mac := hmac.New(sha1.New, key)
	mac.Write(buf)
	sum := mac.Sum(nil) // 20 bytes for SHA1

	offset := sum[len(sum)-1] & 0x0f
	code := uint32(sum[offset]&0x7f)<<24 |
		uint32(sum[offset+1])<<16 |
		uint32(sum[offset+2])<<8 |
		uint32(sum[offset+3])

	mod := uint32(1)
	for i := 0; i < digits; i++ {
		mod *= 10
	}
	return fmt.Sprintf("%0*d", digits, code%mod)
}

The counter is encoded as 8 bytes, big-endian, and fed as the HMAC message. That's the entire input side. The interesting part is what happens to the 20-byte HMAC-SHA1 output on the way out, because "dynamic truncation" sounds more intimidating than it is.

The last byte of the HMAC output has its low nibble taken as an offset, which can only be 0 to 15. That offset picks a starting byte somewhere in the first 16 bytes of the 20-byte digest. From there, four bytes are read and packed into a 32-bit integer, but the very top bit of the first of those four bytes is masked off with 0x7f. That mask isn't about security at all: it's there purely so the resulting 32-bit value can never be interpreted as negative on a platform where int is signed 32-bit. RFC 4226 was written with an eye on languages and hardware where that ambiguity mattered; Go's uint32 would be fine without it, but dropping the mask would break interoperability with every other implementation, so it stays. The whole scheme means a different four-byte window of the same HMAC output is used depending on the HMAC output itself, which is a neat way of avoiding always throwing away the same portion of the digest.

After that, it's just a modulo by 10^digits and zero-padding. Six digits is the near-universal default; the RFC's own test vectors use eight.

TOTP: swap the counter for time

TOTP replaces the counter with the number of time steps since the Unix epoch, almost always a 30-second step:

import "time"

func totpCode(key []byte, t time.Time, step time.Duration, digits int) string {
	counter := uint64(t.Unix() / int64(step.Seconds()))
	return hotp(key, counter, digits)
}

That's the whole of RFC 6238 on top of RFC 4226: a time-derived counter instead of an incrementing one. It's why TOTP doesn't need any state on either side beyond a synchronised clock, and why it's a strictly worse choice than HOTP if your device's clock can't be trusted to stay roughly accurate, which is precisely why banks issuing physical HOTP fobs historically preferred the counter version.

Secrets are base32, not the raw bytes

Authenticator apps exchange the secret as a base32 string (the sort of thing under a QR code, or typed in by hand as "provisioning" text), not raw bytes, because base32 is case-insensitive and avoids characters that get mistyped. Decoding it is one stdlib call:

import (
	"encoding/base32"
	"strings"
)

func decodeSecret(s string) ([]byte, error) {
	s = strings.ToUpper(strings.TrimSpace(s))
	return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s)
}

Most QR codes omit the trailing = padding characters that RFC 4648 base32 technically requires, which is why NoPadding matters here; a decoder set up for padded input will reject a perfectly valid secret and the bug report will look like "TOTP just doesn't work" rather than "your base32 config is wrong". RFC 4226 recommends at least 128 bits of secret, with 160 bits (which happens to match the SHA1 block internals) preferred, so a `20`-byte random secret is the sane default when generating one server-side.

Verifying: clock skew and constant-time comparison

Real clocks drift and users are slow to type, so a server checking a submitted code should accept the current step plus a small window either side, not just an exact match:

func validate(key []byte, submitted string, now time.Time) bool {
	const step = 30
	current := now.Unix() / step

	for _, skew := range []int64{-1, 0, 1} {
		want := hotp(key, uint64(current+skew), 6)
		if hmac.Equal([]byte(want), []byte(submitted)) {
			return true
		}
	}
	return false
}

Widening the window trades security for forgiveness: every extra step doubles (roughly) the number of valid codes an attacker gets to guess against within the six-digit space, so plus-or-minus one step (a 90-second window) is the usual ceiling, not a starting point to expand from.

hmac.Equal is doing real work there, not just being tidy: comparing the two strings with == would return as soon as the first differing byte is found, and an attacker who can measure response timing precisely enough could use that to guess a valid code one character at a time. hmac.Equal compares in constant time regardless of where the strings first diverge.

Checking against the RFC's own vectors

RFC 6238 Appendix B publishes test vectors using the 20-byte ASCII key "12345678901234567890" and 8-digit codes, which makes them easy to check an implementation against without trusting your own eyes:

Unix timeExpected TOTP
5994287082
111111110907081804
123456789089005924
func TestRFC6238Vectors(t *testing.T) {
	key := []byte("12345678901234567890")
	cases := map[int64]string{
		59:         "94287082",
		1111111109: "07081804",
		1234567890: "89005924",
	}
	for unix, want := range cases {
		got := totpCode(key, time.Unix(unix, 0).UTC(), 30*time.Second, 8)
		if got != want {
			t.Errorf("t=%d: got %s, want %s", unix, got, want)
		}
	}
}

If that test passes, the implementation is interoperable with every authenticator app that follows the spec, because there's nothing app-specific to get wrong: the entire contract is the shared secret, the step size and the digit count.

A few things that trip people up

SHA1 gets a bad reputation from collision attacks (SHAttered and friends), but those are attacks on SHA1's collision resistance when used to hash arbitrary attacker-chosen data, not on HMAC-SHA1 as a keyed pseudorandom function. HOTP and TOTP don't rely on collision resistance at all, so HMAC-SHA1 remains the de facto standard here regardless of what's happened to SHA1 in other contexts. RFC 6238 does define SHA256 and SHA512 variants, but support for them among authenticator apps is patchy enough that shipping anything other than SHA1 as the default is asking for a support queue.

The other common mistake is generating the QR provisioning URI (otpauth://totp/...?secret=...&issuer=...) with a freshly generated secret that hasn't been base32-encoded, or encoding it with padding included when the target app doesn't expect it. Since the algorithm itself has no failure mode that looks like "silently wrong", a mismatched encoding just produces a code that never matches, which is a genuinely miserable thing to debug without the RFC's test vectors sitting next to your implementation to sanity-check the primitive in isolation first.