Files
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:07 +01:00

282 lines
7.7 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"
"sort"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"steward/internal/model"
)
// Argon2id parameters (OWASP: 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("a password needs 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 checked when a user doesn't exist, so a login takes as
// long whether or not the username is right.
var dummyHash, _ = HashPassword("not-a-real-password")
// CheckPassword reports whether password matches hash. An empty hash never
// matches (in the same time).
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)
// RandomString returns a random string with nbytes of entropy.
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 API token, so leaked ones are easy to spot.
const TokenPrefix = "steward_"
// NewToken makes a token: the whole string to give out, its public ID
// (stored in clear) and the hash of its secret.
func NewToken() (token, publicID, secretHash string) {
publicID = RandomString(10) // 16 characters
secret := RandomString(20) // 32 characters
return TokenPrefix + publicID + "_" + secret, publicID, 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 Steward token (they start with " + TokenPrefix + ")")
}
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 (token secrets, 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 session ID and its CSRF token.
func NewSessionID() (id, csrf string) {
return RandomString(32), RandomString(32)
}
// Resources grants apply to.
const (
ResConfig = "config" // settings, subnets, reservations, history
ResLeases = "leases" // leases and DHCP activity
ResAudit = "audit" // the audit log
ResAll = "*"
)
// Resources lists them in display order.
var Resources = []string{ResConfig, ResLeases, ResAudit}
// Access levels.
const (
Read = "read"
Write = "write"
)
// Roles are ready-made sets of grants.
var Roles = map[string][]model.Grant{
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResLeases, Access: Read}},
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResLeases, Access: Write}, {Resource: ResAudit, Access: Read}},
"leases": {{Resource: ResLeases, Access: Read}},
}
// RoleNames lists the roles.
func RoleNames() []string {
var out []string
for n := range Roles {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ParseGrant reads "resource:access" ("leases:read", "*:write").
func ParseGrant(s string) (model.Grant, error) {
res, acc, ok := strings.Cut(strings.ToLower(strings.TrimSpace(s)), ":")
g := model.Grant{Resource: res, Access: acc}
if !ok {
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. leases:read", s)
}
return g, ValidateGrant(&g)
}
// ValidateGrant checks a grant.
func ValidateGrant(g *model.Grant) error {
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
ok := g.Resource == ResAll
for _, r := range Resources {
ok = ok || g.Resource == r
}
if !ok {
return fmt.Errorf("unknown resource %q (use %s or *)", g.Resource, strings.Join(Resources, ", "))
}
if g.Access != Read && g.Access != Write {
return fmt.Errorf("access must be read or write, not %q", g.Access)
}
return nil
}
// FormatGrant writes a grant as "resource:access".
func FormatGrant(g model.Grant) string { return g.Resource + ":" + g.Access }
// Principal is who makes a request.
type Principal struct {
User *model.User
Token *model.Token
Method string // session, token, socket
IP string
Agent string
}
// Name is the user's name.
func (p *Principal) Name() string {
if p == nil || p.User == nil {
return ""
}
return p.User.Username
}
// IsAdmin reports whether the principal has full rights: an
// administrator, not through a restricted token.
func (p *Principal) IsAdmin() bool {
return p.User != nil && p.User.Admin && (p.Token == nil || p.Token.Grants == nil)
}
// Can reports whether the principal may read (write false) or change
// (write true) a resource.
func (p *Principal) Can(resource string, write bool) bool {
if p == nil || p.User == nil {
return false
}
allowed := p.User.Admin || covers(p.User.Grants, resource, write)
if p.Token != nil && p.Token.Grants != nil {
allowed = allowed && covers(p.Token.Grants, resource, write)
}
return allowed
}
func covers(gs []model.Grant, resource string, write bool) bool {
for _, g := range gs {
if (g.Resource == ResAll || g.Resource == resource) && (!write || g.Access == Write) {
return true
}
}
return false
}
// Permissions lists what the principal may do, for display.
func (p *Principal) Permissions() []string {
var out []string
for _, r := range Resources {
switch {
case p.Can(r, true):
out = append(out, r+":write")
case p.Can(r, false):
out = append(out, r+":read")
}
}
if p.IsAdmin() {
out = append(out, "users:write")
}
return out
}
// Narrower reports whether every grant in a is covered by b.
func Narrower(a, b []model.Grant) bool {
for _, g := range a {
if !covers(b, g.Resource, g.Access == Write) {
if g.Resource != ResAll {
return false
}
for _, r := range Resources {
if !covers(b, r, g.Access == Write) {
return false
}
}
}
}
return true
}