Files
neural-hunt/internal/customer/security.go
jbergner 74d6b2f7d2
Some checks failed
release-tag / release-image (push) Failing after 2m11s
RC-10
2026-08-11 22:08:00 +02:00

134 lines
3.7 KiB
Go

package customer
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"errors"
"strconv"
"strings"
"time"
)
var rawURL = base64.RawURLEncoding
func RandomToken(n int) string {
if n < 16 {
n = 16
}
b := make([]byte, n)
_, _ = rand.Read(b)
return rawURL.EncodeToString(b)
}
func NewPasswordHash(password string) (salt string, hash string, err error) {
if len(strings.TrimSpace(password)) < 12 {
return "", "", errors.New("password must be at least 12 characters")
}
s := make([]byte, 16)
if _, err := rand.Read(s); err != nil {
return "", "", err
}
h := pbkdf2SHA256([]byte(password), s, 310000, 32)
return rawURL.EncodeToString(s), rawURL.EncodeToString(h), nil
}
func VerifyPassword(password, salt, hash string) bool {
s, err := rawURL.DecodeString(salt)
if err != nil {
return false
}
want, err := rawURL.DecodeString(hash)
if err != nil {
return false
}
got := pbkdf2SHA256([]byte(password), s, 310000, len(want))
return len(got) == len(want) && subtle.ConstantTimeCompare(got, want) == 1
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
hLen := sha256.Size
blocks := (keyLen + hLen - 1) / hLen
out := make([]byte, 0, blocks*hLen)
for block := 1; block <= blocks; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
var n [4]byte
binary.BigEndian.PutUint32(n[:], uint32(block))
mac.Write(n[:])
u := mac.Sum(nil)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
out = append(out, t...)
}
return out[:keyLen]
}
// RegistrationChallenge is a stateless, username-bound proof-of-work challenge.
// It prevents a solved challenge from being reused to create multiple different
// accounts while avoiding an unbounded server-side challenge store.
func RegistrationChallenge(secret, username string, ttlSeconds int) string {
if ttlSeconds <= 0 {
ttlSeconds = 300
}
nonce := RandomToken(18)
expires := time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Unix()
body := strings.ToLower(strings.TrimSpace(username)) + "|" + nonce + "|" + strconv.FormatInt(expires, 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return nonce + "." + strconv.FormatInt(expires, 10) + "." + rawURL.EncodeToString(mac.Sum(nil))
}
func VerifyRegistrationChallenge(secret, username, challenge string) bool {
parts := strings.Split(challenge, ".")
if len(parts) != 3 {
return false
}
expires, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || expires < time.Now().UTC().Unix() || expires > time.Now().UTC().Add(10*time.Minute).Unix() {
return false
}
body := strings.ToLower(strings.TrimSpace(username)) + "|" + parts[0] + "|" + parts[1]
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
want := mac.Sum(nil)
got, err := rawURL.DecodeString(parts[2])
return err == nil && len(got) == len(want) && subtle.ConstantTimeCompare(got, want) == 1
}
func VerifyRegistrationProof(username, challenge string, counter uint64, bits int) bool {
if bits <= 0 {
return true
}
if bits > 28 {
return false
}
msg := "nh-register|" + strings.ToLower(strings.TrimSpace(username)) + "|" + challenge + "|" + strconv.FormatUint(counter, 10)
h := sha256.Sum256([]byte(msg))
full := bits / 8
rem := bits % 8
for i := 0; i < full; i++ {
if h[i] != 0 {
return false
}
}
if rem > 0 {
mask := byte(0xff << (8 - rem))
return h[full]&mask == 0
}
return true
}
func HashInviteCode(code string) string {
h := sha256.Sum256([]byte(strings.TrimSpace(code)))
return base64.RawURLEncoding.EncodeToString(h[:])
}