Files
abrown38andClaude Opus 5.5 0b2159b1a4 Add token rotation
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>
2026-10-08 13:58:50 +00:00

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)
}