jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.
Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
4.5 KiB
Go
152 lines
4.5 KiB
Go
// 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)
|
|
}
|