v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112 family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers. R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
7d41c2973c
commit
94f44931dd
28 files changed
+945
-190
No files matched your search
@@ -10,12 +10,15 @@
|
||||
// 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.
|
||||
// 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"
|
||||
@@ -42,9 +45,11 @@ type Member struct {
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// Session is one sign-in. Member "" = the household (a dashboard session vouched for the browser).
|
||||
// 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 {
|
||||
ID string `json:"id"`
|
||||
FP string `json:"fp"`
|
||||
Member string `json:"member"`
|
||||
Gen int `json:"gen"`
|
||||
Exp time.Time `json:"exp"`
|
||||
@@ -55,6 +60,22 @@ type file struct {
|
||||
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
|
||||
@@ -79,6 +100,10 @@ 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{}}
|
||||
@@ -89,7 +114,7 @@ func Open(dir string) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var f file
|
||||
var f oldFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return nil, fmt.Errorf("family.json unreadable: %w", err)
|
||||
}
|
||||
@@ -97,8 +122,25 @@ func Open(dir string) (*Store, error) {
|
||||
m := f.Members[i]
|
||||
s.members[m.Name] = &m
|
||||
}
|
||||
for _, se := range f.Sessions {
|
||||
s.sessions[se.ID] = se
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -114,14 +156,14 @@ func (s *Store) saveLocked() error {
|
||||
}
|
||||
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 {
|
||||
for fp, se := range s.sessions {
|
||||
if now.After(se.Exp) {
|
||||
delete(s.sessions, id)
|
||||
delete(s.sessions, fp)
|
||||
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 })
|
||||
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
|
||||
@@ -222,9 +264,9 @@ func (s *Store) Remove(name string) error {
|
||||
}
|
||||
|
||||
func (s *Store) dropSessionsLocked(name string) {
|
||||
for id, se := range s.sessions {
|
||||
for fp, se := range s.sessions {
|
||||
if se.Member == name {
|
||||
delete(s.sessions, id)
|
||||
delete(s.sessions, fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +306,8 @@ func (s *Store) Verify(name, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// NewSession starts a session for member ("" = the household). It refuses a member who does not exist.
|
||||
// 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
|
||||
@@ -273,7 +316,8 @@ func (s *Store) NewSession(member string) (string, error) {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := hex.EncodeToString(b)
|
||||
value := hex.EncodeToString(b)
|
||||
fp := Fingerprint(value)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gen := 0
|
||||
@@ -284,23 +328,62 @@ func (s *Store) NewSession(member string) (string, error) {
|
||||
}
|
||||
gen = m.Gen
|
||||
}
|
||||
s.sessions[id] = Session{ID: id, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)}
|
||||
s.sessions[fp] = Session{FP: fp, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
delete(s.sessions, id)
|
||||
delete(s.sessions, fp)
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
return value, 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 == "" {
|
||||
// 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()
|
||||
se, ok := s.sessions[id]
|
||||
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
|
||||
}
|
||||
@@ -313,26 +396,21 @@ func (s *Store) Valid(id string) (Session, bool) {
|
||||
return se, true
|
||||
}
|
||||
|
||||
// Find returns the first session that match accepts and that still holds (the same test as Valid). It lets a caller
|
||||
// look a session up by something DERIVED from its id — the per-app token the family gate puts in an app's cookie
|
||||
// (R-976) — without that id ever leaving the dashboard host.
|
||||
func (s *Store) Find(match func(id string) bool) (Session, bool) {
|
||||
// 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()
|
||||
var found string
|
||||
for id := range s.sessions {
|
||||
if match(id) {
|
||||
found = id
|
||||
break
|
||||
defer s.mu.Unlock()
|
||||
for fp := range s.sessions {
|
||||
if match(fp) {
|
||||
return s.validLocked(fp)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if found == "" {
|
||||
return Session{}, false
|
||||
}
|
||||
return s.Valid(found)
|
||||
return Session{}, false
|
||||
}
|
||||
|
||||
// EndAll deletes every session — every member's and the household's — and keeps the members (R-976: a dashboard
|
||||
@@ -357,16 +435,17 @@ func (s *Store) EndAll() (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// EndSession deletes a session (logout). Unknown ids are not an error.
|
||||
func (s *Store) EndSession(id string) error {
|
||||
// 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[id]; !ok {
|
||||
if _, ok := s.sessions[fp]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.sessions, id)
|
||||
delete(s.sessions, fp)
|
||||
return s.saveLocked()
|
||||
}
|
||||
@@ -125,3 +125,24 @@ func TestFamily_NilStoreAnswersNobody(t *testing.T) {
|
||||
t.Fatal("nil store valid")
|
||||
}
|
||||
}
|
||||
|
||||
// R-1112: a file written before fingerprints that cannot be rewritten is an error, never a store that carries on with
|
||||
// the session values left on disk.
|
||||
func TestFamily_OldFileThatCannotBeRewrittenIsAnError(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root writes into a read-only directory")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
old := `{"members":[],"sessions":[{"id":"a1b2c3","member":"","gen":0,"exp":"` + exp + `"}]}`
|
||||
os.WriteFile(filepath.Join(dir, "family.json"), []byte(old), 0o600)
|
||||
os.Chmod(dir, 0o500)
|
||||
defer os.Chmod(dir, 0o700)
|
||||
if _, err := Open(dir); err == nil {
|
||||
t.Fatal("an old file that could not be rewritten opened as a store")
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "family.json"))
|
||||
if string(b) != old {
|
||||
t.Fatal("the failed rewrite changed the file")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user