// 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" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" "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"` // the LOGIN: 1-32 of [a-z0-9._-] (nameRE); the key everywhere Display string `json:"display,omitempty"` // the name as the household typed it (W12); "" for a member made before 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 } // MaxDisplayRunes is the longest name the household may type for a family member (W12). const MaxDisplayRunes = 64 // Person is one member as the dashboard's card shows them: the name the household typed and the login made from it. type Person struct { Login string `json:"login"` Display string `json:"display"` } // AddPerson makes a member from the name the household typed (W12, 2026-10-21): the display name is kept exactly as // typed; the LOGIN is made from it by LoginFrom (accents removed, lower case) and must still pass nameRE — the login's // own check is unchanged. When the login is taken by someone with another name, the next free numbered login is used // („Ödön" → odon, then „Odon" → odon2). The same name again (letter case aside) is ErrExists, never a second login. // Returns the login and the generated password (once). Pinned by TestW12_AccentedNamesGetAPlainLogin. func (s *Store) AddPerson(display string) (login, pw string, err error) { display = strings.TrimSpace(display) base := LoginFrom(display) if display == "" || utf8.RuneCountInString(display) > MaxDisplayRunes || !nameRE.MatchString(base) { return "", "", ErrBadName } for _, r := range display { if unicode.IsControl(r) { return "", "", ErrBadName } } pw = newPassword() h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost) if err != nil { return "", "", err } s.mu.Lock() defer s.mu.Unlock() for _, m := range s.members { if strings.EqualFold(m.displayName(), display) { return "", "", ErrExists } } login = base for n := 2; s.members[login] != nil; n++ { suffix := strconv.Itoa(n) stem := base if len(stem)+len(suffix) > 32 { stem = stem[:32-len(suffix)] } login = stem + suffix } if !nameRE.MatchString(login) { return "", "", ErrBadName } stored := display if stored == login { stored = "" // nothing to remember: the login IS the name } s.members[login] = &Member{Name: login, Display: stored, Hash: string(h), Gen: 1, Created: s.now().UTC().Format(time.RFC3339)} if err := s.saveLocked(); err != nil { delete(s.members, login) return "", "", err } return login, pw, nil } func (m *Member) displayName() string { if m.Display != "" { return m.Display } return m.Name } // foldMap removes the accents of the letters a Hungarian (and a European) household types; sharp s becomes "ss". // The letters are written as escapes: several of them are the mojibake gate's cp1250 signatures. var foldMap = func() map[rune]string { out := map[rune]string{'\u00df': "ss", '\u00e6': "ae", '\u0153': "oe", '\u00f8': "o", '\u0142': "l", '\u0111': "d", '\u00f0': "d", '\u00fe': "th"} for base, letters := range map[string]string{ "a": "\u00e1\u00e0\u00e2\u00e4\u00e3\u00e5\u0101\u0103\u0105", "c": "\u00e7\u0107\u010d\u0109\u010b", "d": "\u010f", "e": "\u00e9\u00e8\u00ea\u00eb\u0113\u0117\u0119\u011b\u0115", "g": "\u011f\u011d\u0121\u0123", "h": "\u0125\u0127", "i": "\u00ed\u00ec\u00ee\u00ef\u012b\u012f\u0129\u0131", "j": "\u0135", "k": "\u0137", "l": "\u013a\u013c\u013e\u0140", "n": "\u00f1\u0144\u0148\u0146", "o": "\u00f3\u00f2\u00f4\u00f6\u00f5\u0151\u014d\u014f", "r": "\u0155\u0159\u0157", "s": "\u015b\u0161\u015f\u015d\u0219", "t": "\u0165\u0163\u021b", "u": "\u00fa\u00f9\u00fb\u00fc\u0171\u016b\u016f\u0173\u0169\u016d", "y": "\u00fd\u00ff\u0177", "z": "\u017a\u017c\u017e", } { for _, r := range letters { out[r] = base } } return out }() // LoginFrom makes a login from a typed name (W12): lower case, accents removed („Ödön" → odon, „Írisz" → irisz), // a run of spaces becomes one dot („Kis Anna" → kis.anna), and every other sign outside a-z 0-9 . _ - is left out. // Leading signs are dropped, and the result is cut to 32 characters. The caller still checks it with nameRE. func LoginFrom(display string) string { var b strings.Builder space := false for _, r := range strings.ToLower(strings.TrimSpace(display)) { if unicode.IsSpace(r) { space = true continue } var part string switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': part = string(r) default: part = foldMap[r] } if part == "" { continue } if space && b.Len() > 0 { b.WriteByte('.') } space = false b.WriteString(part) } out := strings.TrimLeft(b.String(), "._-") if len(out) > 32 { out = out[:32] } return out } // People lists the members with their typed names, sorted by login. func (s *Store) People() []Person { if s == nil { return nil } s.mu.Lock() defer s.mu.Unlock() out := make([]Person, 0, len(s.members)) for _, m := range s.members { out = append(out, Person{Login: m.Name, Display: m.displayName()}) } sort.Slice(out, func(i, j int) bool { return out[i].Login < out[j].Login }) return out } // 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() }