syncova-backup/packages/platform/totp/totp.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

265 lines
9.2 KiB
Go

// Package totp implementiert zeitbasierte Einmalkennwörter nach RFC 6238.
//
// Die Umsetzung erfolgt bewusst im eigenen Haus statt über eine Fremdbibliothek:
// der Algorithmus ist klein, vollständig spezifiziert und über die offiziellen
// Testvektoren des RFC nachweisbar korrekt. Zudem braucht Syncova ohnehin
// eigene Logik für Replay-Schutz und Zeitfenster-Toleranz.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/base32"
"errors"
"fmt"
"hash"
"net/url"
"strings"
"time"
)
// Algorithm benennt die Hashfunktion des HMAC.
type Algorithm string
const (
// AlgorithmSHA1 ist der Standard nach RFC 6238 und wird von allen
// verbreiteten Authenticator-Apps unterstützt.
AlgorithmSHA1 Algorithm = "SHA1"
// AlgorithmSHA256 ist die stärkere Variante.
AlgorithmSHA256 Algorithm = "SHA256"
// AlgorithmSHA512 ist die stärkste Variante.
AlgorithmSHA512 Algorithm = "SHA512"
)
// newHashFunction liefert die Konstruktorfunktion zum Algorithmus.
func (algorithm Algorithm) newHashFunction() (func() hash.Hash, error) {
switch algorithm {
case AlgorithmSHA1:
return sha1.New, nil
case AlgorithmSHA256:
return sha256.New, nil
case AlgorithmSHA512:
return sha512.New, nil
default:
return nil, fmt.Errorf("unbekannter algorithmus %q", algorithm)
}
}
// Standardparameter.
//
// Sechs Ziffern bei 30 Sekunden Schrittweite entsprechen dem, was
// Authenticator-Apps erwarten; Abweichungen davon führen zu Kompatibilitätsproblemen.
const (
// DefaultDigits ist die Anzahl der Ziffern eines Codes.
DefaultDigits = 6
// DefaultPeriod ist die Gültigkeitsdauer eines Zeitschritts.
DefaultPeriod = 30 * time.Second
// DefaultAlgorithm ist die vorgegebene Hashfunktion.
DefaultAlgorithm = AlgorithmSHA1
// DefaultSkew erlaubt je einen Zeitschritt Abweichung in beide Richtungen.
//
// Ohne Toleranz schlüge jede geringfügig falsch gehende Uhr fehl; eine
// größere Toleranz verlängerte dagegen das Zeitfenster für einen Angreifer.
DefaultSkew = 1
// secretByteLength ist die Länge eines erzeugten Secrets (160 Bit laut RFC 4226).
secretByteLength = 20
)
// ErrInvalidCode meldet einen nicht passenden Code.
var ErrInvalidCode = errors.New("der code ist ungültig")
// ErrCodeAlreadyUsed meldet die Wiederverwendung eines bereits benutzten Codes.
//
// Ohne diese Prüfung könnte ein abgefangener Code innerhalb seines Zeitfensters
// ein zweites Mal verwendet werden.
var ErrCodeAlreadyUsed = errors.New("dieser code wurde bereits verwendet")
// Configuration beschreibt die Parameter einer TOTP-Prüfung.
type Configuration struct {
// Algorithm ist die verwendete Hashfunktion.
Algorithm Algorithm
// Digits ist die Anzahl der Ziffern eines Codes.
Digits int
// Period ist die Gültigkeitsdauer eines Zeitschritts.
Period time.Duration
// Skew ist die erlaubte Abweichung in Zeitschritten je Richtung.
Skew int
}
// DefaultConfiguration liefert die Standardparameter.
func DefaultConfiguration() Configuration {
return Configuration{
Algorithm: DefaultAlgorithm,
Digits: DefaultDigits,
Period: DefaultPeriod,
Skew: DefaultSkew,
}
}
// GenerateSecret erzeugt ein neues zufälliges Secret in Base32.
//
// Base32 ohne Füllzeichen ist das Format, das Authenticator-Apps und
// QR-Codes erwarten.
func GenerateSecret() (string, error) {
secretBytes := make([]byte, secretByteLength)
if _, randomError := rand.Read(secretBytes); randomError != nil {
return "", fmt.Errorf("es konnte kein sicheres secret erzeugt werden: %w", randomError)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secretBytes), nil
}
// GenerateCode berechnet den Code für einen Zeitpunkt.
func GenerateCode(base32Secret string, codeTime time.Time, configuration Configuration) (string, error) {
secretBytes, decodeError := decodeSecret(base32Secret)
if decodeError != nil {
return "", decodeError
}
timeStep := codeTime.UTC().Unix() / int64(configuration.Period.Seconds())
return computeCode(secretBytes, timeStep, configuration)
}
// ValidationResult ist das Ergebnis einer erfolgreichen Prüfung.
type ValidationResult struct {
// TimeStep ist der Zeitschritt, für den der Code galt.
//
// Der Wert wird gespeichert, um die erneute Verwendung desselben Codes
// zu verhindern.
TimeStep int64
}
// Validate prüft einen Code gegen ein Secret.
//
// lastUsedTimeStep ist der zuletzt akzeptierte Zeitschritt dieses Benutzers;
// ein Code aus diesem oder einem älteren Schritt wird abgelehnt. Für die erste
// Prüfung wird 0 übergeben.
func Validate(base32Secret string, providedCode string, validationTime time.Time, lastUsedTimeStep int64, configuration Configuration) (ValidationResult, error) {
secretBytes, decodeError := decodeSecret(base32Secret)
if decodeError != nil {
return ValidationResult{}, decodeError
}
// Leerzeichen entstehen leicht beim Abtippen und sind kein Fehler des Benutzers.
normalizedCode := strings.ReplaceAll(strings.TrimSpace(providedCode), " ", "")
if len(normalizedCode) != configuration.Digits {
return ValidationResult{}, ErrInvalidCode
}
currentTimeStep := validationTime.UTC().Unix() / int64(configuration.Period.Seconds())
// Es werden alle Zeitschritte innerhalb der erlaubten Abweichung geprüft.
// Der Ablauf bricht bewusst nicht beim ersten Treffer ab, damit die Laufzeit
// nicht verrät, welcher Schritt gepasst hat.
matchedTimeStep := int64(-1)
for stepOffset := -configuration.Skew; stepOffset <= configuration.Skew; stepOffset++ {
candidateTimeStep := currentTimeStep + int64(stepOffset)
expectedCode, computeError := computeCode(secretBytes, candidateTimeStep, configuration)
if computeError != nil {
return ValidationResult{}, computeError
}
if subtle.ConstantTimeCompare([]byte(expectedCode), []byte(normalizedCode)) == 1 {
matchedTimeStep = candidateTimeStep
}
}
if matchedTimeStep < 0 {
return ValidationResult{}, ErrInvalidCode
}
// Ein bereits verwendeter Zeitschritt wird abgelehnt, selbst wenn der Code
// rechnerisch stimmt: sonst liesse sich ein abgefangener Code erneut nutzen.
if matchedTimeStep <= lastUsedTimeStep {
return ValidationResult{}, ErrCodeAlreadyUsed
}
return ValidationResult{TimeStep: matchedTimeStep}, nil
}
// computeCode berechnet den Code eines Zeitschritts nach RFC 4226.
func computeCode(secretBytes []byte, timeStep int64, configuration Configuration) (string, error) {
hashConstructor, hashError := configuration.Algorithm.newHashFunction()
if hashError != nil {
return "", hashError
}
// Der Zeitschritt wird als 8-Byte-Big-Endian-Wert in den HMAC gegeben.
counterBytes := make([]byte, 8)
for byteIndex := 7; byteIndex >= 0; byteIndex-- {
counterBytes[byteIndex] = byte(timeStep & 0xff)
timeStep >>= 8
}
messageAuthenticator := hmac.New(hashConstructor, secretBytes)
messageAuthenticator.Write(counterBytes)
authenticatorSum := messageAuthenticator.Sum(nil)
// Dynamic Truncation laut RFC 4226 §5.3: die letzten vier Bit benennen den
// Startpunkt der zu verwendenden vier Byte.
truncationOffset := authenticatorSum[len(authenticatorSum)-1] & 0x0f
truncatedValue := (uint32(authenticatorSum[truncationOffset])&0x7f)<<24 |
(uint32(authenticatorSum[truncationOffset+1])&0xff)<<16 |
(uint32(authenticatorSum[truncationOffset+2])&0xff)<<8 |
(uint32(authenticatorSum[truncationOffset+3]) & 0xff)
// Der Modulus schneidet den Wert auf die gewünschte Ziffernzahl.
codeModulus := uint32(1)
for digitIndex := 0; digitIndex < configuration.Digits; digitIndex++ {
codeModulus *= 10
}
// Führende Nullen bleiben erhalten - ein Code "012345" ist gültig.
return fmt.Sprintf("%0*d", configuration.Digits, truncatedValue%codeModulus), nil
}
// decodeSecret liest ein Base32-Secret.
func decodeSecret(base32Secret string) ([]byte, error) {
// Authenticator-Apps zeigen Secrets häufig in Gruppen mit Leerzeichen an.
normalizedSecret := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(base32Secret), " ", ""))
if normalizedSecret == "" {
return nil, errors.New("das secret ist leer")
}
secretBytes, decodeError := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalizedSecret)
if decodeError != nil {
// Der Wert selbst wird nicht ausgegeben: er ist ein Geheimnis.
return nil, errors.New("das secret ist kein gültiges base32")
}
return secretBytes, nil
}
// ProvisioningURI baut die otpauth-URI zum Einrichten einer Authenticator-App.
//
// Der Rückgabewert enthält das Secret im Klartext und darf deshalb ausschließlich
// an den Besitzer selbst ausgeliefert und niemals geloggt werden.
func ProvisioningURI(base32Secret string, accountName string, issuerName string, configuration Configuration) string {
// Das Label folgt der Konvention "Aussteller:Konto".
uriLabel := fmt.Sprintf("%s:%s", issuerName, accountName)
queryParameters := url.Values{}
queryParameters.Set("secret", base32Secret)
queryParameters.Set("issuer", issuerName)
queryParameters.Set("algorithm", string(configuration.Algorithm))
queryParameters.Set("digits", fmt.Sprintf("%d", configuration.Digits))
queryParameters.Set("period", fmt.Sprintf("%d", int(configuration.Period.Seconds())))
provisioningURL := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: "/" + uriLabel,
RawQuery: queryParameters.Encode(),
}
return provisioningURL.String()
}