6ed223886d
gates / gates (push) Successful in 46s
Found on demo-hp with 0.325.0-rc.1: the store (and so the conversion of an old family.json) opened only on the first family request; a box nobody visits through a family gate kept the session values. OpenFamilyStore from main, one INFO line with the count. TestR1112_StartConvertsTheFamilyFile, TestR1112_MainOpensTheFamilyStore, red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
463 lines
14 KiB
Go
463 lines
14 KiB
Go
// 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 carries a
|
|
// token derived from the session for that app (never the session id itself, R-976), so the gate asks this store on
|
|
// every request (Find): 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, and a
|
|
// session is stored as its FINGERPRINT (R-1112, the R-35 shape of the dashboard's sessions): the cookie value itself is
|
|
// never written, and presenting a fingerprint as a cookie is hashed again and misses. A file written before R-1112
|
|
// holds the values themselves; Open converts it and rewrites it at once. Pinned by internal/family/family_test.go.
|
|
package family
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"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). FP is the session's
|
|
// fingerprint (Fingerprint of the cookie value) — the store's key and what is written to disk; the cookie value is
|
|
// known only to the browser that holds it.
|
|
type Session struct {
|
|
FP string `json:"fp"`
|
|
Member string `json:"member"`
|
|
Gen int `json:"gen"`
|
|
Exp time.Time `json:"exp"`
|
|
}
|
|
|
|
type file struct {
|
|
Members []Member `json:"members"`
|
|
Sessions []Session `json:"sessions"`
|
|
}
|
|
|
|
// oldFile reads both shapes: a session written before R-1112 carries the cookie value itself as "id".
|
|
type oldFile struct {
|
|
Members []Member `json:"members"`
|
|
Sessions []struct {
|
|
Session
|
|
ID string `json:"id"`
|
|
} `json:"sessions"`
|
|
}
|
|
|
|
// Fingerprint is a session's key in the store and on disk: hex(sha256(cookie value)) — sessionFingerprint's shape in
|
|
// internal/web (R-35). 24 random bytes cannot be found from it, and a fingerprint presented as a cookie misses.
|
|
func Fingerprint(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// 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
|
|
|
|
converted int // sessions Open found stored the old way (R-1112) — ConvertedAtOpen, for the start-up log line
|
|
}
|
|
|
|
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.
|
|
//
|
|
// R-1112: a session still stored as its cookie value is converted to its fingerprint and the file is rewritten at once
|
|
// (tmp+fsync+rename), so its members stay signed in and the value leaves the disk. A rewrite that fails is an error
|
|
// too (the gate stays shut for members, the household still passes): carrying on would leave the values on disk.
|
|
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 oldFile
|
|
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
|
|
}
|
|
converted := 0
|
|
for _, e := range f.Sessions {
|
|
se := e.Session
|
|
if se.FP == "" && e.ID != "" {
|
|
se.FP = Fingerprint(e.ID)
|
|
converted++
|
|
}
|
|
if se.FP == "" {
|
|
continue
|
|
}
|
|
s.sessions[se.FP] = se
|
|
}
|
|
s.converted = converted
|
|
if converted > 0 {
|
|
s.mu.Lock()
|
|
err := s.saveLocked()
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("family.json: %d session(s) stored the old way could not be rewritten as fingerprints: %w", converted, err)
|
|
}
|
|
}
|
|
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 fp, se := range s.sessions {
|
|
if now.After(se.Exp) {
|
|
delete(s.sessions, fp)
|
|
continue
|
|
}
|
|
f.Sessions = append(f.Sessions, se)
|
|
}
|
|
sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].FP < f.Sessions[j].FP })
|
|
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 fp, se := range s.sessions {
|
|
if se.Member == name {
|
|
delete(s.sessions, fp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ConvertedAtOpen is how many sessions Open found stored the old way and rewrote as fingerprints (0 for a nil store).
|
|
func (s *Store) ConvertedAtOpen() int {
|
|
if s == nil {
|
|
return 0
|
|
}
|
|
return s.converted
|
|
}
|
|
|
|
// NewSession starts a session for member ("" = the household) and returns its cookie value — the only time the value
|
|
// exists outside the browser; the store keeps its Fingerprint. 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
|
|
}
|
|
value := hex.EncodeToString(b)
|
|
fp := Fingerprint(value)
|
|
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[fp] = Session{FP: fp, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)}
|
|
if err := s.saveLocked(); err != nil {
|
|
delete(s.sessions, fp)
|
|
return "", err
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// HouseholdSession returns the household's live session, starting one only when there is none with at least half of
|
|
// SessionLife left (R-1113). Before, every household pass through /__family/start minted a new 30-day session and
|
|
// family.json grew by one each time. The household's session never reaches a browser (only app tokens derived from
|
|
// its fingerprint do), so one serves every pass; the half-life rule keeps an app cookie minted from it backed for at
|
|
// least 15 days. Check and start happen under one lock, so parallel passes start at most one.
|
|
func (s *Store) HouseholdSession() (Session, error) {
|
|
if s == nil {
|
|
return Session{}, ErrNoMember
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now := s.now()
|
|
var best Session
|
|
for _, se := range s.sessions {
|
|
if se.Member == "" && se.Exp.After(best.Exp) {
|
|
best = se
|
|
}
|
|
}
|
|
if best.FP != "" && best.Exp.Sub(now) >= SessionLife/2 {
|
|
return best, nil
|
|
}
|
|
b := make([]byte, 24)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return Session{}, err
|
|
}
|
|
fp := Fingerprint(hex.EncodeToString(b))
|
|
se := Session{FP: fp, Exp: now.Add(SessionLife)}
|
|
s.sessions[fp] = se
|
|
if err := s.saveLocked(); err != nil {
|
|
delete(s.sessions, fp)
|
|
return Session{}, err
|
|
}
|
|
return se, nil
|
|
}
|
|
|
|
// Valid reports whether the session a browser presents (its cookie value) 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(value string) (Session, bool) {
|
|
if s == nil || value == "" {
|
|
return Session{}, false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.validLocked(Fingerprint(value))
|
|
}
|
|
|
|
func (s *Store) validLocked(fp string) (Session, bool) {
|
|
se, ok := s.sessions[fp]
|
|
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
|
|
}
|
|
|
|
// Find returns the first session whose fingerprint match accepts and that still holds (the same test as Valid). It
|
|
// lets a caller look a session up by something DERIVED from it — the per-app token the family gate puts in an app's
|
|
// cookie (R-976) — without the session ever leaving the dashboard host.
|
|
func (s *Store) Find(match func(fp string) bool) (Session, bool) {
|
|
if s == nil || match == nil {
|
|
return Session{}, false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for fp := range s.sessions {
|
|
if match(fp) {
|
|
return s.validLocked(fp)
|
|
}
|
|
}
|
|
return Session{}, false
|
|
}
|
|
|
|
// EndAll deletes every session — every member's and the household's — and keeps the members (R-976: a dashboard
|
|
// password change or reset ends every way into the family apps; members sign in again with their own passwords).
|
|
// It returns how many sessions it ended.
|
|
func (s *Store) EndAll() (int, error) {
|
|
if s == nil {
|
|
return 0, nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
n := len(s.sessions)
|
|
if n == 0 {
|
|
return 0, nil
|
|
}
|
|
// Ended in memory even when the save fails: an ending is never undone because a disk write failed (the caller
|
|
// logs the error; the sessions stay ended until a restart would read the old file back).
|
|
s.sessions = map[string]Session{}
|
|
if err := s.saveLocked(); err != nil {
|
|
return n, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// EndSession deletes the session a browser presents (logout, by its cookie value). Unknown values are not an error.
|
|
func (s *Store) EndSession(value string) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
fp := Fingerprint(value)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.sessions[fp]; !ok {
|
|
return nil
|
|
}
|
|
delete(s.sessions, fp)
|
|
return s.saveLocked()
|
|
}
|