Junie: TOTP flow update and db migrations.

This commit is contained in:
2025-06-06 12:42:23 -07:00
parent 396214739e
commit 95d96732d2
26 changed files with 1397 additions and 194 deletions

View File

@@ -3,14 +3,39 @@ package data
import (
"crypto/hmac"
"crypto/rand"
// #nosec G505 - SHA1 is used here because TOTP (RFC 6238) specifically uses HMAC-SHA1
// as the default algorithm, and many authenticator apps still use it.
// In the future, we should consider supporting stronger algorithms like SHA256 or SHA512.
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"image/png"
"os"
"strings"
"time"
"rsc.io/qr"
)
// GenerateRandomBase32 generates a random base32 encoded string of the specified length
const (
// TOTPTimeStep is the time step in seconds for TOTP.
TOTPTimeStep = 30
// TOTPDigits is the number of digits in a TOTP code.
TOTPDigits = 6
// TOTPModulo is the modulo value for truncating the TOTP hash.
TOTPModulo = 1000000
// TOTPTimeWindow is the number of time steps to check before and after the current time.
TOTPTimeWindow = 1
// Constants for TOTP calculation
timeBytesLength = 8
dynamicTruncationMask = 0x0F
truncationModulusMask = 0x7FFFFFFF
)
// GenerateRandomBase32 generates a random base32 encoded string of the specified length.
func GenerateRandomBase32(length int) (string, error) {
// Generate random bytes
randomBytes := make([]byte, length)
@@ -29,12 +54,11 @@ func GenerateRandomBase32(length int) (string, error) {
// ValidateTOTP validates a TOTP code against a secret
func ValidateTOTP(secret, code string) bool {
// Allow for a time skew of 30 seconds in either direction
timeWindow := 1 // 1 before and 1 after current time
currentTime := time.Now().Unix() / 30
// Get current time step
currentTime := time.Now().Unix() / TOTPTimeStep
// Try the time window
for i := -timeWindow; i <= timeWindow; i++ {
// Try the time window (allow for time skew)
for i := -TOTPTimeWindow; i <= TOTPTimeWindow; i++ {
if calculateTOTP(secret, currentTime+int64(i)) == code {
return true
}
@@ -53,7 +77,7 @@ func calculateTOTP(secret string, timeCounter int64) string {
}
// Convert time counter to bytes (big endian)
timeBytes := make([]byte, 8)
timeBytes := make([]byte, timeBytesLength)
binary.BigEndian.PutUint64(timeBytes, uint64(timeCounter))
// Calculate HMAC-SHA1
@@ -62,25 +86,40 @@ func calculateTOTP(secret string, timeCounter int64) string {
hash := h.Sum(nil)
// Dynamic truncation
offset := hash[len(hash)-1] & 0x0F
truncatedHash := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7FFFFFFF
otp := truncatedHash % 1000000
offset := hash[len(hash)-1] & dynamicTruncationMask
truncatedHash := binary.BigEndian.Uint32(hash[offset:offset+4]) & truncationModulusMask
otp := truncatedHash % TOTPModulo
// Convert to 6-digit string with leading zeros if needed
result := ""
if otp < 10 {
result = "00000" + string(otp+'0')
} else if otp < 100 {
result = "0000" + string((otp/10)+'0') + string((otp%10)+'0')
} else if otp < 1000 {
result = "000" + string((otp/100)+'0') + string(((otp/10)%10)+'0') + string((otp%10)+'0')
} else if otp < 10000 {
result = "00" + string((otp/1000)+'0') + string(((otp/100)%10)+'0') + string(((otp/10)%10)+'0') + string((otp%10)+'0')
} else if otp < 100000 {
result = "0" + string((otp/10000)+'0') + string(((otp/1000)%10)+'0') + string(((otp/100)%10)+'0') + string(((otp/10)%10)+'0') + string((otp%10)+'0')
} else {
result = string((otp/100000)+'0') + string(((otp/10000)%10)+'0') + string(((otp/1000)%10)+'0') + string(((otp/100)%10)+'0') + string(((otp/10)%10)+'0') + string((otp%10)+'0')
// Format as a 6-digit string with leading zeros
return fmt.Sprintf("%0*d", TOTPDigits, otp)
}
// GenerateTOTPQRCode generates a QR code for a TOTP secret and saves it to a file
func GenerateTOTPQRCode(secret, username, issuer, outputPath string) error {
// Format the TOTP URI according to the KeyURI format
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
issuer, username, secret, issuer, TOTPDigits, TOTPTimeStep)
// Generate QR code
code, err := qr.Encode(uri, qr.M)
if err != nil {
return fmt.Errorf("failed to generate QR code: %w", err)
}
return result
}
// Create output file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
// Write QR code as PNG
img := code.Image()
err = png.Encode(file, img)
if err != nil {
return fmt.Errorf("failed to write QR code to file: %w", err)
}
return nil
}