// 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() }