The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s

- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-02 07:42:47 +02:00
parent a4a753b9e2
commit 977665d8c0
25 changed files with 2168 additions and 10 deletions
+327
View File
@@ -0,0 +1,327 @@
// Package family is the household's family list for the permanent family gate (`09` §3 decisions 63, 64; R-780).
//
// Each family member has their OWN name and password (never the dashboard's); the household's dashboard admin adds,
// resets and removes them. A member who signs in gets a SESSION (30 days). Every app cookie the gate mints names the
// session, so the gate asks this store on every request: a removed member, a reset password (the member's generation
// moves on) or a logout (the session is deleted) ends every app's access at the next request.
//
// A session with Member "" is the HOUSEHOLD's: minted when the dashboard's own session vouched for the browser (the
// setup gate's rule, decision 46). It is never a dashboard session and never opens the dashboard.
//
// Persisted as family.json (0600, tmp+fsync+rename) in the controller's data dir, so the controller's own backup and
// restore carry it and a restart keeps every session. Hashes only — a password is shown once, when it is made.
// Pinned by internal/family/family_test.go.
package family
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// SessionLife is how long a family sign-in lasts.
const SessionLife = 30 * 24 * time.Hour
// Member is one family member. Gen moves on at every password reset, ending the member's earlier sessions.
type Member struct {
Name string `json:"name"`
Hash string `json:"hash"`
Gen int `json:"gen"`
Created string `json:"created"`
}
// Session is one sign-in. Member "" = the household (a dashboard session vouched for the browser).
type Session struct {
ID string `json:"id"`
Member string `json:"member"`
Gen int `json:"gen"`
Exp time.Time `json:"exp"`
}
type file struct {
Members []Member `json:"members"`
Sessions []Session `json:"sessions"`
}
// Store is the family list and its sessions. A nil *Store answers "nobody" to every question.
type Store struct {
path string
now func() time.Time
cost int
dummyOnce sync.Once
dummy []byte
mu sync.Mutex
members map[string]*Member
sessions map[string]Session
}
var (
ErrBadName = errors.New("a name is 1-32 characters: lower-case letters, digits, dot, dash, underscore; starting with a letter or digit")
ErrExists = errors.New("a member with that name already exists")
ErrNoMember = errors.New("no member with that name")
)
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
// Open loads (or starts) the store at dir/family.json. A file that cannot be parsed is an error, never an empty list:
// an empty list would silently lock every member out, and a rewrite would destroy the household's list.
func Open(dir string) (*Store, error) {
s := &Store{path: filepath.Join(dir, "family.json"), now: time.Now, cost: bcrypt.DefaultCost,
members: map[string]*Member{}, sessions: map[string]Session{}}
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
var f file
if err := json.Unmarshal(b, &f); err != nil {
return nil, fmt.Errorf("family.json unreadable: %w", err)
}
for i := range f.Members {
m := f.Members[i]
s.members[m.Name] = &m
}
for _, se := range f.Sessions {
s.sessions[se.ID] = se
}
return s, nil
}
// SetClock and SetCost are test seams.
func (s *Store) SetClock(f func() time.Time) { s.now = f }
func (s *Store) SetCost(c int) { s.cost = c }
func (s *Store) saveLocked() error {
f := file{Members: []Member{}, Sessions: []Session{}}
for _, m := range s.members {
f.Members = append(f.Members, *m)
}
sort.Slice(f.Members, func(i, j int) bool { return f.Members[i].Name < f.Members[j].Name })
now := s.now()
for id, se := range s.sessions {
if now.After(se.Exp) {
delete(s.sessions, id)
continue
}
f.Sessions = append(f.Sessions, se)
}
sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].ID < f.Sessions[j].ID })
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
fh, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if _, err := fh.Write(b); err != nil {
fh.Close()
return err
}
if err := fh.Sync(); err != nil {
fh.Close()
return err
}
if err := fh.Close(); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// pwAlphabet leaves out look-alikes (0/o, 1/l/i).
const pwAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
// newPassword is 4 groups of 4 from pwAlphabet (~79 bits), easy to read aloud and type on a phone.
func newPassword() string {
out := make([]byte, 0, 19)
for g := 0; g < 4; g++ {
if g > 0 {
out = append(out, '-')
}
for i := 0; i < 4; i++ {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(pwAlphabet))))
out = append(out, pwAlphabet[n.Int64()])
}
}
return string(out)
}
// Add makes a member with a generated password, returned once.
func (s *Store) Add(name string) (string, error) {
if !nameRE.MatchString(name) {
return "", ErrBadName
}
pw := newPassword()
h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.members[name]; ok {
return "", ErrExists
}
s.members[name] = &Member{Name: name, Hash: string(h), Gen: 1, Created: s.now().UTC().Format(time.RFC3339)}
if err := s.saveLocked(); err != nil {
delete(s.members, name)
return "", err
}
return pw, nil
}
// Reset gives a member a new generated password (returned once) and ends every earlier session of theirs.
func (s *Store) Reset(name string) (string, error) {
pw := newPassword()
h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
m, ok := s.members[name]
if !ok {
return "", ErrNoMember
}
old := *m
m.Hash, m.Gen = string(h), m.Gen+1
s.dropSessionsLocked(name)
if err := s.saveLocked(); err != nil {
*m = old
return "", err
}
return pw, nil
}
// Remove deletes a member and every session of theirs.
func (s *Store) Remove(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.members[name]; !ok {
return ErrNoMember
}
delete(s.members, name)
s.dropSessionsLocked(name)
return s.saveLocked()
}
func (s *Store) dropSessionsLocked(name string) {
for id, se := range s.sessions {
if se.Member == name {
delete(s.sessions, id)
}
}
}
// Names lists the members, sorted.
func (s *Store) Names() []string {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.members))
for n := range s.members {
out = append(out, n)
}
sort.Strings(out)
return out
}
// Verify checks a member's password. An unknown name costs a bcrypt compare too, so timing does not tell names apart.
func (s *Store) Verify(name, password string) bool {
if s == nil {
return false
}
s.mu.Lock()
m, ok := s.members[name]
hash := ""
if ok {
hash = m.Hash
}
s.mu.Unlock()
if !ok {
s.dummyOnce.Do(func() { s.dummy, _ = bcrypt.GenerateFromPassword([]byte(newPassword()), s.cost) })
_ = bcrypt.CompareHashAndPassword(s.dummy, []byte(password))
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// NewSession starts a session for member ("" = the household). It refuses a member who does not exist.
func (s *Store) NewSession(member string) (string, error) {
if s == nil {
return "", ErrNoMember
}
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
s.mu.Lock()
defer s.mu.Unlock()
gen := 0
if member != "" {
m, ok := s.members[member]
if !ok {
return "", ErrNoMember
}
gen = m.Gen
}
s.sessions[id] = Session{ID: id, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)}
if err := s.saveLocked(); err != nil {
delete(s.sessions, id)
return "", err
}
return id, nil
}
// Valid reports whether a session still holds: it exists, has not expired, and — for a member — the member still
// exists at the generation the session was made in.
func (s *Store) Valid(id string) (Session, bool) {
if s == nil || id == "" {
return Session{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
se, ok := s.sessions[id]
if !ok || s.now().After(se.Exp) {
return Session{}, false
}
if se.Member != "" {
m, ok := s.members[se.Member]
if !ok || m.Gen != se.Gen {
return Session{}, false
}
}
return se, true
}
// EndSession deletes a session (logout). Unknown ids are not an error.
func (s *Store) EndSession(id string) error {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.sessions[id]; !ok {
return nil
}
delete(s.sessions, id)
return s.saveLocked()
}
+127
View File
@@ -0,0 +1,127 @@
package family
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
func testStore(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
s.SetCost(bcrypt.MinCost)
return s, dir
}
// A member signs in with their own password; the password is never stored; the list survives a reopen.
func TestFamily_AddVerifyPersist(t *testing.T) {
s, dir := testStore(t)
pw, err := s.Add("anna")
if err != nil || len(pw) != 19 {
t.Fatalf("add: %v %q", err, pw)
}
if !s.Verify("anna", pw) || s.Verify("anna", pw+"x") || s.Verify("bela", pw) {
t.Fatal("verify wrong")
}
b, _ := os.ReadFile(filepath.Join(dir, "family.json"))
if strings.Contains(string(b), pw) {
t.Fatal("the plain password reached family.json")
}
if st, _ := os.Stat(filepath.Join(dir, "family.json")); st.Mode().Perm() != 0o600 {
t.Fatalf("family.json mode %v", st.Mode().Perm())
}
s2, err := Open(dir)
if err != nil || !s2.Verify("anna", pw) {
t.Fatalf("the list did not survive a reopen: %v", err)
}
if _, err := s.Add("anna"); err != ErrExists {
t.Fatalf("duplicate: %v", err)
}
for _, bad := range []string{"", "Anna", "a b", "../x", strings.Repeat("a", 33), "-x"} {
if _, err := s.Add(bad); err != ErrBadName {
t.Fatalf("name %q accepted", bad)
}
}
}
// THE CONSEQUENCE: a reset or a removal ends the member's sessions at once; a logout ends one session; an expired
// session is refused; another member's session is untouched.
func TestFamily_SessionsEndOnResetRemoveLogout(t *testing.T) {
s, _ := testStore(t)
now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now })
s.Add("anna")
s.Add("bela")
a1, _ := s.NewSession("anna")
a2, _ := s.NewSession("anna")
b1, _ := s.NewSession("bela")
h1, _ := s.NewSession("")
for _, id := range []string{a1, a2, b1, h1} {
if _, ok := s.Valid(id); !ok {
t.Fatalf("fresh session %s refused", id)
}
}
if _, err := s.Reset("anna"); err != nil {
t.Fatal(err)
}
if _, ok := s.Valid(a1); ok {
t.Fatal("a reset password must end the member's earlier sessions")
}
if _, ok := s.Valid(b1); !ok {
t.Fatal("another member's session must survive")
}
s.EndSession(b1)
if _, ok := s.Valid(b1); ok {
t.Fatal("logout must end the session")
}
b2, _ := s.NewSession("bela")
s.Remove("bela")
if _, ok := s.Valid(b2); ok {
t.Fatal("a removed member's session must end")
}
if _, err := s.NewSession("bela"); err != ErrNoMember {
t.Fatal("no session for a removed member")
}
now = now.Add(SessionLife + time.Minute)
if _, ok := s.Valid(h1); ok {
t.Fatal("an expired session must be refused")
}
}
// A gen bump survives a reopen: an OLD session read back from disk after a reset is still refused.
func TestFamily_ResetHoldsAcrossReopen(t *testing.T) {
s, dir := testStore(t)
s.Add("anna")
id, _ := s.NewSession("anna")
s.Reset("anna")
s2, _ := Open(dir)
if _, ok := s2.Valid(id); ok {
t.Fatal("after a reopen the pre-reset session must still be refused")
}
}
func TestFamily_UnreadableFileIsAnError(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "family.json"), []byte("{not json"), 0o600)
if _, err := Open(dir); err == nil {
t.Fatal("an unreadable list must be an error, never an empty list")
}
}
func TestFamily_NilStoreAnswersNobody(t *testing.T) {
var s *Store
if s.Verify("a", "b") || len(s.Names()) != 0 {
t.Fatal("nil store")
}
if _, ok := s.Valid("x"); ok {
t.Fatal("nil store valid")
}
}