How does a time-based one-time password (TOTP) work, and how would you compute one with CryptoKit?
Predict first
Do the RFC 4226 and RFC 6238 test vectors come out?
import Foundation
import CryptoKit
func hotp(_ key: Data, _ counter: UInt64, digits: Int = 6) -> String {
var c = counter.bigEndian
let mac = Data(HMAC<Insecure.SHA1>.authenticationCode(for: Data(bytes: &c, count: 8), using: SymmetricKey(data: key)))
let o = Int(mac[19] & 0x0f)
let v = (UInt32(mac[o] & 0x7f) << 24) | (UInt32(mac[o + 1]) << 16) | (UInt32(mac[o + 2]) << 8) | UInt32(mac[o + 3])
var m = 1; for _ in 0..<digits { m *= 10 }
return String(format: "%0\(digits)d", Int(v) % m)
}
let secret = Data("12345678901234567890".utf8)
print("HOTP:", (0..<5).map { hotp(secret, UInt64($0)) }.joined(separator: " "))
for t in [59, 1111111109, 1234567890, 2000000000] { print("TOTP at", t, hotp(secret, UInt64(t / 30), digits: 8)) }
- HOTP: 755224 287082 359152 969429 338314 / TOTP at 59 94287082 / TOTP at 1111111109 07081804 / TOTP at 1234567890 89005924 / TOTP at 2000000000 69279037
- HOTP: 000000 000001 000002 000003 000004
- HOTP: 755224 755224 755224 755224 755224
Mental model. A TOTP code is a combination lock whose combination is recomputed every 30 seconds from a secret both you and the bank hold; the clock is the only thing that must agree.
A TOTP code (the six digits in an authenticator app) is an HMAC of the current time step under a shared secret, truncated to a few digits. HOTP (RFC 4226) computes HMAC-SHA1(secret, counter) with the counter as 8 big-endian bytes, then applies dynamic truncation: the low four bits of the last byte give an offset, four bytes from there are read with the top bit cleared, and the number is taken modulo 10digits. TOTP (RFC 6238) is HOTP where the counter is unixTime / 30. SHA-1 appears here only because the standard and the authenticator apps use it for this HMAC use, where it is not weakened the way it is for collisions; CryptoKit exposes it as Insecure.SHA1, and the name is a warning for other uses. The check reproduces the published test vectors exactly: HOTP for counters 0 to 4 gives 755224, 287082, 359152, 969429 and 338314, and TOTP at time 59 with eight digits gives 94287082.
What this means for an app: the secret (shown to the user as a base32 string or QR code) is a long-term credential, so store it in the Keychain, never log it, and treat the QR content as sensitive. The server accepts the current step and usually one step either side to allow clock drift, and must remember the last accepted step so a code cannot be replayed within its window. Compare codes in constant time and rate-limit attempts, since six digits are only a million guesses. Also be honest about the limit: TOTP defeats password reuse and remote guessing, but a phishing site can relay a code in real time, which is why passkeys (Q383) are the stronger answer. Not run: base32 decoding of provisioning URIs, which is not in CryptoKit.
The HMAC-SHA1 construction with dynamic truncation matches the published vectors.
Common traps
- Computing the counter from local time without a plan for clock drift. Accept a small window and tell the user to fix their clock.
- Letting a code be reused inside its window. Record the last accepted step server-side.
- Storing the shared secret in UserDefaults. It is a credential; use the Keychain.
They might follow up with
- Why does it not need network access? Both sides compute the same function of the secret and the time.
- Why are passkeys better than TOTP? They are bound to the site’s origin, so a phishing page cannot use them.
Related questions
- 14.6.5 How does PKCE protect an OAuth sign-in in an app, how is the code challenge computed, and what are state and…
- 14.6.6 What should code that reads a JWT check, why is a client usually not the one to trust it, and what is the…
- 14.2.1 How do Face ID and Touch ID with LocalAuthentication work, and what does the Secure Enclave add?
- 17.5.1 How do passkeys, Sign in with Apple and ASWebAuthenticationSession work in AuthenticationServices?
← How do P-256 and Ed25519 signatures differ in CryptoKit: sizes… How does PKCE protect an OAuth sign-in in an app, how is the code… →