// 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$$ // // 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 }