Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/localauth.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions go/internal/localauth/localauth.go
Original file line number Diff line number Diff line change
@@ -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()
}
90 changes: 90 additions & 0 deletions go/internal/localauth/localauth_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
12 changes: 12 additions & 0 deletions go/internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading