A program bans an address (every port, or a protocol's ports) for a time; the ban list says what it is kept from: the firewall, some hosts behind it, or everything. Bans drop ahead of every rule, close the address's connections, group IPv6 by /64, are kept in the database across restarts and audited. Never banned: the inside, the firewall's own addresses, the caller, the list's never. bans grant (bans:write:LIST), hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/, and failed logins and refused tokens logged for Guard and fail2ban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
10 KiB
Go
344 lines
10 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"
|
|
|
|
"housecarl/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 = "housecarl_"
|
|
|
|
// 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)
|
|
}
|
|
|
|
// RotateToken makes a new secret for a token's public ID.
|
|
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 Housecarl 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" // the firewall configuration, its history and imports
|
|
ResSets = "sets" // dynamic sets' entries (a grant may name the sets)
|
|
ResBans = "bans" // ban lists' bans (a grant may name the lists)
|
|
ResLog = "log" // counters, the drop log and the status
|
|
ResAudit = "audit" // the audit log
|
|
ResAll = "*"
|
|
)
|
|
|
|
// Resources lists them in display order.
|
|
var Resources = []string{ResConfig, ResSets, ResBans, ResLog, 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: ResSets, Access: Read}, {Resource: ResBans, Access: Read}, {Resource: ResLog, Access: Read}},
|
|
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResBans, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
|
|
"log": {{Resource: ResLog, 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" ("log:read", "*:write"), and for
|
|
// sets and bans optionally the sets or ban lists it is limited to
|
|
// ("sets:write:guest,portal", "bans:write:web").
|
|
func ParseGrant(s string) (model.Grant, error) {
|
|
parts := strings.SplitN(strings.TrimSpace(s), ":", 3)
|
|
g := model.Grant{Resource: strings.ToLower(parts[0])}
|
|
if len(parts) < 2 {
|
|
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, sets:write:SET1,SET2 or bans:write:LIST", s)
|
|
}
|
|
g.Access = strings.ToLower(parts[1])
|
|
if len(parts) == 3 {
|
|
for _, n := range strings.Split(parts[2], ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
g.Sets = append(g.Sets, n)
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if len(g.Sets) > 0 && g.Resource != ResSets && g.Resource != ResBans {
|
|
return fmt.Errorf("only a sets or bans grant can name sets or ban lists (sets:write:%s)", strings.Join(g.Sets, ","))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormatGrant writes a grant as "resource:access" (and ":SET,…").
|
|
func FormatGrant(g model.Grant) string {
|
|
if len(g.Sets) > 0 {
|
|
return g.Resource + ":" + g.Access + ":" + strings.Join(g.Sets, ",")
|
|
}
|
|
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
|
|
}
|
|
|
|
// CanSet reports whether the principal may read or change the entries of
|
|
// a dynamic set: a sets grant covers it if it names no sets, or names it.
|
|
func (p *Principal) CanSet(name string, write bool) bool { return p.canNamed(ResSets, name, write) }
|
|
|
|
// CanBan reports whether the principal may read or change the bans of a
|
|
// ban list: a bans grant covers it if it names no lists, or names it.
|
|
func (p *Principal) CanBan(list string, write bool) bool { return p.canNamed(ResBans, list, write) }
|
|
|
|
func (p *Principal) canNamed(resource, name string, write bool) bool {
|
|
if p == nil || p.User == nil {
|
|
return false
|
|
}
|
|
allowed := p.User.Admin || coversNamed(p.User.Grants, resource, name, write)
|
|
if p.Token != nil && p.Token.Grants != nil {
|
|
allowed = allowed && coversNamed(p.Token.Grants, resource, name, write)
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
func coversNamed(gs []model.Grant, resource, name string, write bool) bool {
|
|
for _, g := range gs {
|
|
if (g.Resource != ResAll && g.Resource != resource) || (write && g.Access != Write) {
|
|
continue
|
|
}
|
|
if len(g.Sets) == 0 {
|
|
return true
|
|
}
|
|
for _, s := range g.Sets {
|
|
if strings.EqualFold(s, name) {
|
|
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
|
|
}
|