Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package password hashes and verifies passwords with scrypt.
//
// A fixed set of scrypt parameters (N, r, p, dklen, salt length) is
// enforced so weaker configurations stored in older users.toml files are
// rejected. Stored hashes use scrypt$<N>$<r>$<p>$<saltB64>$<hashB64>,
// the encoding every released version has written, so an existing
// users.toml keeps working unchanged.
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"golang.org/x/crypto/scrypt"
)
const (
// CostN, BlockR, ParallelP and KeyLength are the scrypt policy floor.
CostN = 16384
BlockR = 8
ParallelP = 1
KeyLength = 32
SaltBytes = 16
prefix = "scrypt"
maxMemBytes = 64 << 20 // bound scrypt memory for hostile stored hashes
maxWorkBits = 1 << 28 // bound the full 128*N*r*p work: p multiplies CPU, not memory
)
// ErrEmpty is returned when the password to hash is empty.
var ErrEmpty = errors.New("password must not be empty")
// MaxPasswordLength caps input size to bound scrypt work.
const MaxPasswordLength = 1024
// dummy is a hash of an unguessable value, derived on first use.
var dummy = sync.OnceValue(func() string {
salt := make([]byte, SaltBytes)
rand.Read(salt)
derived, err := scrypt.Key(salt, salt, CostN, BlockR, ParallelP, KeyLength)
if err != nil {
return ""
}
return fmt.Sprintf("%s$%d$%d$%d$%s$%s",
prefix, CostN, BlockR, ParallelP,
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(derived),
)
})
// Dummy returns a valid encoded hash of a value nobody knows, for
// verification against when the username does not exist: a caller can
// spend the same scrypt work either way, so the response time does not
// reveal whether an account exists.
func Dummy() string { return dummy() }
// Hash derives a scrypt hash and returns the encoded string.
func Hash(password string) (string, error) {
if password == "" {
return "", ErrEmpty
}
if len([]rune(password)) > MaxPasswordLength {
return "", fmt.Errorf("password longer than %d characters", MaxPasswordLength)
}
salt := make([]byte, SaltBytes)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
derived, err := scrypt.Key([]byte(password), salt, CostN, BlockR, ParallelP, KeyLength)
if err != nil {
return "", fmt.Errorf("scrypt hash: %w", err)
}
return fmt.Sprintf("%s$%d$%d$%d$%s$%s",
prefix, CostN, BlockR, ParallelP,
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(derived),
), nil
}
// Verify checks a password against an encoded scrypt hash in constant
// time. Hashes produced with parameters below the policy floor, or that
// are malformed, are rejected with false and a warning is logged.
func Verify(password, encoded string) bool {
n, r, p, salt, expected, ok := parse(encoded)
if !ok {
slog.Warn("password verify: malformed stored hash")
return false
}
if n < CostN || r < BlockR || p < ParallelP || len(expected) < KeyLength {
slog.Warn("password verify: stored hash uses weak scrypt parameters, rejecting",
"n", n, "r", r, "p", p, "dklen", len(expected))
return false
}
if scryptMem(n, r) > maxMemBytes {
slog.Warn("password verify: stored hash exceeds memory bound, rejecting",
"n", n, "r", r)
return false
}
// p multiplies the sequential work without touching the memory bound,
// so it needs its own ceiling: a hostile file with a huge p would
// otherwise burn hours of CPU inside a single verification.
if p < 1 || int64(p) > maxWorkBits/(128*int64(n)*int64(r)) {
slog.Warn("password verify: stored hash exceeds work bound, rejecting",
"n", n, "r", r, "p", p)
return false
}
derived, err := scrypt.Key([]byte(password), salt, n, r, p, len(expected))
if err != nil {
slog.Warn("password verify: scrypt failed", "error", err)
return false
}
return subtle.ConstantTimeCompare(derived, expected) == 1
}
// NeedsRehash reports whether stored uses parameters the policy floor no
// longer accepts: Verify rejects such a hash, so the account cannot sign
// in until its password is reset out of band (users.toml or a new hash
// from the operator). Re-hashing on login is not possible, because the
// weak verification that would allow it is exactly what the floor forbids.
func NeedsRehash(stored string) bool {
n, r, p, _, expected, ok := parse(stored)
if !ok {
return true
}
return n < CostN || r < BlockR || p < ParallelP || len(expected) < KeyLength
}
func parse(encoded string) (n, r, p int, salt, hash []byte, ok bool) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != prefix {
return 0, 0, 0, nil, nil, false
}
n, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, nil, nil, false
}
r, err = strconv.Atoi(parts[2])
if err != nil {
return 0, 0, 0, nil, nil, false
}
p, err = strconv.Atoi(parts[3])
if err != nil {
return 0, 0, 0, nil, nil, false
}
salt, err = base64.StdEncoding.DecodeString(parts[4])
if err != nil {
return 0, 0, 0, nil, nil, false
}
hash, err = base64.StdEncoding.DecodeString(parts[5])
if err != nil {
return 0, 0, 0, nil, nil, false
}
return n, r, p, salt, hash, true
}
func scryptMem(n, r int) int64 {
return 128 * int64(n) * int64(r)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package password
import (
"strings"
"testing"
)
func TestHashAndVerifyRoundTrip(t *testing.T) {
encoded, err := Hash("correct horse battery staple")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if !strings.HasPrefix(encoded, "scrypt$16384$8$1$") {
t.Fatalf("encoded = %q, want scrypt$16384$8$1$ prefix", encoded)
}
if !Verify("correct horse battery staple", encoded) {
t.Fatal("Verify returned false for the correct password")
}
if Verify("wrong password", encoded) {
t.Fatal("Verify returned true for a wrong password")
}
}
func TestHashRejectsEmpty(t *testing.T) {
if _, err := Hash(""); err != ErrEmpty {
t.Fatalf("err = %v, want ErrEmpty", err)
}
}
func TestHashRejectsTooLong(t *testing.T) {
if _, err := Hash(strings.Repeat("x", MaxPasswordLength+1)); err == nil {
t.Fatal("want error for over-long password")
}
}
func TestVerifyRejectsMalformed(t *testing.T) {
for _, encoded := range []string{
"",
"not-a-hash",
"bcrypt$16384$8$1$c2FsdA==$aGFzaA==",
"scrypt$16384$8$c2FsdA==$aGFzaA==",
"scrypt$abc$8$1$c2FsdA==$aGFzaA==",
"scrypt$16384$8$1$!!!notb64==$aGFzaA==",
} {
if Verify("secret", encoded) {
t.Fatalf("Verify(%q) = true, want false", encoded)
}
}
}
func TestVerifyRejectsWeakParameters(t *testing.T) {
// N=1024, r=8, p=1 with a well-formed 32-byte hash: below the floor.
encoded := "scrypt$1024$8$1$c2FsdHNhbHRzYWx0c2E=$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA=="
if Verify("secret", encoded) {
t.Fatal("Verify accepted weak scrypt parameters")
}
if !NeedsRehash(encoded) {
t.Fatal("NeedsRehash = false for weak parameters")
}
}
func TestVerifyRejectsExcessiveMemory(t *testing.T) {
// N and r parse and clear the floor, but 128*N*r exceeds the bound.
encoded := "scrypt$1048576$1024$1$c2FsdHNhbHRzYWx0c2E=$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA=="
if Verify("secret", encoded) {
t.Fatal("Verify accepted an excessive-memory hash")
}
}
func TestNeedsRehash(t *testing.T) {
encoded, err := Hash("password123")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if NeedsRehash(encoded) {
t.Fatal("NeedsRehash = true for a current-policy hash")
}
if !NeedsRehash("garbage") {
t.Fatal("NeedsRehash = false for garbage")
}
}