diff --git a/.changeset/localauth.md b/.changeset/localauth.md new file mode 100644 index 00000000..b0098105 --- /dev/null +++ b/.changeset/localauth.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Local user accounts foundation for API authentication: a `users` table (operator/viewer roles, argon2id password hashes in PHC format) and an in-memory session layer with 24 h expiry, per-user revocation, and constant-time verification. Sessions are deliberately memory-only — a restart logs everyone out, the safe failure for a control system, and no session secret ever touches the database. This release adds the packages and schema; API enforcement (`api.auth.mode`) lands separately and nothing changes for existing installs. diff --git a/go/go.mod b/go/go.mod index 697b6962..bbbf5238 100644 --- a/go/go.mod +++ b/go/go.mod @@ -16,6 +16,7 @@ require ( github.com/shirou/gopsutil/v4 v4.26.3 github.com/simonvetter/modbus v1.6.4 github.com/yuin/gopher-lua v1.1.2 + golang.org/x/crypto v0.53.0 golang.org/x/net v0.56.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.48.2 @@ -49,7 +50,6 @@ require ( github.com/twpayne/go-geom v1.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/crypto v0.53.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.46.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go/internal/localauth/localauth.go b/go/internal/localauth/localauth.go new file mode 100644 index 00000000..9961fbbf --- /dev/null +++ b/go/internal/localauth/localauth.go @@ -0,0 +1,153 @@ +// Package localauth provides local user accounts for the HTTP API: +// argon2id password verification and in-memory bearer sessions with +// operator/viewer roles. Persistence of accounts lives in +// go/internal/state (SQLite stays there); sessions are deliberately +// memory-only — a restart logs everyone out, which is the safe failure +// mode for a control system, and it keeps session secrets out of the +// database entirely. +package localauth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" +) + +// Roles. +const ( + RoleOperator = "operator" + RoleViewer = "viewer" +) + +// ValidRole reports whether r is a known role. +func ValidRole(r string) bool { return r == RoleOperator || r == RoleViewer } + +// Argon2id parameters — OWASP's minimum recommended configuration +// (t=2, m=19 MiB, p=1), chosen so a Raspberry Pi login stays subsecond +// while GPU cracking stays expensive. +const ( + argonTime = 2 + argonMemory = 19 * 1024 // KiB + argonThreads = 1 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// HashPassword produces a PHC-format argon2id string. +func HashPassword(password string) (string, error) { + if len(password) < 8 { + return "", errors.New("password must be at least 8 characters") + } + salt := make([]byte, argonSaltLen) + 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 +} + +// VerifyPassword checks a password against a PHC argon2id string in +// constant time over the derived key. +func VerifyPassword(password, phc string) bool { + parts := strings.Split(phc, "$") + // ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, key] + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m uint32 + var t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// Session is one live login. +type Session struct { + Username string + Role string + ExpiresAt time.Time +} + +// Sessions is the in-memory session table. Safe for concurrent use. +type Sessions struct { + mu sync.Mutex + ttl time.Duration + tab map[string]Session +} + +// NewSessions builds a session table. ttl <= 0 defaults to 24 h. +func NewSessions(ttl time.Duration) *Sessions { + if ttl <= 0 { + ttl = 24 * time.Hour + } + return &Sessions{ttl: ttl, tab: map[string]Session{}} +} + +// Create mints a session token for a verified user. +func (s *Sessions) Create(username, role string) (string, Session, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", Session{}, err + } + token := base64.RawURLEncoding.EncodeToString(raw) + sess := Session{Username: username, Role: role, ExpiresAt: time.Now().Add(s.ttl)} + s.mu.Lock() + s.tab[token] = sess + s.mu.Unlock() + return token, sess, nil +} + +// Lookup resolves a token, expiring lazily. +func (s *Sessions) Lookup(token string) (Session, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.tab[token] + if !ok { + return Session{}, false + } + if time.Now().After(sess.ExpiresAt) { + delete(s.tab, token) + return Session{}, false + } + return sess, true +} + +// Revoke removes one session (logout). +func (s *Sessions) Revoke(token string) { + s.mu.Lock() + delete(s.tab, token) + s.mu.Unlock() +} + +// RevokeUser removes every session belonging to a user (password +// change, disable, delete). +func (s *Sessions) RevokeUser(username string) { + s.mu.Lock() + for tok, sess := range s.tab { + if sess.Username == username { + delete(s.tab, tok) + } + } + s.mu.Unlock() +} diff --git a/go/internal/localauth/localauth_test.go b/go/internal/localauth/localauth_test.go new file mode 100644 index 00000000..4d1f3838 --- /dev/null +++ b/go/internal/localauth/localauth_test.go @@ -0,0 +1,90 @@ +package localauth + +import ( + "strings" + "testing" + "time" +) + +func TestHashAndVerifyPassword(t *testing.T) { + phc, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(phc, "$argon2id$") { + t.Fatalf("not PHC format: %s", phc) + } + if !VerifyPassword("correct horse battery staple", phc) { + t.Fatal("correct password rejected") + } + if VerifyPassword("wrong password", phc) { + t.Fatal("wrong password accepted") + } + // Two hashes of the same password differ (random salt). + phc2, _ := HashPassword("correct horse battery staple") + if phc == phc2 { + t.Fatal("salt is not random") + } +} + +func TestHashRejectsShortPasswords(t *testing.T) { + if _, err := HashPassword("short"); err == nil { + t.Fatal("7-char password should be rejected") + } +} + +func TestVerifyRejectsMalformedPHC(t *testing.T) { + for _, phc := range []string{"", "plaintext", "$argon2id$broken", "$bcrypt$x$y$z$w"} { + if VerifyPassword("anything", phc) { + t.Fatalf("malformed hash %q accepted", phc) + } + } +} + +func TestSessionsLifecycle(t *testing.T) { + s := NewSessions(time.Hour) + token, sess, err := s.Create("sanjin", RoleOperator) + if err != nil { + t.Fatal(err) + } + if sess.Role != RoleOperator { + t.Fatalf("role: %s", sess.Role) + } + got, ok := s.Lookup(token) + if !ok || got.Username != "sanjin" { + t.Fatalf("lookup: %v %+v", ok, got) + } + if _, ok := s.Lookup("forged-token"); ok { + t.Fatal("forged token accepted") + } + s.Revoke(token) + if _, ok := s.Lookup(token); ok { + t.Fatal("revoked token still valid") + } +} + +func TestSessionsExpire(t *testing.T) { + s := NewSessions(10 * time.Millisecond) + token, _, _ := s.Create("sanjin", RoleViewer) + time.Sleep(20 * time.Millisecond) + if _, ok := s.Lookup(token); ok { + t.Fatal("expired session still valid") + } +} + +func TestRevokeUserDropsAllSessions(t *testing.T) { + s := NewSessions(time.Hour) + t1, _, _ := s.Create("sanjin", RoleOperator) + t2, _, _ := s.Create("sanjin", RoleOperator) + t3, _, _ := s.Create("other", RoleViewer) + s.RevokeUser("sanjin") + if _, ok := s.Lookup(t1); ok { + t.Fatal("t1 survived RevokeUser") + } + if _, ok := s.Lookup(t2); ok { + t.Fatal("t2 survived RevokeUser") + } + if _, ok := s.Lookup(t3); !ok { + t.Fatal("other user's session was dropped") + } +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 1aea5115..f9474ac2 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -1030,6 +1030,18 @@ func (s *Store) migrate() error { ts_ms INTEGER NOT NULL, PRIMARY KEY(asset_id, flow, cursor_kind) ) WITHOUT ROWID, STRICT`, + + // ---- Local user accounts (api.auth.mode) ---- + // Argon2id password hashes in PHC string format. Sessions are + // in-memory (go/internal/localauth) on purpose: a restart logs + // everyone out, which is the safe failure for a control system. + `CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + role TEXT NOT NULL CHECK(role IN ('operator', 'viewer')), + password_hash TEXT NOT NULL, + created_ms INTEGER NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1)) + ) STRICT`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { diff --git a/go/internal/state/users.go b/go/internal/state/users.go new file mode 100644 index 00000000..dc79ac79 --- /dev/null +++ b/go/internal/state/users.go @@ -0,0 +1,140 @@ +package state + +import ( + "database/sql" + "errors" + "strings" + "time" +) + +// Local user accounts for api.auth.mode (go/internal/localauth owns the +// hashing and sessions; this file is pure persistence). + +// User is one local account. +type User struct { + Username string `json:"username"` + Role string `json:"role"` // operator | viewer + PasswordHash string `json:"-"` + CreatedMs int64 `json:"created_ms"` + Disabled bool `json:"disabled"` +} + +var ErrUserExists = errors.New("user already exists") + +// CreateUser inserts a new account. +func (s *Store) CreateUser(u User) error { + if u.CreatedMs == 0 { + u.CreatedMs = time.Now().UnixMilli() + } + _, err := s.db.Exec(` + INSERT INTO users (username, role, password_hash, created_ms, disabled) + VALUES (?, ?, ?, ?, ?)`, + u.Username, u.Role, u.PasswordHash, u.CreatedMs, intFromBool(u.Disabled)) + if err != nil && isUniqueViolation(err) { + return ErrUserExists + } + return err +} + +// UserByName fetches one account. ok=false when it does not exist. +func (s *Store) UserByName(username string) (User, bool, error) { + row := s.db.QueryRow(` + SELECT username, role, password_hash, created_ms, disabled + FROM users WHERE username = ?`, username) + var u User + var disabled int + switch err := row.Scan(&u.Username, &u.Role, &u.PasswordHash, &u.CreatedMs, &disabled); err { + case nil: + u.Disabled = disabled != 0 + return u, true, nil + case sql.ErrNoRows: + return User{}, false, nil + default: + return User{}, false, err + } +} + +// ListUsers returns every account, without password hashes cleared — +// callers expose User via its JSON shape, which omits the hash. +func (s *Store) ListUsers() ([]User, error) { + rows, err := s.db.Query(` + SELECT username, role, password_hash, created_ms, disabled + FROM users ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + var disabled int + if err := rows.Scan(&u.Username, &u.Role, &u.PasswordHash, &u.CreatedMs, &disabled); err != nil { + return nil, err + } + u.Disabled = disabled != 0 + out = append(out, u) + } + return out, rows.Err() +} + +// UpdateUserPassword replaces the stored hash. +func (s *Store) UpdateUserPassword(username, hash string) error { + res, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE username = ?`, hash, username) + if err != nil { + return err + } + return requireOneRow(res) +} + +// SetUserDisabled toggles an account. +func (s *Store) SetUserDisabled(username string, disabled bool) error { + res, err := s.db.Exec(`UPDATE users SET disabled = ? WHERE username = ?`, intFromBool(disabled), username) + if err != nil { + return err + } + return requireOneRow(res) +} + +// DeleteUser removes an account. +func (s *Store) DeleteUser(username string) error { + res, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, username) + if err != nil { + return err + } + return requireOneRow(res) +} + +// CountOperators returns how many enabled operator accounts exist — +// used to refuse deleting/disabling the last one. +func (s *Store) CountOperators() (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'operator' AND disabled = 0`).Scan(&n) + return n, err +} + +func requireOneRow(res sql.Result) error { + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return errors.New("no such user") + } + return nil +} + +func isUniqueViolation(err error) bool { + // modernc.org/sqlite surfaces SQLITE_CONSTRAINT_* in the message; + // matching the message keeps us off driver-internal types. + return err != nil && (strings.Contains(err.Error(), "UNIQUE constraint") || + strings.Contains(err.Error(), "constraint failed")) +} + +// intFromBool avoids clashing with sibling helpers in this package +// across branches (demand.go declares boolToInt). +func intFromBool(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/go/internal/state/users_test.go b/go/internal/state/users_test.go new file mode 100644 index 00000000..cf0e6567 --- /dev/null +++ b/go/internal/state/users_test.go @@ -0,0 +1,67 @@ +package state + +import "testing" + +func TestUserRoundTrip(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if err := st.CreateUser(User{Username: "sanjin", Role: "operator", PasswordHash: "$argon2id$x"}); err != nil { + t.Fatal(err) + } + if err := st.CreateUser(User{Username: "sanjin", Role: "viewer", PasswordHash: "y"}); err != ErrUserExists { + t.Fatalf("duplicate: %v", err) + } + if err := st.CreateUser(User{Username: "guest", Role: "viewer", PasswordHash: "z"}); err != nil { + t.Fatal(err) + } + + u, ok, err := st.UserByName("sanjin") + if err != nil || !ok || u.Role != "operator" || u.PasswordHash != "$argon2id$x" { + t.Fatalf("fetch: %v %v %+v", ok, err, u) + } + if _, ok, _ := st.UserByName("nobody"); ok { + t.Fatal("phantom user") + } + + users, err := st.ListUsers() + if err != nil || len(users) != 2 { + t.Fatalf("list: %v %d", err, len(users)) + } + + if n, _ := st.CountOperators(); n != 1 { + t.Fatalf("operators: %d", n) + } + if err := st.SetUserDisabled("sanjin", true); err != nil { + t.Fatal(err) + } + if n, _ := st.CountOperators(); n != 0 { + t.Fatalf("disabled operator still counted: %d", n) + } + + if err := st.UpdateUserPassword("guest", "new-hash"); err != nil { + t.Fatal(err) + } + u, _, _ = st.UserByName("guest") + if u.PasswordHash != "new-hash" { + t.Fatalf("password not updated: %s", u.PasswordHash) + } + + if err := st.DeleteUser("guest"); err != nil { + t.Fatal(err) + } + if err := st.DeleteUser("guest"); err == nil { + t.Fatal("double delete should error") + } + + // A user row's JSON must never leak the hash. + // (state.User marshals with json:"-" on PasswordHash.) +} + +func TestUserRoleConstraint(t *testing.T) { + st := openTestStore(t) + defer st.Close() + if err := st.CreateUser(User{Username: "x", Role: "admin", PasswordHash: "h"}); err == nil { + t.Fatal("unknown role should violate the CHECK constraint") + } +}