// 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) 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, 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: ResLog, Access: Read}}, "operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, 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 optionally the sets it is limited to ("sets:write:guest,portal"). 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, or sets:write:SET1,SET2", 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 { return fmt.Errorf("only a sets grant can name sets (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 { if p == nil || p.User == nil { return false } allowed := p.User.Admin || coversSet(p.User.Grants, name, write) if p.Token != nil && p.Token.Grants != nil { allowed = allowed && coversSet(p.Token.Grants, name, write) } return allowed } func coversSet(gs []model.Grant, name string, write bool) bool { for _, g := range gs { if (g.Resource != ResAll && g.Resource != ResSets) || (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 }