106 lines
3.2 KiB
Go
106 lines
3.2 KiB
Go
// 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)
|
|
}
|