// Package auth handles passwords, API tokens, sessions and permissions. package auth import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base32" "encoding/base64" "encoding/hex" "errors" "fmt" "strconv" "strings" "golang.org/x/crypto/argon2" ) // Argon2id parameters (OWASP recommendation: m=19 MiB, t=2, p=1). const ( argonTime = 2 argonMemory = 19 * 1024 argonThreads = 1 argonKeyLen = 32 saltLen = 16 ) // MinPasswordLength is the shortest accepted password. const MinPasswordLength = 8 // HashPassword returns an argon2id hash in PHC string format. func HashPassword(password string) (string, error) { if len(password) < MinPasswordLength { return "", fmt.Errorf("password must be at least %d characters", MinPasswordLength) } salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return "", err } key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil } // dummyHash is verified against when a user doesn't exist, so a login // attempt takes the same time whether or not the username is valid. var dummyHash, _ = HashPassword("not-a-real-password") // CheckPassword reports whether password matches hash. An empty hash // never matches (the time spent is the same). func CheckPassword(hash, password string) bool { if hash == "" { hash = dummyHash password = "\x00" + password } parts := strings.Split(hash, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false } var m, t uint32 var p uint8 for _, kv := range strings.Split(parts[3], ",") { k, v, _ := strings.Cut(kv, "=") n, err := strconv.ParseUint(v, 10, 32) if err != nil { return false } switch k { case "m": m = uint32(n) case "t": t = uint32(n) case "p": p = uint8(n) } } salt, err1 := base64.RawStdEncoding.DecodeString(parts[4]) want, err2 := base64.RawStdEncoding.DecodeString(parts[5]) if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 { return false } got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1 } var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) func randomString(nbytes int) string { b := make([]byte, nbytes) if _, err := rand.Read(b); err != nil { panic(err) } return b32.EncodeToString(b) } // TokenPrefix starts every Jarl API token, which makes leaked tokens easy // to recognize (and to find with secret scanners). const TokenPrefix = "jarl_" // NewToken generates a token: the full secret string to give to the user, // its public ID (stored in clear) and the hash of the secret part. func NewToken() (token, publicID, secretHash string) { publicID = randomString(10) // 16 chars secret := randomString(20) // 32 chars return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret) } // RotateToken makes a new token with an existing public ID (a rotated // token keeps its ID; only the secret changes). func RotateToken(publicID string) (token, secretHash string) { secret := randomString(20) return TokenPrefix + publicID + "_" + secret, HashSecret(secret) } // ParseToken splits a token into its public ID and secret. func ParseToken(token string) (publicID, secret string, err error) { rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix) if !ok { return "", "", errors.New("not a Jarl token") } publicID, secret, ok = strings.Cut(rest, "_") if !ok || len(publicID) != 16 || len(secret) != 32 { return "", "", errors.New("malformed token") } return publicID, secret, nil } // HashSecret hashes a high-entropy secret (tokens, session IDs). func HashSecret(secret string) string { h := sha256.Sum256([]byte(secret)) return hex.EncodeToString(h[:]) } // CheckSecret compares a secret with a stored hash in constant time. func CheckSecret(secret, hash string) bool { return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1 } // NewSessionID returns a new session ID and CSRF token. func NewSessionID() (id, csrf string) { return randomString(32), randomString(32) } // GenerateTSIGSecret returns a random base64 secret of n bytes. func GenerateTSIGSecret(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) } return base64.StdEncoding.EncodeToString(b) }