Files
ResolveSpec/pkg/security/lookup/direct/password.go
T
Hein c9fa8c60f2 refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
2026-10-01 13:19:44 +02:00

68 lines
2.0 KiB
Go

package direct
import (
"crypto/subtle"
"errors"
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
)
// bcrypt only considers the first 72 bytes of input; longer passwords are
// rejected rather than silently truncated.
const maxPasswordBytes = 72
var errPasswordTooLong = errors.New("password must be at most 72 bytes")
// HashPassword returns the bcrypt hash of password.
func HashPassword(password string) (string, error) {
if len(password) > maxPasswordBytes {
return "", errPasswordTooLong
}
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(h), nil
}
func isBcryptHash(s string) bool {
return strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$")
}
// VerifyPassword checks supplied against the stored value. A stored bcrypt hash is compared
// with bcrypt. A legacy cleartext value (written before hashing was implemented) is compared
// in constant time and, on a match, needsRehash is true so the caller can upgrade the row.
// An empty stored value (e.g. an OAuth2-only user) never matches.
func VerifyPassword(stored, supplied string) (ok, needsRehash bool) {
if stored == "" || supplied == "" || len(supplied) > maxPasswordBytes {
return false, false
}
if isBcryptHash(stored) {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(supplied)) == nil, false
}
if subtle.ConstantTimeCompare([]byte(stored), []byte(supplied)) == 1 {
return true, true
}
return false, false
}
var (
dummyHashOnce sync.Once
dummyHash string
)
// BurnPasswordCheck spends roughly one bcrypt comparison so an unknown username costs about
// the same as a wrong password.
func BurnPasswordCheck(supplied string) {
dummyHashOnce.Do(func() {
h, _ := bcrypt.GenerateFromPassword([]byte("resolvespec-dummy"), bcrypt.DefaultCost)
dummyHash = string(h)
})
if len(supplied) > maxPasswordBytes {
supplied = supplied[:maxPasswordBytes]
}
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(supplied))
}