diff --git a/apps/server/go.mod b/apps/server/go.mod index 589dede..dd8d8ea 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + golang.org/x/crypto v0.48.0 golang.org/x/sys v0.42.0 modernc.org/sqlite v1.52.0 non24.app/core v0.0.0 @@ -15,7 +16,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/apps/server/go.sum b/apps/server/go.sum index e15296f..ccbb021 100644 --- a/apps/server/go.sum +++ b/apps/server/go.sum @@ -16,6 +16,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -23,8 +25,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/apps/server/internal/api/portal.go b/apps/server/internal/api/portal.go new file mode 100644 index 0000000..a245d03 --- /dev/null +++ b/apps/server/internal/api/portal.go @@ -0,0 +1,251 @@ +package api + +import ( + "errors" + "log" + "net/http" + "strings" + "time" + + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" + syncmodel "non24.app/server/internal/sync" +) + +// portalAdmin is the owner-authenticated half of sharing. It is registered +// only when the portal is configured, so a daemon with the portal off has no +// sharing routes at all. +type portalAdmin struct { + store *portal.Store + origin string + materializer portalbridge.Materializer +} + +// WithPortal enables the share-link administration routes and wires +// materialization. The public /p/ mux is mounted separately by the daemon; +// this option governs only the owner surface. +func WithPortal(portalStore *portal.Store, publicOrigin string, materializer portalbridge.Materializer) Option { + return func(s *Server) { + s.portal = &portalAdmin{ + store: portalStore, + origin: strings.TrimSuffix(publicOrigin, "/"), + materializer: materializer, + } + } +} + +// sharingDisclosure is required by exposure-gate item 7. It states the +// measured uncertainty and the residual risk of live sharing in the same +// breath as the link, so an owner cannot create one without seeing both. +const sharingDisclosure = "Anyone with this link and passcode can see broad windows when you are likely awake, and can watch those windows change over time. " + + "The estimate is often off by about 2 hours and sometimes more. " + + "Screenshots and anything a recipient remembers cannot be revoked." + +type createPortalProfileRequest struct { + Label string `json:"label"` + Passcode string `json:"passcode"` + ExpiresInDays int `json:"expiresInDays"` + Grants portal.Grants `json:"grants"` +} + +type createPortalProfileResponse struct { + SchemaVersion string `json:"schema_version"` + ProfileID string `json:"profileId"` + LinkURL string `json:"linkUrl"` + ExpiresAt time.Time `json:"expiresAt"` + Disclosure string `json:"disclosure"` +} + +type portalProfileDTO struct { + ProfileID string `json:"profileId"` + Label string `json:"label"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + Grants portal.Grants `json:"grants"` + Access []portalAccessCountDTO `json:"access"` +} + +type portalAccessCountDTO struct { + Event string `json:"event"` + Count int `json:"count"` + LastAccess time.Time `json:"lastAccess"` +} + +type portalProfileListResponse struct { + SchemaVersion string `json:"schema_version"` + Profiles []portalProfileDTO `json:"profiles"` + Disclosure string `json:"disclosure"` +} + +func (s *Server) handleCreatePortalProfile(w http.ResponseWriter, r *http.Request) { + var req createPortalProfileRequest + if err := decodeBody(w, r, maxDeviceBodyBytes, &req); err != nil { + writeDecodeError(w, err) + return + } + if req.ExpiresInDays <= 0 { + writeError(w, http.StatusBadRequest, "expiresInDays must be positive") + return + } + now := s.now() + expiresAt := now.Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour) + + profileID, err := portal.NewProfileID() + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + token, err := portal.NewLinkToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + if err := s.portal.store.CreateProfile(r.Context(), portal.CreateProfileInput{ + ProfileID: profileID, + Token: token, + Passcode: req.Passcode, + Grants: req.Grants, + CreatedAt: now, + ExpiresAt: expiresAt, + }); err != nil { + // Validation failures here are the caller's fault (short passcode, + // over-long lifetime); they carry no secret and are safe to relay. + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.store.PutPortalLabel(r.Context(), profileID, req.Label, now.Format(time.RFC3339Nano)); err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + // Materialize immediately so the recipient does not open a blank page. + if err := s.portal.materializer.MaterializeAll(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + + writeJSON(w, http.StatusCreated, createPortalProfileResponse{ + SchemaVersion: syncmodel.SchemaVersion, + ProfileID: profileID, + LinkURL: s.portal.origin + "/p/" + token, + ExpiresAt: expiresAt, + Disclosure: sharingDisclosure, + }) +} + +func (s *Server) handleListPortalProfiles(w http.ResponseWriter, r *http.Request) { + now := s.now() + states, err := s.portal.store.ListProfiles(r.Context(), now) + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile list failed") + return + } + labels, err := s.store.PortalLabels(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile list failed") + return + } + since := now.Add(-30 * 24 * time.Hour) + + profiles := make([]portalProfileDTO, 0, len(states)) + for _, state := range states { + summaries, err := s.portal.store.SummarizeAccess(r.Context(), state.ID, since) + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile list failed") + return + } + access := make([]portalAccessCountDTO, 0, len(summaries)) + for _, summary := range summaries { + access = append(access, portalAccessCountDTO{ + Event: string(summary.Event), + Count: summary.Count, + LastAccess: summary.LastAccess, + }) + } + profiles = append(profiles, portalProfileDTO{ + ProfileID: state.ID, + Label: labels[state.ID], + State: profileState(state), + CreatedAt: state.CreatedAt, + ExpiresAt: state.ExpiresAt, + Grants: state.Grants, + Access: access, + }) + } + writeJSON(w, http.StatusOK, portalProfileListResponse{ + SchemaVersion: syncmodel.SchemaVersion, + Profiles: profiles, + Disclosure: sharingDisclosure, + }) +} + +func profileState(state portal.ProfileState) string { + switch { + case state.Revoked: + return "revoked" + case state.Expired: + return "expired" + default: + return "active" + } +} + +func (s *Server) handleRevokePortalProfile(w http.ResponseWriter, r *http.Request) { + profileID := strings.TrimSpace(r.PathValue("id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "share profile id is required") + return + } + if err := s.portal.store.RevokeProfile(r.Context(), profileID, s.now()); err != nil { + if errors.Is(err, portal.ErrProfileNotFound) { + writeError(w, http.StatusNotFound, "share profile not found") + return + } + writeError(w, http.StatusInternalServerError, "share profile revoke failed") + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "schema_version": syncmodel.SchemaVersion, + "status": "revoked", + }) +} + +// handleErasePortalProfile removes the owner-side label and the portal-side +// access audit for a link. Revocation stops access; this erases the record +// that the link existed, matching the ADR-0014/0017 erasure right. +func (s *Server) handleErasePortalProfile(w http.ResponseWriter, r *http.Request) { + profileID := strings.TrimSpace(r.PathValue("id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "share profile id is required") + return + } + if err := s.portal.store.RevokeProfile(r.Context(), profileID, s.now()); err != nil && !errors.Is(err, portal.ErrProfileNotFound) { + writeError(w, http.StatusInternalServerError, "share profile erase failed") + return + } + if err := s.portal.store.EraseAudit(r.Context(), profileID); err != nil { + writeError(w, http.StatusInternalServerError, "share profile erase failed") + return + } + if err := s.store.DeletePortalLabel(r.Context(), profileID); err != nil { + writeError(w, http.StatusInternalServerError, "share profile erase failed") + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "schema_version": syncmodel.SchemaVersion, + "status": "erased", + }) +} + +// refreshPortal republishes availability after private data changed. Failures +// are reported to the caller's log path rather than failing the sync request: +// a stale portal snapshot is a visible, honest state, but a rejected push +// would lose the device's data. +func (s *Server) refreshPortal(r *http.Request) { + if s.portal == nil { + return + } + if err := s.portal.materializer.MaterializeAll(r.Context()); err != nil { + log.Printf("portal: snapshot refresh failed: %v", err) + } +} diff --git a/apps/server/internal/api/portal_test.go b/apps/server/internal/api/portal_test.go new file mode 100644 index 0000000..f8d09c0 --- /dev/null +++ b/apps/server/internal/api/portal_test.go @@ -0,0 +1,189 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" + "non24.app/server/internal/readmodel" +) + +var portalTestNow = time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC) + +// TestSharingRoutesAbsentWithoutPortal keeps the owner surface off too. A +// daemon with the portal disabled exposes no sharing endpoints at all. +func TestSharingRoutesAbsentWithoutPortal(t *testing.T) { + h := newTestHarness(t) + token := h.registerDevice(t, "desktop") + + routes := []struct { + method string + path string + }{ + {http.MethodGet, "/v1/portal/profiles"}, + {http.MethodPost, "/v1/portal/profiles"}, + {http.MethodPost, "/v1/portal/profiles/abc/revoke"}, + {http.MethodPost, "/v1/portal/profiles/abc/erase"}, + } + for _, route := range routes { + status, body := h.request(t, route.method, route.path, token, "{}") + if status != http.StatusNotFound { + t.Errorf("%s %s status = %d body = %s, want 404 when the portal is disabled", + route.method, route.path, status, body) + } + } +} + +func newPortalHarness(t *testing.T) (*testHarness, *portal.Store) { + t.Helper() + portalStore, err := portal.Open(filepath.Join(t.TempDir(), "portal.db"), bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatalf("open portal store: %v", err) + } + t.Cleanup(func() { _ = portalStore.Close() }) + + harness := newTestHarness(t, func(s *Server) { + WithPortal(portalStore, "https://share.example.test", portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: s.store}, + Profiles: portalStore, + Sink: portalStore, + Now: func() time.Time { return portalTestNow }, + })(s) + }) + return harness, portalStore +} + +func TestSharingLifecycle(t *testing.T) { + h, portalStore := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, data := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Mum","passcode":"long-enough-passcode","expiresInDays":30,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusCreated { + t.Fatalf("create status = %d body = %s", status, data) + } + var created createPortalProfileResponse + if err := json.Unmarshal(data, &created); err != nil { + t.Fatalf("decode create: %v", err) + } + + if !strings.HasPrefix(created.LinkURL, "https://share.example.test/p/") { + t.Errorf("link url = %q, want the configured public origin", created.LinkURL) + } + for _, phrase := range []string{"2 hours", "cannot be revoked"} { + if !strings.Contains(created.Disclosure, phrase) { + t.Errorf("disclosure does not mention %q: %q", phrase, created.Disclosure) + } + } + + linkToken := strings.TrimPrefix(created.LinkURL, "https://share.example.test/p/") + if _, err := portalStore.ResolveLink(t.Context(), linkToken, portalTestNow); err != nil { + t.Fatalf("issued link does not resolve: %v", err) + } + + status, data = h.request(t, http.MethodGet, "/v1/portal/profiles", token, "") + if status != http.StatusOK { + t.Fatalf("list status = %d body = %s", status, data) + } + var list portalProfileListResponse + if err := json.Unmarshal(data, &list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list.Profiles) != 1 { + t.Fatalf("profile count = %d, want 1", len(list.Profiles)) + } + if list.Profiles[0].Label != "Mum" { + t.Errorf("label = %q, want Mum", list.Profiles[0].Label) + } + if list.Profiles[0].State != "active" { + t.Errorf("state = %q, want active", list.Profiles[0].State) + } + + status, data = h.request(t, http.MethodPost, "/v1/portal/profiles/"+created.ProfileID+"/revoke", token, "{}") + if status != http.StatusOK { + t.Fatalf("revoke status = %d body = %s", status, data) + } + if _, err := portalStore.ResolveLink(t.Context(), linkToken, portalTestNow); err == nil { + t.Error("link still resolves after revocation") + } + + status, data = h.request(t, http.MethodPost, "/v1/portal/profiles/"+created.ProfileID+"/erase", token, "{}") + if status != http.StatusOK { + t.Fatalf("erase status = %d body = %s", status, data) + } + label, err := h.st.PortalLabel(t.Context(), created.ProfileID) + if err != nil { + t.Fatalf("read label: %v", err) + } + if label != "" { + t.Errorf("private label survived erasure: %q", label) + } +} + +// TestSharingLabelStaysOutOfPortalStore is the split-store invariant at the +// owner boundary: the recipient's name is the owner's data, not the portal's. +func TestSharingLabelStaysOutOfPortalStore(t *testing.T) { + h, portalStore := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, data := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Dr Alvarez neurology","passcode":"long-enough-passcode","expiresInDays":7,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusCreated { + t.Fatalf("create status = %d body = %s", status, data) + } + var created createPortalProfileResponse + if err := json.Unmarshal(data, &created); err != nil { + t.Fatal(err) + } + + profiles, err := portalStore.ListProfiles(t.Context(), portalTestNow) + if err != nil { + t.Fatalf("list portal profiles: %v", err) + } + encoded, err := json.Marshal(profiles) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("Alvarez")) { + t.Error("the private share label reached the portal store") + } + if created.ProfileID == "" { + t.Error("no profile id was returned") + } +} + +func TestSharingRejectsWeakPasscode(t *testing.T) { + h, _ := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, _ := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Short","passcode":"abc","expiresInDays":30,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for a short passcode", status) + } +} + +func TestSharingRejectsOverlongLifetime(t *testing.T) { + h, _ := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, _ := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Forever","passcode":"long-enough-passcode","expiresInDays":400,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for a lifetime past the 90-day cap", status) + } +} + +func TestSharingRequiresDeviceAuth(t *testing.T) { + h, _ := newPortalHarness(t) + status, _ := h.request(t, http.MethodGet, "/v1/portal/profiles", "", "") + if status != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 without a device token", status) + } +} diff --git a/apps/server/internal/api/server.go b/apps/server/internal/api/server.go index 50e793d..58730e7 100644 --- a/apps/server/internal/api/server.go +++ b/apps/server/internal/api/server.go @@ -32,6 +32,7 @@ type Server struct { store *store.Store enrollmentSecret string assistant *assistant.Service + portal *portalAdmin now func() time.Time } @@ -75,6 +76,15 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /v1/sync/push", s.requireDevice(http.HandlerFunc(s.handlePush))) mux.Handle("GET /v1/sync/pull", s.requireDevice(http.HandlerFunc(s.handlePull))) mux.Handle("POST /v1/sync/erase", s.requireDevice(http.HandlerFunc(s.handleErase))) + if s.portal != nil { + // Registered only when the portal is configured. With the portal off + // there is no sharing route to probe, on the owner surface or the + // public one. + mux.Handle("GET /v1/portal/profiles", s.requireDevice(http.HandlerFunc(s.handleListPortalProfiles))) + mux.Handle("POST /v1/portal/profiles", s.requireDevice(http.HandlerFunc(s.handleCreatePortalProfile))) + mux.Handle("POST /v1/portal/profiles/{id}/revoke", s.requireDevice(http.HandlerFunc(s.handleRevokePortalProfile))) + mux.Handle("POST /v1/portal/profiles/{id}/erase", s.requireDevice(http.HandlerFunc(s.handleErasePortalProfile))) + } return mux } @@ -435,6 +445,9 @@ func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "sync push failed") return } + // An accepted push can change sleep inputs, so the shared projection is + // republished before the response returns (design section 5). + s.refreshPortal(r) writeJSON(w, http.StatusOK, syncmodel.PushResponse{ SchemaVersion: syncmodel.SchemaVersion, Cursor: cursor, @@ -461,6 +474,9 @@ func (s *Server) handleErase(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "sync erase failed") return } + // Erasure must reach the shared projection too: a window derived from a + // record the user just deleted may not keep being published. + s.refreshPortal(r) writeJSON(w, http.StatusOK, syncmodel.EraseResponse{ SchemaVersion: syncmodel.SchemaVersion, Erased: erased, diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index 250b164..e83bcce 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -16,8 +16,10 @@ import ( "io" "math/big" "net" + "net/url" "os" "path/filepath" + "strconv" "strings" "time" ) @@ -39,6 +41,8 @@ const ( EnvLLMAPIKey = "ZEITBOARD_LLM_API_KEY" EnvLLMAPIKeyFile = "ZEITBOARD_LLM_API_KEY_FILE" EnvLLMEndpoint = "ZEITBOARD_LLM_ENDPOINT" + EnvPortalEnabled = "ZEITBOARD_PORTAL_ENABLED" + EnvPortalOrigin = "ZEITBOARD_PORTAL_ORIGIN" ) type Config struct { @@ -51,9 +55,23 @@ type Config struct { DataKey []byte `json:"-"` EnrollmentSecret string `json:"-"` Assistant AssistantConfig `json:"assistant"` + Portal PortalConfig `json:"portal"` UsesSelfSignedDevCert bool `json:"-"` } +// PortalConfig gates the public availability portal. Enabled defaults to false +// and must stay that way until the exposure gate in docs/portal-design.md +// section 12 passes: when it is false the daemon never constructs a portal +// handler, so no /p/ route exists to be probed. +type PortalConfig struct { + Enabled bool `json:"enabled"` + + // PublicOrigin is the exact scheme://host[:port] visitors reach. It is + // required when the portal is enabled because mutating requests are + // accepted only with an exactly matching Origin header. + PublicOrigin string `json:"publicOrigin"` +} + type AssistantConfig struct { Provider string `json:"provider"` Model string `json:"model"` @@ -92,6 +110,10 @@ func Load(path string) (Config, error) { overrideString(&cfg.Assistant.Model, EnvLLMModel) overrideString(&cfg.Assistant.APIKeyFile, EnvLLMAPIKeyFile) overrideString(&cfg.Assistant.Endpoint, EnvLLMEndpoint) + overrideString(&cfg.Portal.PublicOrigin, EnvPortalOrigin) + if err := overrideBool(&cfg.Portal.Enabled, EnvPortalEnabled); err != nil { + return Config{}, err + } dataKey, err := loadDataKey(cfg.DataKeyFile) if err != nil { @@ -131,9 +153,67 @@ func Load(path string) (Config, error) { } cfg.UsesSelfSignedDevCert = true } + if err := validatePortal(&cfg.Portal); err != nil { + return Config{}, err + } return cfg, nil } +// validatePortal refuses a half-configured public surface. An enabled portal +// without an exact origin could not enforce its CSRF check, so it must fail to +// start rather than serve with the check disabled. +func validatePortal(portal *PortalConfig) error { + portal.PublicOrigin = strings.TrimSuffix(strings.TrimSpace(portal.PublicOrigin), "/") + if !portal.Enabled { + return nil + } + if portal.PublicOrigin == "" { + return errors.New("portal publicOrigin is required when the portal is enabled") + } + parsed, err := url.Parse(portal.PublicOrigin) + if err != nil || parsed.Host == "" { + return errors.New("portal publicOrigin must be an absolute scheme://host origin") + } + if parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil { + return errors.New("portal publicOrigin must contain only a scheme and host") + } + switch parsed.Scheme { + case "https": + case "http": + // Plain HTTP is tolerated only for loopback development, because the + // session cookie carries the __Host- prefix and Secure attribute, + // which browsers accept over http only for trustworthy origins. + if !isLocalHostname(parsed.Hostname()) { + return errors.New("portal publicOrigin must use https outside loopback") + } + default: + return errors.New("portal publicOrigin must use http or https") + } + return nil +} + +func isLocalHostname(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func overrideBool(target *bool, envName string) error { + raw := strings.TrimSpace(os.Getenv(envName)) + if raw == "" { + return nil + } + value, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("%s must be a boolean", envName) + } + *target = value + return nil +} + func (c Config) TLSConfig() (*tls.Config, error) { tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} if c.TLSCertPath != "" { diff --git a/apps/server/internal/config/portal_test.go b/apps/server/internal/config/portal_test.go new file mode 100644 index 0000000..a1c2526 --- /dev/null +++ b/apps/server/internal/config/portal_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writePortalConfig(t *testing.T, portal map[string]any) string { + t.Helper() + dir := t.TempDir() + keyPath := filepath.Join(dir, "data-key.txt") + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + if err := os.WriteFile(keyPath, []byte(key), 0o600); err != nil { + t.Fatal(err) + } + secretPath := filepath.Join(dir, "enrollment-secret.txt") + if err := os.WriteFile(secretPath, []byte("enrollment-secret-123"), 0o600); err != nil { + t.Fatal(err) + } + payload := map[string]any{ + "listenAddress": "127.0.0.1:8765", + "dataDir": filepath.Join(dir, "data"), + "dataKeyFile": keyPath, + "enrollmentSecretFile": secretPath, + } + if portal != nil { + payload["portal"] = portal + } + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func clearPortalEnv(t *testing.T) { + t.Helper() + t.Setenv(EnvPortalEnabled, "") + t.Setenv(EnvPortalOrigin, "") +} + +// TestPortalIsDisabledByDefault is exposure-gate item 1. A config that says +// nothing about the portal must not serve one. +func TestPortalIsDisabledByDefault(t *testing.T) { + clearPortalEnv(t) + cfg, err := Load(writePortalConfig(t, nil)) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Portal.Enabled { + t.Error("portal is enabled by a config that never mentions it") + } + if cfg.Portal.PublicOrigin != "" { + t.Errorf("portal origin defaulted to %q", cfg.Portal.PublicOrigin) + } +} + +// TestPortalRequiresOriginWhenEnabled refuses a half-configured public +// surface: without an exact origin the CSRF check could not be enforced, so +// the daemon must fail to start rather than serve without it. +func TestPortalRequiresOriginWhenEnabled(t *testing.T) { + clearPortalEnv(t) + _, err := Load(writePortalConfig(t, map[string]any{"enabled": true})) + if err == nil { + t.Fatal("an enabled portal without a public origin was accepted") + } + if !strings.Contains(err.Error(), "publicOrigin") { + t.Errorf("error does not name the missing field: %v", err) + } +} + +func TestPortalOriginRules(t *testing.T) { + clearPortalEnv(t) + cases := map[string]struct { + origin string + wantErr bool + }{ + "https": {"https://share.example.com", false}, + "https with port": {"https://share.example.com:8443", false}, + "loopback http": {"http://localhost:8765", false}, + "remote http": {"http://share.example.com", true}, + "with path": {"https://share.example.com/portal", true}, + "with query": {"https://share.example.com?a=b", true}, + "with credentials": {"https://user:pass@share.example.com", true}, + "unsupported scheme": {"ftp://share.example.com", true}, + "bare host": {"share.example.com", true}, + } + for name, testCase := range cases { + cfg, err := Load(writePortalConfig(t, map[string]any{ + "enabled": true, + "publicOrigin": testCase.origin, + })) + if testCase.wantErr { + if err == nil { + t.Errorf("%s: origin %q was accepted", name, testCase.origin) + } + continue + } + if err != nil { + t.Errorf("%s: origin %q was rejected: %v", name, testCase.origin, err) + continue + } + if cfg.Portal.PublicOrigin != testCase.origin { + t.Errorf("%s: origin = %q, want %q", name, cfg.Portal.PublicOrigin, testCase.origin) + } + } +} + +// TestPortalOriginTrailingSlashIsNormalized keeps the Origin comparison exact: +// browsers never send a trailing slash, so a configured one must not silently +// break every mutation. +func TestPortalOriginTrailingSlashIsNormalized(t *testing.T) { + clearPortalEnv(t) + cfg, err := Load(writePortalConfig(t, map[string]any{ + "enabled": true, + "publicOrigin": "https://share.example.com/", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Portal.PublicOrigin != "https://share.example.com" { + t.Errorf("origin = %q, want the trailing slash removed", cfg.Portal.PublicOrigin) + } +} + +func TestPortalEnvOverrides(t *testing.T) { + clearPortalEnv(t) + path := writePortalConfig(t, nil) + t.Setenv(EnvPortalEnabled, "true") + t.Setenv(EnvPortalOrigin, "https://env.example.com") + cfg, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if !cfg.Portal.Enabled || cfg.Portal.PublicOrigin != "https://env.example.com" { + t.Errorf("env overrides not applied: %+v", cfg.Portal) + } + + t.Setenv(EnvPortalEnabled, "yes-please") + if _, err := Load(path); err == nil { + t.Error("a non-boolean portal toggle was accepted") + } +} diff --git a/apps/server/internal/daemon/portal_test.go b/apps/server/internal/daemon/portal_test.go new file mode 100644 index 0000000..218163c --- /dev/null +++ b/apps/server/internal/daemon/portal_test.go @@ -0,0 +1,50 @@ +package daemon + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestPortalRoutesAreAbsentWhenDisabled is exposure-gate item 1 at the routing +// layer: with the portal off there must be no /p/ path to probe at all, not +// merely one that refuses. +func TestPortalRoutesAreAbsentWhenDisabled(t *testing.T) { + private := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + handler := mountPortal(private, nil) + + for _, path := range []string{"/p/", "/p/some-token", "/p/some-token/availability", "/p/assets/portal.css"} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + // The private handler answers everything, which is exactly the point: + // no portal mux was mounted, so /p/ is not special. + if recorder.Code != http.StatusTeapot { + t.Errorf("%s returned %d; a portal route appears to be mounted while disabled", path, recorder.Code) + } + } +} + +func TestPortalRoutesAreMountedWhenEnabled(t *testing.T) { + private := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + public := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusGone) + }) + handler := mountPortal(private, public) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/p/token", nil)) + if recorder.Code != http.StatusGone { + t.Errorf("/p/token returned %d, want the public handler's 410", recorder.Code) + } + + // The device API must keep working alongside it. + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/status", nil)) + if recorder.Code != http.StatusTeapot { + t.Errorf("/v1/status returned %d, want the private handler", recorder.Code) + } +} diff --git a/apps/server/internal/daemon/server.go b/apps/server/internal/daemon/server.go index f5891d5..2fd871c 100644 --- a/apps/server/internal/daemon/server.go +++ b/apps/server/internal/daemon/server.go @@ -13,7 +13,10 @@ import ( "non24.app/server/internal/api" "non24.app/server/internal/config" + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" "non24.app/server/internal/provider" + "non24.app/server/internal/readmodel" "non24.app/server/internal/store" ) @@ -59,7 +62,49 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error return fmt.Errorf("open store: %w", err) } defer st.Close() - handler := api.New(st, cfg.EnrollmentSecret, api.WithProvider(llm, providerStatus)).Handler() + + options := []api.Option{api.WithProvider(llm, providerStatus)} + var publicHandler http.Handler + if cfg.Portal.Enabled { + portalStore, portalErr := portal.Open(filepath.Join(cfg.DataDir, "zeitboard-portal.db"), cfg.DataKey) + if portalErr != nil { + return fmt.Errorf("open portal store: %w", portalErr) + } + defer portalStore.Close() + + materializer := portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: st}, + Profiles: portalStore, + Sink: portalStore, + } + portalHandler, handlerErr := portal.NewHandler(portal.HandlerConfig{ + Store: portalStore, + PublicOrigin: cfg.Portal.PublicOrigin, + }) + if handlerErr != nil { + return fmt.Errorf("configure portal: %w", handlerErr) + } + // Refresh once at startup so a link opened before the first sync of + // the day still shows current freshness rather than yesterday's. + if err := materializer.MaterializeAll(context.Background()); err != nil { + log.Printf("portal: initial snapshot refresh failed: %v", err) + } + // A dedicated stop channel rather than the caller's: `stop` may be + // nil, and this defer must run before the store closes (LIFO) so the + // worker never touches a closed database. + maintenanceStop := make(chan struct{}) + maintenanceDone := startPortalMaintenance(portalStore, maintenanceStop) + defer func() { + close(maintenanceStop) + <-maintenanceDone + }() + + options = append(options, api.WithPortal(portalStore, cfg.Portal.PublicOrigin, materializer)) + publicHandler = portalHandler.Routes() + log.Printf("zeitboardd portal enabled at %s/p/", cfg.Portal.PublicOrigin) + } + + handler := mountPortal(api.New(st, cfg.EnrollmentSecret, options...).Handler(), publicHandler) server := &http.Server{ Addr: cfg.ListenAddress, Handler: handler, @@ -109,6 +154,45 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error } } +const portalMaintenanceInterval = time.Hour + +// startPortalMaintenance expires sessions and rate buckets and enforces the +// access-audit retention window. Retention that nothing enforces is not +// retention, so this runs on a timer rather than being left to an operator. +// The returned channel closes once the worker has stopped. +func startPortalMaintenance(store *portal.Store, stop <-chan struct{}) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(portalMaintenanceInterval) + defer ticker.Stop() + for { + if err := store.PurgeExpired(context.Background(), time.Now().UTC()); err != nil { + log.Printf("portal: maintenance sweep failed: %v", err) + } + select { + case <-ticker.C: + case <-stop: + return + } + } + }() + return done +} + +// mountPortal routes /p/ to the public handler and everything else to the +// device API. When the portal is disabled publicHandler is nil and the private +// handler is returned unchanged, so no /p/ path exists at all. +func mountPortal(private http.Handler, publicHandler http.Handler) http.Handler { + if publicHandler == nil { + return private + } + mux := http.NewServeMux() + mux.Handle("/", private) + mux.Handle("/p/", publicHandler) + return mux +} + func normalizeServeError(err error) error { if err == nil || errors.Is(err, http.ErrServerClosed) { return nil diff --git a/apps/server/internal/daemon/server_test.go b/apps/server/internal/daemon/server_test.go index 3caff66..b400d3f 100644 --- a/apps/server/internal/daemon/server_test.go +++ b/apps/server/internal/daemon/server_test.go @@ -70,6 +70,8 @@ func writeDaemonTestConfig(t *testing.T) string { config.EnvLLMAPIKey, config.EnvLLMAPIKeyFile, config.EnvLLMEndpoint, + config.EnvPortalEnabled, + config.EnvPortalOrigin, } { t.Setenv(name, "") } diff --git a/apps/server/internal/portal/assets/pages.gohtml b/apps/server/internal/portal/assets/pages.gohtml new file mode 100644 index 0000000..cfc92eb --- /dev/null +++ b/apps/server/internal/portal/assets/pages.gohtml @@ -0,0 +1,73 @@ +{{define "head"}} + +
+ + + + +{{if .Refresh}}{{end}} +{{.Detail}}
+{{.Freshness}}
+Times shown in {{.ZoneLabel}}.
+How accurate is this? {{.Qualifier}}
+{{.Notice}}
+Enter the passcode you were given along with this link.
+ {{if .Error}}{{.Error}}
{{end}} + +{{.View.Notice}}
+{{.Error}}
+This page is unavailable.")) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(buffer.Bytes()) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func pagePath(token string) string { + return (&url.URL{Path: "/p/" + token}).String() +} + +func sessionPath(token string) string { + return (&url.URL{Path: "/p/" + token + "/session"}).String() +} diff --git a/apps/server/internal/portal/http.go b/apps/server/internal/portal/http.go new file mode 100644 index 0000000..9bb9af3 --- /dev/null +++ b/apps/server/internal/portal/http.go @@ -0,0 +1,277 @@ +package portal + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + // maxPublicBodyBytes caps a request before any decoding happens. + maxPublicBodyBytes = 4 << 10 + + // sessionCookieName uses the __Host- prefix, which browsers only accept + // with Secure, Path=/, and no Domain attribute. + sessionCookieName = "__Host-zb_portal" + + // resolutionFloor is applied to link resolution whether it succeeds or + // fails, so an enumerator cannot separate "real link" from "unknown + // link" by response time. It is a bounded floor, not a constant-time + // guarantee; docs/portal-design.md section 4 states that limit. + resolutionFloor = 120 * time.Millisecond +) + +type profileContextKey struct{} +type sessionContextKey struct{} + +// Handler serves the public portal. It holds a portal store and nothing else: +// there is no field through which a private record could be reached. +type Handler struct { + store *Store + now func() time.Time + publicOrigin string + resolutionFloor time.Duration +} + +type HandlerConfig struct { + Store *Store + Now func() time.Time + + // PublicOrigin is the exact scheme://host[:port] the portal is served + // on. Mutating requests must carry it as Origin. + PublicOrigin string + + // ResolutionFloor overrides the enumeration timing floor. Values at or + // below zero use the package default, so a caller cannot disable the + // floor by leaving the field unset; tests shorten it explicitly. + ResolutionFloor time.Duration +} + +func NewHandler(cfg HandlerConfig) (*Handler, error) { + if cfg.Store == nil { + return nil, errors.New("portal handler requires a store") + } + origin := strings.TrimSpace(strings.TrimSuffix(cfg.PublicOrigin, "/")) + if origin == "" { + return nil, errors.New("portal handler requires a public origin") + } + now := cfg.Now + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + floor := cfg.ResolutionFloor + if floor <= 0 { + floor = resolutionFloor + } + return &Handler{store: cfg.Store, now: now, publicOrigin: origin, resolutionFloor: floor}, nil +} + +// Routes returns the public mux. It is mounted only when the portal is +// enabled; when disabled the daemon never calls this and no /p/ route exists. +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /p/assets/portal.css", h.baseChain(http.HandlerFunc(h.handleStylesheet))) + mux.Handle("GET /p/{linkToken}", h.linkChain(http.HandlerFunc(h.handlePage))) + mux.Handle("POST /p/{linkToken}/session", h.linkChain(h.requireOrigin(http.HandlerFunc(h.handleSession)))) + mux.Handle("GET /p/{linkToken}/availability", h.linkChain(h.requireSession(http.HandlerFunc(h.handleAvailability)))) + return mux +} + +// baseChain applies the caps and headers that every public response needs, +// including responses that never resolve a link. +func (h *Handler) baseChain(next http.Handler) http.Handler { + return h.limitBody(h.securityHeaders(next)) +} + +// linkChain is the full public order from docs/portal-design.md section 3: +// size cap, security headers, source throttling, link resolution, then the +// handler. The passcode-session gate is applied per route because the page +// itself must render for an unauthenticated visitor. +func (h *Handler) linkChain(next http.Handler) http.Handler { + return h.limitBody(h.securityHeaders(h.throttle(h.resolveLink(next)))) +} + +func (h *Handler) limitBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxPublicBodyBytes) + next.ServeHTTP(w, r) + }) +} + +func (h *Handler) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := w.Header() + header.Set("Cache-Control", "no-store, max-age=0") + header.Set("Content-Security-Policy", + "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; "+ + "img-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'") + header.Set("Referrer-Policy", "no-referrer") + header.Set("X-Content-Type-Options", "nosniff") + header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + header.Set("Cross-Origin-Resource-Policy", "same-origin") + // Search engines must never index a share link. + header.Set("X-Robots-Tag", "noindex, nofollow, noarchive") + next.ServeHTTP(w, r) + }) +} + +// throttle applies the persisted per-source read limit before any expensive +// work. It runs before link resolution and therefore before the passcode KDF, +// which is what keeps argon2id from becoming a memory-exhaustion lever. +func (h *Handler) throttle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + sourceID, err := h.store.SourceID(ctx, r.RemoteAddr) + if err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + allowed, retryAfter, err := h.store.Allow(ctx, "read:"+sourceID, ReadLimitPerHour, ReadLimitWindow, h.now()) + if err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + if !allowed { + _ = h.store.RecordAccess(ctx, "", EventThrottled, sourceID, h.now()) + w.Header().Set("Retry-After", retryAfterSeconds(retryAfter)) + h.writeGeneric(w, r, http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, sourceContextKey{}, sourceID))) + }) +} + +type sourceContextKey struct{} + +func sourceFromContext(ctx context.Context) string { + value, _ := ctx.Value(sourceContextKey{}).(string) + return value +} + +// resolveLink turns the path token into a profile. Unknown, expired, and +// revoked links are indistinguishable here because ResolveLink returns one +// error for all three; this handler could not leak the difference if it tried. +func (h *Handler) resolveLink(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := h.now() + ctx := r.Context() + token := r.PathValue("linkToken") + profile, err := h.store.ResolveLink(ctx, token, h.now()) + h.holdFloor(started) + if err != nil { + if errors.Is(err, ErrLinkNotUsable) { + _ = h.store.RecordAccess(ctx, "", EventLinkRejected, sourceFromContext(ctx), h.now()) + h.writeGeneric(w, r, http.StatusGone) + return + } + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, profileContextKey{}, profile))) + }) +} + +// holdFloor pads a resolution to the timing floor. It sleeps rather than +// busy-waits so a flood costs memory for a parked goroutine, not CPU. +func (h *Handler) holdFloor(started time.Time) { + elapsed := h.now().Sub(started) + if elapsed < h.resolutionFloor { + time.Sleep(h.resolutionFloor - elapsed) + } +} + +// requireSession gates authenticated reads. The session must belong to the +// same profile the path token resolved to, so holding a session for one link +// grants nothing on another. +func (h *Handler) requireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + profile := profileFromContext(r.Context()) + session, ok := h.sessionFor(r, profile) + if !ok { + h.writeGeneric(w, r, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionContextKey{}, session))) + }) +} + +func (h *Handler) sessionFor(r *http.Request, profile Profile) (Session, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return Session{}, false + } + session, err := h.store.ResolveSession(r.Context(), cookie.Value, h.now()) + if err != nil { + return Session{}, false + } + if session.ProfileID != profile.ID { + return Session{}, false + } + return session, true +} + +// requireOrigin is the pre-session CSRF defence. A passcode POST cannot carry +// a synchronizer token yet, so browser attestation that the request came from +// this origin is what stops a third party from silently authenticating a +// visitor's browser to a link. +func (h *Handler) requireOrigin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !h.isSameOriginMutation(r) { + h.writeGeneric(w, r, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// isSameOriginMutation decides whether a mutating request really came from a +// page this server served. +// +// Origin alone is not sufficient here. These responses set +// Referrer-Policy: no-referrer, and per the Fetch standard that makes a +// browser send `Origin: null` on a same-origin form POST rather than the real +// origin. Relaxing the referrer policy to recover Origin would put the link +// token into Referer headers, which section 9 of the design specifically +// avoids. Sec-Fetch-Site is unaffected by referrer policy and cannot be set by +// page script, so it is the primary signal; an exact Origin match remains the +// fallback for clients that predate Fetch Metadata. +func (h *Handler) isSameOriginMutation(r *http.Request) bool { + origin := r.Header.Get("Origin") + // A present, non-null Origin must match no matter what else is claimed. + if origin != "" && origin != "null" && !strings.EqualFold(origin, h.publicOrigin) { + return false + } + switch r.Header.Get("Sec-Fetch-Site") { + case "same-origin": + return true + case "": + // No Fetch Metadata at all: fall back to a real Origin match. A + // request with neither header is refused, so this fails closed. + return origin != "" && strings.EqualFold(origin, h.publicOrigin) + default: + // cross-site, same-site, and none are all refused: a subdomain is not + // this origin, and a POST is never user-initiated navigation. + return false + } +} + +func profileFromContext(ctx context.Context) Profile { + profile, _ := ctx.Value(profileContextKey{}).(Profile) + return profile +} + +func sessionFromContext(ctx context.Context) Session { + session, _ := ctx.Value(sessionContextKey{}).(Session) + return session +} + +func retryAfterSeconds(value time.Duration) string { + seconds := int(value.Seconds()) + if seconds < 1 { + seconds = 1 + } + return strconv.Itoa(seconds) +} diff --git a/apps/server/internal/portal/portal_test.go b/apps/server/internal/portal/portal_test.go new file mode 100644 index 0000000..ba5cf2f --- /dev/null +++ b/apps/server/internal/portal/portal_test.go @@ -0,0 +1,769 @@ +package portal + +import ( + "context" + "encoding/json" + "go/parser" + "go/token" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +const ( + testOrigin = "https://share.example.test" + testPasscode = "open-sesame" +) + +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) Advance(delta time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(delta) +} + +type harness struct { + t *testing.T + store *Store + handler http.Handler + clock *clock + token string + profile Profile +} + +func newHarness(t *testing.T) *harness { + t.Helper() + fixed := &clock{now: time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC)} + store, err := Open(filepath.Join(t.TempDir(), "portal.db"), make([]byte, 32)) + if err != nil { + t.Fatalf("open portal store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + handler, err := NewHandler(HandlerConfig{ + Store: store, + Now: fixed.Now, + PublicOrigin: testOrigin, + ResolutionFloor: time.Nanosecond, + }) + if err != nil { + t.Fatalf("new portal handler: %v", err) + } + + h := &harness{t: t, store: store, handler: handler.Routes(), clock: fixed} + h.token, h.profile = h.createProfile(Grants{WakingWindows: true}) + return h +} + +func (h *harness) createProfile(grants Grants) (string, Profile) { + h.t.Helper() + profileID, err := NewProfileID() + if err != nil { + h.t.Fatalf("new profile id: %v", err) + } + token, err := NewLinkToken() + if err != nil { + h.t.Fatalf("new link token: %v", err) + } + now := h.clock.Now() + if err := h.store.CreateProfile(context.Background(), CreateProfileInput{ + ProfileID: profileID, + Token: token, + Passcode: testPasscode, + Grants: grants, + CreatedAt: now, + ExpiresAt: now.Add(30 * 24 * time.Hour), + }); err != nil { + h.t.Fatalf("create profile: %v", err) + } + profile, err := h.store.ResolveLink(context.Background(), token, now) + if err != nil { + h.t.Fatalf("resolve new link: %v", err) + } + return token, profile +} + +func (h *harness) publish(profileID string, snapshot Snapshot) { + h.t.Helper() + if err := h.store.PublishSnapshot(context.Background(), profileID, snapshot); err != nil { + h.t.Fatalf("publish snapshot: %v", err) + } +} + +func (h *harness) sampleSnapshot() Snapshot { + now := h.clock.Now() + return Snapshot{ + Version: now.UnixMilli(), + GeneratedAt: now, + Status: StatusAvailable, + HorizonEnd: now.Add(30 * time.Hour), + Windows: []Window{ + {StartAt: now.Add(-time.Hour), EndAt: now.Add(6 * time.Hour), ZoneID: "America/New_York"}, + {StartAt: now.Add(24 * time.Hour), EndAt: now.Add(30 * time.Hour), ZoneID: "America/New_York"}, + }, + } +} + +func (h *harness) get(path string, cookie string) *httptest.ResponseRecorder { + h.t.Helper() + request := httptest.NewRequest(http.MethodGet, path, nil) + request.RemoteAddr = "203.0.113.10:5555" + if cookie != "" { + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + } + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder +} + +// login performs the passcode exchange using only an Origin header, which is +// the pre-Fetch-Metadata client shape. +func (h *harness) login(token, passcode, origin string) *httptest.ResponseRecorder { + headers := map[string]string{} + if origin != "" { + headers["Origin"] = origin + } + return h.loginWithHeaders(token, passcode, headers) +} + +func (h *harness) loginWithHeaders(token, passcode string, headers map[string]string) *httptest.ResponseRecorder { + h.t.Helper() + form := url.Values{"passcode": {passcode}} + request := httptest.NewRequest(http.MethodPost, "/p/"+token+"/session", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.RemoteAddr = "203.0.113.10:5555" + for name, value := range headers { + request.Header.Set(name, value) + } + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder +} + +func (h *harness) sessionCookie(recorder *httptest.ResponseRecorder) string { + h.t.Helper() + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == sessionCookieName { + return cookie.Value + } + } + h.t.Fatalf("no session cookie in response (status %d)", recorder.Code) + return "" +} + +// TestPortalPackageDoesNotReachPrivateData is the structural half of the +// boundary claim in docs/portal-design.md section 2. The public package must +// not be able to reach a sleep record by following an import, and a reviewer +// should not have to notice a new import to keep that true. +func TestPortalPackageDoesNotReachPrivateData(t *testing.T) { + forbidden := []string{ + "non24.app/server/internal/store", + "non24.app/server/internal/readmodel", + "non24.app/server/internal/assistant", + "non24.app/server/internal/api", + "non24.app/server/internal/portalbridge", + "non24.app/server/internal/provider", + "non24.app/core/estimation", + "non24.app/core/domain", + } + fileSet := token.NewFileSet() + packages, err := parser.ParseDir(fileSet, ".", nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse portal package: %v", err) + } + for name, pkg := range packages { + for path, file := range pkg.Files { + if strings.HasSuffix(path, "_test.go") { + continue + } + for _, imported := range file.Imports { + value := strings.Trim(imported.Path.Value, `"`) + for _, banned := range forbidden { + if value == banned { + t.Errorf("package %s file %s imports forbidden package %s", name, path, banned) + } + } + } + } + } +} + +// TestAvailabilityDTOAllowlist asserts the exact public JSON shape. Any new +// key is a widening of the public boundary and must fail here first. +func TestAvailabilityDTOAllowlist(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + recorder := h.get("/p/"+h.token+"/availability", cookie) + if recorder.Code != http.StatusOK { + t.Fatalf("availability status = %d, want 200", recorder.Code) + } + var decoded map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode availability: %v", err) + } + + allowed := map[string]bool{"version": true, "windows": true, "generatedAt": true, "horizonEnd": true, "status": true} + for key := range decoded { + if !allowed[key] { + t.Errorf("availability response has non-allowlisted key %q", key) + } + } + for _, required := range []string{"version", "windows", "generatedAt", "horizonEnd", "status"} { + if _, ok := decoded[required]; !ok { + t.Errorf("availability response is missing %q", required) + } + } + + windowKeys := map[string]bool{"startAt": true, "endAt": true, "zoneId": true} + windows, ok := decoded["windows"].([]any) + if !ok || len(windows) == 0 { + t.Fatalf("expected windows in availability response, got %v", decoded["windows"]) + } + for _, entry := range windows { + window, ok := entry.(map[string]any) + if !ok { + t.Fatalf("window is not an object: %v", entry) + } + for key := range window { + if !windowKeys[key] { + t.Errorf("window has non-allowlisted key %q", key) + } + } + } +} + +// TestUnknownExpiredRevokedAreIndistinguishable is the enumeration guard: all +// three must produce byte-identical responses. +func TestUnknownExpiredRevokedAreIndistinguishable(t *testing.T) { + h := newHarness(t) + + expiredToken, expiredProfile := h.createProfile(Grants{WakingWindows: true}) + revokedToken, revokedProfile := h.createProfile(Grants{WakingWindows: true}) + if err := h.store.RevokeProfile(context.Background(), revokedProfile.ID, h.clock.Now()); err != nil { + t.Fatalf("revoke: %v", err) + } + _ = expiredProfile + h.clock.Advance(31 * 24 * time.Hour) + + unknownToken, err := NewLinkToken() + if err != nil { + t.Fatalf("new token: %v", err) + } + + responses := make([]*httptest.ResponseRecorder, 0, 3) + for _, token := range []string{unknownToken, expiredToken, revokedToken} { + responses = append(responses, h.get("/p/"+token, "")) + } + for i, recorder := range responses { + if recorder.Code != http.StatusGone { + t.Errorf("response %d status = %d, want 410", i, recorder.Code) + } + if recorder.Body.String() != responses[0].Body.String() { + t.Errorf("response %d body differs from the first; link states are distinguishable", i) + } + } +} + +// TestRevocationIsImmediate covers the kill switch: an already-authenticated +// visitor must lose access at once, not when their cookie expires. +func TestRevocationIsImmediate(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + if recorder := h.get("/p/"+h.token, cookie); recorder.Code != http.StatusOK { + t.Fatalf("pre-revocation status = %d, want 200", recorder.Code) + } + if err := h.store.RevokeProfile(context.Background(), h.profile.ID, h.clock.Now()); err != nil { + t.Fatalf("revoke: %v", err) + } + if recorder := h.get("/p/"+h.token, cookie); recorder.Code != http.StatusGone { + t.Errorf("post-revocation status = %d, want 410", recorder.Code) + } + if _, err := h.store.ReadSnapshot(context.Background(), h.profile.ID); err == nil { + t.Error("snapshot survived revocation; availability data must be deleted") + } +} + +func TestPasscodeGate(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + + page := h.get("/p/"+h.token, "") + if page.Code != http.StatusOK { + t.Fatalf("unauthenticated page status = %d, want 200", page.Code) + } + if !strings.Contains(page.Body.String(), "needs a passcode") { + t.Error("unauthenticated visitor was not shown the passcode form") + } + if strings.Contains(page.Body.String(), "Likely awake") { + t.Error("availability rendered before the passcode was accepted") + } + + rejected := h.login(h.token, "wrong-passcode", testOrigin) + if rejected.Code != http.StatusUnauthorized { + t.Errorf("wrong passcode status = %d, want 401", rejected.Code) + } + + // The failed attempt arms the backoff, so advance past it before the + // legitimate visitor tries again. + h.clock.Advance(2 * time.Second) + accepted := h.login(h.token, testPasscode, testOrigin) + if accepted.Code != http.StatusSeeOther { + t.Fatalf("correct passcode status = %d, want 303", accepted.Code) + } + cookie := h.sessionCookie(accepted) + dashboard := h.get("/p/"+h.token, cookie) + if !strings.Contains(dashboard.Body.String(), "Likely awake right now") { + t.Errorf("dashboard did not render the current state: %s", dashboard.Body.String()) + } +} + +// TestPasscodeBackoffIsPerProfileAndSource proves progressive delay exists and +// that it does not become a global lockout an attacker could trigger. +func TestPasscodeBackoffIsPerProfileAndSource(t *testing.T) { + h := newHarness(t) + if recorder := h.login(h.token, "wrong", testOrigin); recorder.Code != http.StatusUnauthorized { + t.Fatalf("first failure status = %d, want 401", recorder.Code) + } + // Immediately retrying with the *correct* passcode is throttled, not + // permanently refused. + throttled := h.login(h.token, testPasscode, testOrigin) + if throttled.Code != http.StatusTooManyRequests { + t.Errorf("immediate retry status = %d, want 429", throttled.Code) + } + h.clock.Advance(5 * time.Second) + if recorder := h.login(h.token, testPasscode, testOrigin); recorder.Code != http.StatusSeeOther { + t.Errorf("retry after backoff status = %d, want 303", recorder.Code) + } +} + +func TestSessionIsBoundToOneProfile(t *testing.T) { + h := newHarness(t) + otherToken, otherProfile := h.createProfile(Grants{WakingWindows: true}) + h.publish(h.profile.ID, h.sampleSnapshot()) + h.publish(otherProfile.ID, h.sampleSnapshot()) + + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + crossed := h.get("/p/"+otherToken+"/availability", cookie) + if crossed.Code != http.StatusUnauthorized { + t.Errorf("cross-link availability status = %d, want 401", crossed.Code) + } +} + +func TestSessionRequiresExactOrigin(t *testing.T) { + h := newHarness(t) + for name, origin := range map[string]string{ + "missing": "", + "foreign": "https://evil.example", + "subdomain": "https://x.share.example.test", + "scheme swap": "http://share.example.test", + } { + recorder := h.login(h.token, testPasscode, origin) + if recorder.Code != http.StatusForbidden { + t.Errorf("%s origin status = %d, want 403", name, recorder.Code) + } + } +} + +// TestSameOriginFormPostWithNullOrigin is the case a browser actually +// produces. Referrer-Policy: no-referrer makes a browser send `Origin: null` +// on a same-origin form submission, so gating on Origin alone would refuse +// every real login. Sec-Fetch-Site carries the truth. +func TestSameOriginFormPostWithNullOrigin(t *testing.T) { + h := newHarness(t) + recorder := h.loginWithHeaders(h.token, testPasscode, map[string]string{ + "Origin": "null", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + }) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("same-origin form post status = %d, want 303", recorder.Code) + } +} + +func TestFetchMetadataDecidesMutations(t *testing.T) { + cases := map[string]struct { + headers map[string]string + want int + }{ + "same-origin without Origin": { + map[string]string{"Sec-Fetch-Site": "same-origin"}, http.StatusSeeOther, + }, + "same-origin with matching Origin": { + map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": testOrigin}, http.StatusSeeOther, + }, + "cross-site even with a spoofed Origin": { + map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": testOrigin}, http.StatusForbidden, + }, + "same-site subdomain": { + map[string]string{"Sec-Fetch-Site": "same-site"}, http.StatusForbidden, + }, + "user-initiated navigation": { + map[string]string{"Sec-Fetch-Site": "none"}, http.StatusForbidden, + }, + "no metadata and no Origin": { + map[string]string{}, http.StatusForbidden, + }, + "no metadata with matching Origin": { + map[string]string{"Origin": testOrigin}, http.StatusSeeOther, + }, + "null Origin without metadata": { + map[string]string{"Origin": "null"}, http.StatusForbidden, + }, + "same-origin metadata but foreign Origin": { + map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://evil.example"}, http.StatusForbidden, + }, + } + for name, testCase := range cases { + h := newHarness(t) + recorder := h.loginWithHeaders(h.token, testPasscode, testCase.headers) + if recorder.Code != testCase.want { + t.Errorf("%s: status = %d, want %d", name, recorder.Code, testCase.want) + } + } +} + +func TestSecurityHeadersOnEveryResponse(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + paths := []string{"/p/" + h.token, "/p/" + h.token + "/availability", "/p/assets/portal.css", "/p/nonexistent-token"} + for _, path := range paths { + recorder := h.get(path, cookie) + header := recorder.Header() + csp := header.Get("Content-Security-Policy") + if !strings.Contains(csp, "default-src 'none'") { + t.Errorf("%s missing default-src 'none': %q", path, csp) + } + if !strings.Contains(csp, "frame-ancestors 'none'") { + t.Errorf("%s missing frame-ancestors 'none'", path) + } + for _, directive := range []string{"style-src 'self'", "script-src 'self'", "connect-src 'self'", "img-src 'self'"} { + if !strings.Contains(csp, directive) { + t.Errorf("%s missing %q", path, directive) + } + } + if got := header.Get("Cache-Control"); got != "no-store, max-age=0" { + t.Errorf("%s Cache-Control = %q", path, got) + } + if got := header.Get("Referrer-Policy"); got != "no-referrer" { + t.Errorf("%s Referrer-Policy = %q", path, got) + } + if got := header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("%s X-Content-Type-Options = %q", path, got) + } + if !strings.Contains(header.Get("X-Robots-Tag"), "noindex") { + t.Errorf("%s is not marked noindex", path) + } + } +} + +// TestPageHasNoInlineOrThirdPartyAssets keeps the CSP honest: a page with an +// inline style would render fine in a browser that ignores CSP and break in +// one that does not. +func TestPageHasNoInlineOrThirdPartyAssets(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + body := h.get("/p/"+h.token, cookie).Body.String() + + for _, forbidden := range []string{"