syncova-backup/packages/platform/crypto/password.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

174 lines
5.9 KiB
Go

// Package crypto stellt die kryptografischen Grundbausteine von Syncova bereit:
// Passwort-Hashing, verschlüsselte Ablage von Secrets und Token-Erzeugung.
//
// Grundsätze (PROMPT.md §12, §41):
// - Passwörter werden niemals im Klartext gespeichert oder geloggt.
// - Vergleiche laufen in konstanter Zeit, damit sie keine Rückschlüsse erlauben.
// - Verwendete Parameter wandern mit dem Hash, damit sie später verschärft
// werden können, ohne bestehende Anmeldungen zu brechen.
package crypto
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id-Parameter.
//
// Die Werte orientieren sich an den OWASP-Empfehlungen (64 MiB Speicher,
// 3 Durchgänge). Speicherbedarf ist hier die wirksamste Verteidigung, weil er
// Angriffe mit Grafikkarten und Spezialhardware unwirtschaftlich macht.
const (
// argon2Time ist die Anzahl der Durchgänge.
argon2Time uint32 = 3
// argon2Memory ist der Speicherbedarf in KiB (64 MiB).
argon2Memory uint32 = 64 * 1024
// argon2Threads ist der Parallelitätsgrad.
argon2Threads uint8 = 4
// argon2KeyLength ist die Länge des erzeugten Hashes in Byte.
argon2KeyLength uint32 = 32
// argon2SaltLength ist die Länge des Zufallssalzes in Byte.
argon2SaltLength uint32 = 16
)
// ErrPasswordMismatch meldet, dass ein Passwort nicht zum Hash passt.
//
// Der Fehler ist bewusst unspezifisch: er unterscheidet nicht zwischen
// "falsches Passwort" und "unbekannter Benutzer", damit die API keine
// Benutzernamen preisgibt.
var ErrPasswordMismatch = errors.New("passwort stimmt nicht überein")
// ErrInvalidPasswordHash meldet einen beschädigten oder unbekannten Hash.
var ErrInvalidPasswordHash = errors.New("passwort-hash ist ungültig oder wurde mit einem unbekannten Verfahren erzeugt")
// HashPassword erzeugt einen Argon2id-Hash im PHC-Format.
//
// Das Ergebnis enthält Verfahren, Version, Parameter, Salz und Hash:
//
// $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
//
// Dadurch bleibt ein alter Hash prüfbar, auch nachdem die Parameter für neue
// Passwörter verschärft wurden.
func HashPassword(plainPassword string) (string, error) {
// Ein leeres Passwort wäre eine stillschweigend wirkungslose Absicherung.
if plainPassword == "" {
return "", errors.New("das passwort darf nicht leer sein")
}
passwordSalt := make([]byte, argon2SaltLength)
if _, randomError := rand.Read(passwordSalt); randomError != nil {
return "", fmt.Errorf("es konnte kein sicheres salz erzeugt werden: %w", randomError)
}
passwordHash := argon2.IDKey([]byte(plainPassword), passwordSalt, argon2Time, argon2Memory, argon2Threads, argon2KeyLength)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(passwordSalt),
base64.RawStdEncoding.EncodeToString(passwordHash),
), nil
}
// VerifyPassword prüft ein Passwort gegen einen gespeicherten Hash.
//
// Der Vergleich läuft in konstanter Zeit: eine von der Übereinstimmung abhängige
// Laufzeit würde einem Angreifer verraten, wie viele Zeichen bereits stimmen.
func VerifyPassword(plainPassword string, encodedHash string) error {
hashParameters, passwordSalt, expectedHash, parseError := decodePasswordHash(encodedHash)
if parseError != nil {
return parseError
}
computedHash := argon2.IDKey(
[]byte(plainPassword),
passwordSalt,
hashParameters.time,
hashParameters.memory,
hashParameters.threads,
uint32(len(expectedHash)),
)
if subtle.ConstantTimeCompare(computedHash, expectedHash) != 1 {
return ErrPasswordMismatch
}
return nil
}
// NeedsRehash meldet, ob ein Hash mit schwächeren Parametern als den aktuellen
// erzeugt wurde.
//
// Damit lässt sich ein Passwort bei der nächsten erfolgreichen Anmeldung
// stillschweigend auf die aktuellen Parameter heben.
func NeedsRehash(encodedHash string) bool {
hashParameters, _, _, parseError := decodePasswordHash(encodedHash)
if parseError != nil {
// Ein unlesbarer Hash muss in jedem Fall ersetzt werden.
return true
}
return hashParameters.time < argon2Time ||
hashParameters.memory < argon2Memory ||
hashParameters.threads < argon2Threads
}
// argon2Parameters bündelt die aus einem Hash gelesenen Parameter.
type argon2Parameters struct {
// memory ist der Speicherbedarf in KiB.
memory uint32
// time ist die Anzahl der Durchgänge.
time uint32
// threads ist der Parallelitätsgrad.
threads uint8
}
// decodePasswordHash zerlegt einen Hash im PHC-Format.
func decodePasswordHash(encodedHash string) (argon2Parameters, []byte, []byte, error) {
// Erwartet werden sechs Teile: leer, Verfahren, Version, Parameter, Salz, Hash.
hashParts := strings.Split(encodedHash, "$")
if len(hashParts) != 6 {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
if hashParts[1] != "argon2id" {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
var encodedVersion int
if _, scanError := fmt.Sscanf(hashParts[2], "v=%d", &encodedVersion); scanError != nil {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
// Eine abweichende Argon2-Version würde einen anderen Hash liefern.
if encodedVersion != argon2.Version {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
var hashParameters argon2Parameters
if _, scanError := fmt.Sscanf(hashParts[3], "m=%d,t=%d,p=%d",
&hashParameters.memory, &hashParameters.time, &hashParameters.threads); scanError != nil {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
passwordSalt, saltError := base64.RawStdEncoding.DecodeString(hashParts[4])
if saltError != nil {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
expectedHash, hashError := base64.RawStdEncoding.DecodeString(hashParts[5])
if hashError != nil || len(expectedHash) == 0 {
return argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
return hashParameters, passwordSalt, expectedHash, nil
}