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>
265 lines
9.2 KiB
Go
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()
|
|
}
|