fix: harden credential and input handling
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHashProducesDistinctHashesForTheSamePassword(t *testing.T) {
|
||||
const pw = "correct horse battery staple"
|
||||
a, err := HashPassword(pw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := HashPassword(pw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("identical hashes for the same password: the salt is not random")
|
||||
}
|
||||
for _, h := range []string{a, b} {
|
||||
if strings.Contains(h, pw) {
|
||||
t.Fatal("the hash contains the plain-text password")
|
||||
}
|
||||
if !strings.HasPrefix(h, "$argon2id$") {
|
||||
t.Fatalf("hash is not Argon2id: %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAcceptsCorrectAndRejectsWrongPasswords(t *testing.T) {
|
||||
const pw = "correct horse battery staple"
|
||||
h, err := HashPassword(pw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(pw, h)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("correct password rejected: ok=%v err=%v", ok, err)
|
||||
}
|
||||
for _, wrong := range []string{
|
||||
"correct horse battery stapl",
|
||||
"correct horse battery staple ",
|
||||
"Correct horse battery staple",
|
||||
"",
|
||||
strings.Repeat("x", 200),
|
||||
} {
|
||||
ok, err := VerifyPassword(wrong, h)
|
||||
if err != nil && !errors.Is(err, ErrPasswordTooLong) {
|
||||
t.Fatalf("unexpected error for %q: %v", wrong, err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("wrong password %q was accepted", wrong)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashEncodesItsOwnParameters(t *testing.T) {
|
||||
h, err := HashPassword("a sufficiently long password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Parameters travel with the hash, so they can be raised later without
|
||||
// invalidating existing passwords.
|
||||
for _, want := range []string{"v=19", "m=", "t=", "p="} {
|
||||
if !strings.Contains(h, want) {
|
||||
t.Fatalf("hash %q does not record %q", h, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsMalformedHashes(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"not phc": "plaintext",
|
||||
"wrong algorithm": "$argon2i$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA",
|
||||
"bad version": "$argon2id$v=99$m=65536,t=3,p=2$c2FsdA$aGFzaA",
|
||||
"missing sections": "$argon2id$v=19$m=65536,t=3,p=2",
|
||||
"bad base64": "$argon2id$v=19$m=65536,t=3,p=2$!!!$aGFzaA",
|
||||
"weak params": "$argon2id$v=19$m=1,t=1,p=1$c2FsdA$aGFzaA",
|
||||
"zero parallelism": "$argon2id$v=19$m=65536,t=3,p=0$c2FsdA$aGFzaA",
|
||||
"unknown param": "$argon2id$v=19$m=65536,t=3,x=2$c2FsdA$aGFzaA",
|
||||
"excessive memory": "$argon2id$v=19$m=4294967295,t=3,p=2$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcA",
|
||||
"excessive time": "$argon2id$v=19$m=65536,t=4294967295,p=2$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcA",
|
||||
"oversized hash": strings.Repeat("x", maxEncodedHashLen+1),
|
||||
}
|
||||
for name, h := range cases {
|
||||
ok, err := VerifyPassword("anything", h)
|
||||
if ok {
|
||||
t.Errorf("%s: a malformed hash verified successfully", name)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("%s: malformed hash produced no error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordPolicy(t *testing.T) {
|
||||
if err := ValidatePassword(strings.Repeat("a", MinPasswordLength-1)); !errors.Is(err, ErrPasswordTooShort) {
|
||||
t.Fatalf("short password accepted: %v", err)
|
||||
}
|
||||
if err := ValidatePassword(strings.Repeat("a", MaxPasswordLength+1)); !errors.Is(err, ErrPasswordTooLong) {
|
||||
t.Fatalf("overlong password accepted: %v", err)
|
||||
}
|
||||
if err := ValidatePassword(strings.Repeat("a", MinPasswordLength)); err != nil {
|
||||
t.Fatalf("valid password rejected: %v", err)
|
||||
}
|
||||
// A very long password must be rejected rather than hashed: Argon2id would
|
||||
// happily consume it, but the cost is attacker-controlled.
|
||||
if _, err := HashPassword(strings.Repeat("a", MaxPasswordLength+1)); err == nil {
|
||||
t.Fatal("an unbounded password was hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameValidation(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"Admin": "admin",
|
||||
" ops.team ": "ops.team",
|
||||
"a_b-c.123": "a_b-c.123",
|
||||
}
|
||||
for in, want := range valid {
|
||||
got, err := ValidateUsername(in)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateUsername(%q) errored: %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("ValidateUsername(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "ab", strings.Repeat("a", 65), "has space", "quote'", "semi;colon", "$ne", "../../etc"} {
|
||||
if _, err := ValidateUsername(bad); err == nil {
|
||||
t.Errorf("ValidateUsername(%q) was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A username that would be meaningful to MongoDB as an operator must be
|
||||
// rejected before it ever reaches a query.
|
||||
func TestUsernameRejectsOperatorShapedInput(t *testing.T) {
|
||||
for _, bad := range []string{"$ne", "$gt", "{\"$ne\":null}", "a$b"} {
|
||||
if _, err := ValidateUsername(bad); err == nil {
|
||||
t.Errorf("operator-shaped username %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDummyVerificationCostsRealTime(t *testing.T) {
|
||||
// The unknown-user path must not be obviously faster than a real
|
||||
// verification, or response timing would enumerate accounts.
|
||||
h, err := HashPassword("a sufficiently long password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, _ = VerifyPassword("wrong password entirely", h)
|
||||
real := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
SpendDummyVerification("wrong password entirely")
|
||||
dummy := time.Since(start)
|
||||
|
||||
if dummy < real/4 {
|
||||
t.Fatalf("dummy verification took %v against a real %v: the timing gap would leak account existence", dummy, real)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFComparison(t *testing.T) {
|
||||
if !CheckCSRF("token-value", "token-value") {
|
||||
t.Fatal("matching CSRF tokens rejected")
|
||||
}
|
||||
for _, bad := range []string{"", "token-valu", "token-value ", "TOKEN-VALUE", "x"} {
|
||||
if CheckCSRF("token-value", bad) {
|
||||
t.Fatalf("CSRF token %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if CheckCSRF("", "") {
|
||||
t.Fatal("two empty tokens compared as a match")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rate limiting ---------------------------------------------------------
|
||||
|
||||
func TestLoginLimiterEnforcesBurst(t *testing.T) {
|
||||
l := newLoginLimiter(3, time.Minute)
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.allow("client-a") {
|
||||
t.Fatalf("attempt %d refused within the burst", i+1)
|
||||
}
|
||||
}
|
||||
if l.allow("client-a") {
|
||||
t.Fatal("attempt beyond the burst was permitted")
|
||||
}
|
||||
// A different client is unaffected.
|
||||
if !l.allow("client-b") {
|
||||
t.Fatal("one client's limit blocked another")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterResetsOnSuccess(t *testing.T) {
|
||||
l := newLoginLimiter(2, time.Minute)
|
||||
l.allow("c")
|
||||
l.allow("c")
|
||||
if l.allow("c") {
|
||||
t.Fatal("limit not reached")
|
||||
}
|
||||
l.reset("c")
|
||||
if !l.allow("c") {
|
||||
t.Fatal("a successful login did not clear the attempt budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterWindowExpires(t *testing.T) {
|
||||
l := newLoginLimiter(1, 20*time.Millisecond)
|
||||
if !l.allow("c") {
|
||||
t.Fatal("first attempt refused")
|
||||
}
|
||||
if l.allow("c") {
|
||||
t.Fatal("second attempt allowed within the window")
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if !l.allow("c") {
|
||||
t.Fatal("the window did not expire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterDoesNotGrowWithoutBound(t *testing.T) {
|
||||
l := newLoginLimiter(1, 10*time.Millisecond)
|
||||
for i := 0; i < 500; i++ {
|
||||
l.allow(string(rune('a'+i%26)) + string(rune('0'+i%10)) + "-key")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// A sweep runs on the next call, discarding expired buckets.
|
||||
l.allow("trigger-sweep")
|
||||
|
||||
l.mu.Lock()
|
||||
n := len(l.buckets)
|
||||
l.mu.Unlock()
|
||||
if n > 50 {
|
||||
t.Fatalf("limiter retained %d buckets after expiry; memory would grow without bound", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client key ------------------------------------------------------------
|
||||
|
||||
func TestClientKeyIgnoresProxyHeadersByDefault(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
r.RemoteAddr = "192.0.2.10:5555"
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
|
||||
// Trusting the header by default would let anyone bypass rate limiting by
|
||||
// varying it.
|
||||
if got := ClientKey(r, false); got != "192.0.2.10" {
|
||||
t.Fatalf("ClientKey = %q; a spoofable header was trusted", got)
|
||||
}
|
||||
if got := ClientKey(r, true); got != "203.0.113.1" {
|
||||
t.Fatalf("ClientKey with trusted proxy = %q, want the forwarded address", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientKeyHandlesIPv6AndMalformedAddresses(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"[2001:db8::1]:443": "2001:db8::1",
|
||||
"192.0.2.1:80": "192.0.2.1",
|
||||
"garbage": "garbage",
|
||||
}
|
||||
for addr, want := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = addr
|
||||
if got := ClientKey(r, false); got != want {
|
||||
t.Errorf("ClientKey(%q) = %q, want %q", addr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgentIsHashedNotStored(t *testing.T) {
|
||||
ua := "Mozilla/5.0 (very identifying string)"
|
||||
h := hashUserAgent(ua)
|
||||
if h == "" {
|
||||
t.Fatal("no hash produced")
|
||||
}
|
||||
if strings.Contains(h, "Mozilla") {
|
||||
t.Fatal("the user agent was stored verbatim")
|
||||
}
|
||||
if h != hashUserAgent(ua) {
|
||||
t.Fatal("hashing is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzDecodeHash(f *testing.F) {
|
||||
h, _ := HashPassword("a sufficiently long password")
|
||||
f.Add(h)
|
||||
f.Add("$argon2id$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA")
|
||||
f.Add("")
|
||||
|
||||
f.Fuzz(func(t *testing.T, encoded string) {
|
||||
// Must never panic, and must never verify an arbitrary string.
|
||||
ok, err := VerifyPassword("some candidate password", encoded)
|
||||
if ok && err != nil {
|
||||
t.Fatal("verification both succeeded and errored")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Package auth implements local authentication: Argon2id password hashing,
|
||||
// server-side sessions, CSRF protection and login rate limiting.
|
||||
//
|
||||
// Plain-text passwords are never stored, never logged and never compared with
|
||||
// anything other than a constant-time function.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2 parameters.
|
||||
//
|
||||
// These target roughly 64 MiB and a few hundred milliseconds on a modern
|
||||
// machine — deliberately expensive, because the whole point is to make an
|
||||
// offline guessing attack against a stolen hash uneconomic. The parameters are
|
||||
// stored in each hash, so raising them later does not invalidate existing
|
||||
// passwords: an old hash still verifies with its own parameters.
|
||||
const (
|
||||
argonTime uint32 = 3
|
||||
argonMemory uint32 = 64 * 1024 // KiB
|
||||
argonKeyLen uint32 = 32
|
||||
argonSaltLen = 16
|
||||
|
||||
// Stored hashes are data, not trusted instructions. These generous ceilings
|
||||
// prevent a corrupt or attacker-modified database value from turning login
|
||||
// into an extreme allocation or CPU request while leaving room for upgrades.
|
||||
maxEncodedHashLen = 512
|
||||
maxArgonMemory uint32 = 256 * 1024 // KiB
|
||||
maxArgonTime uint32 = 10
|
||||
maxArgonParallelism = 16
|
||||
maxArgonSaltLen = 64
|
||||
maxArgonKeyLen = 64
|
||||
)
|
||||
|
||||
// Password policy.
|
||||
const (
|
||||
// MinPasswordLength is deliberately generous rather than demanding
|
||||
// character classes: length beats composition rules for real strength.
|
||||
MinPasswordLength = 12
|
||||
MaxPasswordLength = 1024
|
||||
MinUsernameLength = 3
|
||||
MaxUsernameLength = 64
|
||||
)
|
||||
|
||||
// Errors returned by password handling.
|
||||
var (
|
||||
ErrPasswordTooShort = fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||
ErrPasswordTooLong = fmt.Errorf("password must be at most %d characters", MaxPasswordLength)
|
||||
ErrInvalidHashFormat = errors.New("stored password hash is not in the expected format")
|
||||
ErrIncompatibleHash = errors.New("stored password hash uses an unsupported algorithm version")
|
||||
ErrInvalidUsername = errors.New("username must be 3-64 characters using letters, digits, dot, dash or underscore")
|
||||
)
|
||||
|
||||
// argonParallelism picks a lane count from the machine's CPUs, bounded so that
|
||||
// a hash produced on a large host still verifies affordably on a small one.
|
||||
func argonParallelism() uint8 {
|
||||
n := runtime.NumCPU()
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > 4 {
|
||||
n = 4
|
||||
}
|
||||
return uint8(n)
|
||||
}
|
||||
|
||||
// HashPassword returns an encoded Argon2id hash in the standard PHC string
|
||||
// format, which embeds the algorithm, its parameters and the salt.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
par := argonParallelism()
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, par, argonKeyLen)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, par,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a candidate password against an encoded hash.
|
||||
//
|
||||
// The comparison is constant-time so that verification duration cannot reveal
|
||||
// how much of the hash matched.
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
params, salt, want, err := decodeHash(encoded)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(password) > MaxPasswordLength {
|
||||
return false, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.parallelism, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
type argonParams struct {
|
||||
memory uint32
|
||||
time uint32
|
||||
parallelism uint8
|
||||
}
|
||||
|
||||
func decodeHash(encoded string) (argonParams, []byte, []byte, error) {
|
||||
var p argonParams
|
||||
if len(encoded) == 0 || len(encoded) > maxEncodedHashLen {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
|
||||
parts := strings.Split(encoded, "$")
|
||||
// "", "argon2id", "v=19", "m=...,t=...,p=...", salt, key
|
||||
if len(parts) != 6 || parts[0] != "" {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
if parts[1] != "argon2id" {
|
||||
return p, nil, nil, ErrIncompatibleHash
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return p, nil, nil, ErrIncompatibleHash
|
||||
}
|
||||
|
||||
kv := strings.Split(parts[3], ",")
|
||||
if len(kv) != 3 {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
for _, item := range kv {
|
||||
k, v, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
switch k {
|
||||
case "m":
|
||||
p.memory = uint32(n)
|
||||
case "t":
|
||||
p.time = uint32(n)
|
||||
case "p":
|
||||
if n == 0 || n > 255 {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
p.parallelism = uint8(n)
|
||||
default:
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
}
|
||||
// Reject nonsensical parameters that would make verification trivial or
|
||||
// impossibly expensive.
|
||||
if p.memory < 1024 || p.memory > maxArgonMemory ||
|
||||
p.time == 0 || p.time > maxArgonTime ||
|
||||
p.parallelism == 0 || p.parallelism > maxArgonParallelism {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(salt) < argonSaltLen || len(salt) > maxArgonSaltLen {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
key, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil || len(key) < 16 || len(key) > maxArgonKeyLen {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
return p, salt, key, nil
|
||||
}
|
||||
|
||||
// ValidatePassword enforces the password policy.
|
||||
func ValidatePassword(password string) error {
|
||||
n := utf8.RuneCountInString(password)
|
||||
if n < MinPasswordLength {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
if len(password) > MaxPasswordLength {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUsername enforces the username policy and returns the canonical
|
||||
// lowercase form.
|
||||
func ValidateUsername(username string) (string, error) {
|
||||
u := strings.ToLower(strings.TrimSpace(username))
|
||||
if len(u) < MinUsernameLength || len(u) > MaxUsernameLength {
|
||||
return "", ErrInvalidUsername
|
||||
}
|
||||
for _, r := range u {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '.', r == '-', r == '_':
|
||||
default:
|
||||
return "", ErrInvalidUsername
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// dummyHash is a real Argon2id hash of a random value, used to spend
|
||||
// comparable time when a username does not exist.
|
||||
//
|
||||
// Without it, a failed lookup would return noticeably faster than a wrong
|
||||
// password, and the response time would disclose which usernames are
|
||||
// registered.
|
||||
var dummyHash = func() string {
|
||||
buf := make([]byte, 32)
|
||||
_, _ = rand.Read(buf)
|
||||
h, err := HashPassword(base64.RawStdEncoding.EncodeToString(buf))
|
||||
if err != nil {
|
||||
// Only reachable if the policy rejects a 43-character random string.
|
||||
return "$argon2id$v=19$m=65536,t=3,p=2$YWJjZGVmZ2hpamtsbW5vcA$" +
|
||||
"c29tZWZpeGVkdmFsdWV0aGF0d2lsbG5ldmVybWF0Y2g"
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
// SpendDummyVerification performs a password verification against a throwaway
|
||||
// hash. Callers use it on the "no such user" path so that both outcomes cost
|
||||
// roughly the same.
|
||||
func SpendDummyVerification(password string) {
|
||||
_, _ = VerifyPassword(password, dummyHash)
|
||||
}
|
||||
Reference in New Issue
Block a user