A TOTP1 based 2FA system has two parts. One is a client that generates the TOTP code. The other part is a server. The server verifies the code. If the client and the server-generated codes match, the server allows the inbound user to access the target system. The code usually expires after 30 seconds and then, you’ll have to regenerate it to be able to authenticate.
As per RFC-62382, the server shares a base-32 encoded secret key with the client. Using this shared secret and the current UNIX timestamp, the client generates a 6-digit code. Independently, the server also generates a 6-digit code using the same secret string and its own current timestamp. If the user-entered client code matches the server-generated code, the auth succeeds. Otherwise, it fails. The client’s and the server’s current timestamp wouldn’t be an exact match. So the algorithm usually adjusts it for ~30 seconds duration.
I wanted to see if I could write a TOTP client and use it like Google Authenticator3 to log into my 2FA-enabled4 GitHub account. Turns out Go’s standard library lets you do that with only a couple of lines of code. Here’s the fully annotated implementation:
package main
import (
"crypto/hmac"; "crypto/sha1"; "encoding/base32";
"encoding/binary"; "strings";
)
func generateTOTP(secretKey string, timestamp int64) uint32 {
// The base32 encoded secret key string is decoded to a byte slice
base32Decoder := base32.StdEncoding.WithPadding(base32.NoPadding)
secretKey = strings.ToUpper(strings.TrimSpace(secretKey)) // preprocess
secretBytes, _ := base32Decoder.DecodeString(secretKey) // decode
// The truncated timestamp / 30 is converted to an 8-byte big-endian
// unsigned integer slice
timeBytes := make([]byte, 8)
binary.BigEndian.PutUint64(timeBytes, uint64(timestamp) / 30)
// The timestamp bytes are concatenated with the decoded secret key
// bytes. Then a 20-byte SHA-1 hash is calculated from the byte slice
hash := hmac.New(sha1.New, secretBytes)
hash.Write(timeBytes) // Concat the timestamp byte slice
h := hash.Sum(nil) // Calculate 20-byte SHA-1 digest
// AND the SHA-1 with 0x0F (15) to get a single-digit offset
offset := h[len(h)-1] & 0x0F
// Truncate the SHA-1 by the offset and convert it into a 32-bit
// unsigned int. AND the 32-bit int with 0x7FFFFFFF (2147483647)
// to get a 31-bit unsigned int.
truncatedHash := binary.BigEndian.Uint32(h[offset:]) & 0x7FFFFFFF
// Take modulo 1_000_000 to get a 6-digit code
return truncatedHash % 1_000_000
}
Use it as such:
// Import "time" and "fmt"
// ...
func main() {
// Collect it from a TOTP server like GitHub 2FA panel
secretKey := "6AXIS2D4ST9CXAW2" // This is a fake one!
now := time.Now().Unix()
totpCode := generateTOTP(secretKey, now)
fmt.Printf("Current TOTP code: %06d\n", totpCode)
}
This prints the following code and will keep printing the same one for the next 30 seconds if you rerun the script multiple times:
Current TOTP code: 134624
Here are the detailed implementation steps:
- Trim whitespace and convert the base32 encoded secret key string to uppercase
- Decode the preprocessed secret key from base32 to a byte slice
- Get the current timestamp, divide by 30, and convert it to an 8-byte big-endian unsigned integer
- Concatenate the timestamp integer bytes with the decoded secret key bytes
- Hash the concatenated bytes to get a 20-byte SHA-15 digest
- Get the last byte of the SHA-1 digest and AND it with 0x0F (15) to mask off all but the last 4 bits to get an offset index from 0-15
- Use the offset index to truncate the SHA-1 digest to get a 32-bit unsigned integer
- AND the 32-bit integer with 0x7FFFFFFF (2147483647) to mask off the most significant bit and convert to an unsigned 31-bit integer
- Take modulo 1_000_000 of the 31-bit integer to get a 6-digit TOTP code
- Return the 6-digit TOTP code
To test the implementation, I collected a secret key from GitHub’s 2FA panel6. Then I logged into my account by inputting a TOTP code generated by this script. Worked flawlessly!
Recent posts
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing
- Behind the blog
- Shell redirection syntax soup