// Package totp implementiert Core IAM-04: TOTP-basierte Zwei-Faktor- // Authentifizierung (RFC 6238, aufbauend auf HOTP RFC 4226) mit stdlib // crypto/hmac+sha1 — kein externes Modul noetig. package totp import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "encoding/base32" "encoding/binary" "fmt" "net/url" "time" "gitea.perlbach24.de/scripte/nexarch/internal/timingsafe" ) // StepSeconds ist das TOTP-Zeitfenster (RFC-6238-Standard: 30 Sekunden). const StepSeconds = 30 // DefaultSkewSteps ist die dokumentierte Zeitversatz-Toleranz // (Akzeptanzkriterium/Pruefung 3): +/- 1 Schritt = +/- 30 Sekunden um die // aktuelle Serverzeit, insgesamt ein 90-Sekunden-Fenster. Grosszuegig genug // fuer leicht abweichende Client-Uhren, eng genug um ein TOTP-Fenster nicht // unbrauchbar lange gueltig zu lassen. const DefaultSkewSteps = 1 // GenerateSecret erzeugt ein neues, zufaelliges TOTP-Secret (20 Byte, wie von // Authenticator-Apps erwartet), Base32-kodiert ohne Padding. func GenerateSecret() (string, error) { buf := make([]byte, 20) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("secret erzeugen: %w", err) } return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil } // ProvisioningURI liefert die otpauth://-URI fuer den QR-Code // (Akzeptanzkriterium 1) — das eigentliche QR-Rendering ist Frontend-Sache // (IAM-08), hier wird nur die standardkonforme URI erzeugt. func ProvisioningURI(issuer, accountName, secret string) string { label := url.PathEscape(issuer) + ":" + url.PathEscape(accountName) q := url.Values{} q.Set("secret", secret) q.Set("issuer", issuer) q.Set("algorithm", "SHA1") q.Set("digits", "6") q.Set("period", fmt.Sprintf("%d", StepSeconds)) return fmt.Sprintf("otpauth://totp/%s?%s", label, q.Encode()) } // GenerateCode berechnet den 6-stelligen TOTP-Code fuer secret zum Zeitpunkt t. func GenerateCode(secret string, t time.Time) (string, error) { key, err := decodeSecret(secret) if err != nil { return "", err } counter := uint64(t.Unix() / StepSeconds) return hotp(key, counter), nil } // Validate prueft code gegen secret mit der dokumentierten Zeitversatz- // Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe. func Validate(secret, code string, t time.Time) (bool, error) { key, err := decodeSecret(secret) if err != nil { return false, err } counter := int64(t.Unix() / StepSeconds) for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ { candidate := hotp(key, uint64(counter+int64(delta))) if timingsafe.EqualString(candidate, code) { return true, nil } } return false, nil } func decodeSecret(secret string) ([]byte, error) { key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) if err != nil { return nil, fmt.Errorf("secret dekodieren: %w", err) } return key, nil } // hotp implementiert RFC 4226 mit HMAC-SHA1, 6 Ziffern. func hotp(key []byte, counter uint64) string { buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, counter) mac := hmac.New(sha1.New, key) mac.Write(buf) sum := mac.Sum(nil) 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]) return fmt.Sprintf("%06d", code%1000000) }