package web import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "sort" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" ) // R-35 (D4, operator ruling 2026-10-08, `09` §3 decision 188): the dashboard's sign-ins survive the controller's own // restarts — a settings push, an update, a crash. Before this, s.sessions lived only in memory and every restart // signed the household out mid-flow. // // The disk holds a FINGERPRINT, never the cookie: the map is keyed by sha256(cookie), and only that key, the expiry // and the CSRF token are written. The data dir rides in the whole-guest archive (plaintext by design, `07` §5), so a // copy of the archive must not hold anything a browser could present. sha256 of 32 random bytes cannot be turned back // into the cookie, and presenting the fingerprint itself is hashed again and misses (TestR35_FileHoldsNoToken). // // Expiry is unchanged (sessionMaxAge from creation; an expired row is dropped at load and at every save). Logout and // invalidateAllSessions (password change, claim reset) write the file at once, so an ended session stays ended // across a restart (TestR35_LogoutEndsSessionAcrossRestart, TestR35_InvalidateAllEndsSessionAcrossRestart). // // Two guards keep a REVOKED session from coming back if a write fails (security review 2026-10-08): // - the file carries a fingerprint of the password hash in force when it was written (CredentialFP); at load, rows // written under another password are dropped — a password change revokes on disk even if its own save failed // (TestR35_PasswordChangeRevokesEvenIfSaveFailed); // - a revoking save (logout, invalidateAllSessions) that fails removes the file instead, so the restart starts with // no sessions rather than the stale ones (TestR35_FailedLogoutSaveRemovesFile). // // A missing file is normal; an unreadable or corrupt one is logged and the server starts with no sessions — never // fatal, and the failure direction is "sign in again", never "signed in" (TestR35_CorruptFileStartsEmpty). const sessionsFileName = "dashboard-sessions.json" type sessionsFile struct { Version int `json:"version"` CredentialFP string `json:"credential_fp"` Sessions []sessionDisk `json:"sessions"` } type sessionDisk struct { Fingerprint string `json:"fingerprint"` ExpiresAt time.Time `json:"expires_at"` CSRFToken string `json:"csrf_token"` } // sessionFingerprint is the map key and the on-disk id of a session: hex(sha256(cookie value)). func sessionFingerprint(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // credentialFingerprint is hex(sha256(the password hash in force)): it changes whenever the password does, from either // source (settings.json or controller.yaml), and reveals nothing the bcrypt hash itself does not. func (s *Server) credentialFingerprint() string { sum := sha256.Sum256([]byte("felhom-dashboard-sessions\x00" + s.effectivePasswordHash())) return hex.EncodeToString(sum[:]) } func (s *Server) sessionsPath() string { if s.cfg == nil || s.cfg.Paths.DataDir == "" { return "" } return filepath.Join(s.cfg.Paths.DataDir, sessionsFileName) } // loadSessions reads the persisted sessions into s.sessions. Called once from NewServer. func (s *Server) loadSessions() { path := s.sessionsPath() if path == "" { return } b, err := os.ReadFile(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logx.Warnf(s.logger, "[web] sessions: cannot read %s, starting with none: %v", path, err) } else { logx.Debugf(s.logger, "[web] sessions: no %s yet, starting with none", sessionsFileName) } return } var f sessionsFile if err := json.Unmarshal(b, &f); err != nil { logx.Warnf(s.logger, "[web] sessions: %s is not valid JSON, starting with none: %v", path, err) return } if f.CredentialFP != s.credentialFingerprint() { logx.Infof(s.logger, "[web] sessions: %d session(s) on disk were written under another password — dropped, sign in again", len(f.Sessions)) return } now := time.Now() loaded, expired, bad := 0, 0, 0 s.sessionsMu.Lock() for _, d := range f.Sessions { if len(d.Fingerprint) != sha256.Size*2 || d.CSRFToken == "" { bad++ continue } if !now.Before(d.ExpiresAt) { expired++ continue } s.sessions[d.Fingerprint] = &session{expiresAt: d.ExpiresAt, csrfToken: d.CSRFToken} loaded++ } s.sessionsMu.Unlock() logx.Infof(s.logger, "[web] sessions: restored %d dashboard session(s) from disk (dropped %d expired, %d malformed)", loaded, expired, bad) } // saveSessionsLocked writes the live sessions (expired ones dropped) atomically, 0600, fsynced. The caller holds // sessionsMu for writing. A failure is logged and returned; the in-memory state stays authoritative for this process. func (s *Server) saveSessionsLocked() error { path := s.sessionsPath() if path == "" { return nil } now := time.Now() f := sessionsFile{Version: 1, CredentialFP: s.credentialFingerprint(), Sessions: []sessionDisk{}} for fp, sess := range s.sessions { if !now.Before(sess.expiresAt) { continue } f.Sessions = append(f.Sessions, sessionDisk{Fingerprint: fp, ExpiresAt: sess.expiresAt, CSRFToken: sess.csrfToken}) } sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].Fingerprint < f.Sessions[j].Fingerprint }) b, err := json.MarshalIndent(f, "", " ") if err != nil { return err } if err := writeSessionsAtomic(path, b); err != nil { logx.Warnf(s.logger, "[web] sessions: cannot save %s (sessions stay valid until this process ends): %v", path, err) return err } logx.Debugf(s.logger, "[web] sessions: saved %d session(s) to %s", len(f.Sessions), sessionsFileName) return nil } // saveSessionsRevokingLocked is the save for a path that ENDS sessions (logout, invalidateAllSessions). If the write // fails, the file is removed so a restart cannot bring an ended session back; a household then signs in again, which // is the safe direction. Caller holds sessionsMu for writing. func (s *Server) saveSessionsRevokingLocked() { if err := s.saveSessionsLocked(); err == nil { return } path := s.sessionsPath() if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { logx.Errorf(s.logger, "[web] sessions: could not save NOR remove %s after ending a session — an ended session may return after a restart until the password changes: %v", path, err) return } logx.Warnf(s.logger, "[web] sessions: save failed while ending a session — removed %s instead (every session ends at the next restart)", sessionsFileName) } // writeSessionsAtomic is tmp + fsync + rename at 0600 — the family.json shape (internal/family saveLocked). func writeSessionsAtomic(path string, b []byte) error { tmp := 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() os.Remove(tmp) return err } if err := fh.Sync(); err != nil { fh.Close() os.Remove(tmp) return err } if err := fh.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) }