From d784859eba7d17653acaaecbf80ce58d43f288d3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:51:26 +0200 Subject: [PATCH 1/6] feat(statistics): support Cloud terms and local-only contribution --- CLAUDE.md | 4 +- README.md | 70 ++++++++++---------- go.mod | 2 +- go.sum | 4 +- internal/registry/statistics.go | 5 +- internal/store/store.go | 3 +- internal/uplink/statistics_test.go | 101 ++++++++++++++++++++++++++--- internal/uplink/uplink.go | 30 +++++++-- privacy/project-statistics-dpia.md | 77 ++++++++++++++++------ 9 files changed, 219 insertions(+), 77 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b18aa05..8f4abdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,8 +32,8 @@ maintains it. never from a body field. Every later box message but `tun` must verify with that key. `tun` is unsigned: its `d` is a phone's ciphertext, which the service routes by `dev` and `sid` and never reads. -- **Live readings stay in memory.** The separate, versioned project statistics - consent permits rounded closed-hour energy and configured capacities on +- **Live readings stay in memory.** The versioned project statistics + scope covers rounded closed-hour energy and configured capacities on disk for 13 months. Only group cells pass `/v1/stats`; no staff route reads a home. Withdrawal and home purge delete its unpublished statistics. Memory holds live readings, and only while the roster's diff --git a/README.md b/README.md index 6336941..1e5404b 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ Not built yet: | Step | What | Where | |---|---|---| | C6 | Tiles | `internal/server` | -| C7–C8 | Statistics | not started | +| C7–C8 | Statistics | implemented with collection off; review pending | ## How it holds a home @@ -384,42 +384,44 @@ The sender refuses redirects, proxies and local destinations. ## Project usage statistics `GET /v1/stats` publishes only group cells under -`ftw-project-stats-2026-10-v1`. Boxes send signed `stats` hours only after the -owner accepts that policy and the service advertises it in signed `hello_ok`. -The service starts with collection off. After the signed C8 DPIA review, -operators can set `FTW_CLOUD_STATISTICS_POLICY` to that exact policy in the -host's compose environment; empty keeps it off. An unapproved policy makes -`/v1/stats` return 503 with `FTW-Statistics-State: disabled`. Withdrawal, -purge and retention remain active while collection is off. The review draft -is in [privacy/project-statistics-dpia.md](privacy/project-statistics-dpia.md). The previous statistics policy sends no new -fields. Reporting installations in the last 24 closed hours are not people -or total FTW installs. Power and capacity are configured; energy sums -recorded hours, including partial hours. Missing data is unknown. - -Cloud stores consented hourly values for 13 months, upserts reconnects, and -deletes household rows on a signed withdrawal or purge. Cells need 20 homes -and no home above 30 percent of the sum. The stats Worker polls this public -view; it receives no home id or per-home value. One instance supplies its -local shard only; public multi-shard totals need an explicit safe collector. - -Owner acceptance uses the existing box API. On the box's loopback interface, -read `GET /api/cloud/stats-policy`, accept its terms, then -`POST /api/cloud/stats` with `{"policy":"ftw-project-stats-2026-10-v1"}`. -The readable Cloud grant must already be on. `DELETE /api/cloud/stats` -withdraws this choice. Do not enable it for someone else or reuse an older -consent. Native apps keep the old scope until their consent screen adopts -this policy. The first contributing hour starts after consent; the sender -does not upload earlier ledger history. - -Enabling this collection scope needs the signed C8 DPIA and the updated -consent text described by Core's ADR 0008. A deployment with collection off -does not claim that sign-off or change an owner's saved choice. The paired -native consent change requires a fresh choice for this policy. - +`ftw-project-stats-2026-10-v2`. Collection stays off until the signed C8 DPIA +and the legal basis for both flows have been reviewed. Empty +`FTW_CLOUD_STATISTICS_POLICY` keeps it off; an exact approved policy is needed +to advertise collection. While paused, `/v1/stats` returns 503 with +`FTW-Statistics-State: disabled`. Withdrawal, purge and retention still run. +The unsigned [review draft](privacy/project-statistics-dpia.md) records the +open decisions. Clear terms alone do not establish a lawful basis. + +Statistics are included for every FTW Cloud user under +`ftw-cloud-2026-10-v2`. Owners accept the current scope through +`POST /api/cloud/grant`. There is no separate statistics opt-out within Cloud; +`DELETE /api/cloud` ends Cloud and requests deletion. Old terms do not accept +the new scope. Deploy compatible Core and clients, show the new terms, and +review the migration before policy activation. When activated, the service +refuses legacy rosters and does not restore their cached access after restart. +Until activation, existing Cloud traffic continues under the old scope. + +Local FTW users can instead answer Yes or No on the box. Yes is the suggested +action, but no new report starts without an answer. `POST /api/cloud/stats` +with the current statistics policy creates a statistics-only signed uplink: +no phone keys, pairing codes, Noise key, live cache or remote access. +`DELETE /api/cloud/stats` stops this local contribution. A current Cloud grant +refuses that separate stop with 409. Never answer for another household. + +Cloud stores the approved hourly fields for 13 months and deletes unpublished +household rows on withdrawal or purge. Only closed hours starting after the +new choice contribute; no earlier ledger history is uploaded. Group values +need 20 homes and no home above 30 percent of a sum. The stats Worker receives +no home id or per-home value. Counts describe reporting installations, not +people or all-time installs. Capacity and power are configured limits, not +proof of physical control. Energy includes only recorded hours, which can be +partial; missing data is unknown. One instance supplies its local shard only; +public multi-shard totals need an explicit safe collector. The separate anonymous daily-report feed is `GET /v1/fleet/public`. It exposes only daily counts and dimension labels with at least 10 reports, for at most 90 days. It contains imported relay history and new reports received by Cloud; consumers replace the old archive rather than adding both. It needs no hourly -energy policy. Averaging seven complete UTC days gives an estimate of active +energy activation. Updated Core sends it only after the current project +statistics answer; legacy versions retain their existing daily-report setting. Averaging seven complete UTC days gives an estimate of active reporting installations, never people or all installations ever made. diff --git a/go.mod b/go.mod index 108f2b0..e53e3e3 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261017140802-6335dbcea60e + github.com/srcfl/ftw/go/protocol v0.0.0-20261017144639-11756627d4d7 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 4868045..48d3887 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017140802-6335dbcea60e h1:9J9k985aF1/Drp5GHOZFkLmVq2ImuWRPIzUlXiPyoqY= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017140802-6335dbcea60e/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017144639-11756627d4d7 h1:nxxjSNZK7JiQXV/jX1XvjENye6GpFUGJry86HGKZWvo= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017144639-11756627d4d7/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/registry/statistics.go b/internal/registry/statistics.go index 0322e1e..dafd98a 100644 --- a/internal/registry/statistics.go +++ b/internal/registry/statistics.go @@ -12,7 +12,7 @@ import ( "github.com/srcfl/ftw/go/protocol/cloudwire" ) -// Statistics tables contain only separately consented, closed-hour values. +// Statistics tables contain only versioned, owner-approved closed-hour values. // They have no staff read route, and leave with the home's purge. func (r *Registry) StatisticsInit() error { _, err := r.db.Exec(`CREATE TABLE IF NOT EXISTS statistics_grants ( @@ -38,8 +38,7 @@ func (r *Registry) StatisticsGrant(home string, g cloudwire.Grant) error { return err } on := g.Controller == cloudwire.ControllerSourceful && g.StatsPolicy == cloudwire.StatsPolicy && - len(g.StatsID) == 16 && g.StatsSinceMs > 0 && slices.Contains(g.Purposes, protocol.CloudPurposeStats) && - slices.Contains(g.Purposes, protocol.CloudPurposeServe) + len(g.StatsID) == 16 && g.StatsSinceMs > 0 && slices.Contains(g.Purposes, protocol.CloudPurposeStats) if !on || !bytes.Equal(old, g.StatsID) { if _, err := tx.Exec(`DELETE FROM statistics_hours WHERE home=?`, home); err != nil { return err diff --git a/internal/store/store.go b/internal/store/store.go index 5b1d930..1446d95 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -615,7 +615,8 @@ func (s *Store) Seed(id string, r *cloudwire.Roster, at time.Time) { } // Forget drops a home the registry has deleted, as after 90 days without a -// roster. It is called by upkeep, never by a request. +// roster. Upkeep uses it after expiry; a signed roster without required Cloud terms +// also loses every cached session until its box submits the current terms. func (s *Store) Forget(id string) { s.mu.Lock() h := s.homes[id] diff --git a/internal/uplink/statistics_test.go b/internal/uplink/statistics_test.go index 6a66031..79ddb85 100644 --- a/internal/uplink/statistics_test.go +++ b/internal/uplink/statistics_test.go @@ -3,6 +3,7 @@ package uplink import ( "bytes" + "github.com/srcfl/ftw-cloud/internal/store" "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" "testing" @@ -25,6 +26,7 @@ func TestSignedStatisticsRequireCurrentConsentAndNeverForward(t *testing.T) { r := b.roster(protocol.ScopeLiveRead) r.Grant.Purposes = append(r.Grant.Purposes, protocol.CloudPurposeStats) r.Grant.StatsID = bytes.Repeat([]byte{9}, 16) + r.Grant.TermsPolicy = cloudwire.CloudTermsPolicy r.Grant.StatsPolicy = cloudwire.StatsPolicy r.Grant.StatsSinceMs = now.Add(-2 * time.Hour).UnixMilli() b.send(cloudwire.MsgRoster, r) @@ -54,15 +56,6 @@ func TestSignedStatisticsRequireCurrentConsentAndNeverForward(t *testing.T) { v, err := s.reg.StatisticsView(now) return err == nil && v.Installations.State == "empty" }) - report.Header = b.header() - b.send(cloudwire.MsgStats, report) - // A following live frame proves the service processed the stale stats. - b.send(cloudwire.MsgLive, cloudwire.Live{Header: b.header(), Fields: map[string]int64{"2": 1200}}) - wait(t, "following live frame", func() bool { return len(snapshot(t, s, b.home)) > 0 }) - v, err := s.reg.StatisticsView(now) - if err != nil || v.Installations.State != "empty" { - t.Fatal("stale consent restored rows") - } } func TestPausedStatisticsIgnoreReportsButStillDeleteOnWithdrawal(t *testing.T) { @@ -83,6 +76,7 @@ func TestPausedStatisticsIgnoreReportsButStillDeleteOnWithdrawal(t *testing.T) { r := b.roster(protocol.ScopeLiveRead) r.Grant.Purposes = append(r.Grant.Purposes, protocol.CloudPurposeStats) r.Grant.StatsID = bytes.Repeat([]byte{9}, 16) + r.Grant.TermsPolicy = cloudwire.CloudTermsPolicy r.Grant.StatsPolicy = cloudwire.StatsPolicy r.Grant.StatsSinceMs = now.Add(-2 * time.Hour).UnixMilli() b.send(cloudwire.MsgRoster, r) @@ -114,3 +108,92 @@ func TestPausedStatisticsIgnoreReportsButStillDeleteOnWithdrawal(t *testing.T) { return err == nil && v.Installations.State == "empty" }) } + +func TestLocalStatisticsAcceptsHoursWithoutRemoteRecipients(t *testing.T) { + s := newService(t) + if err := s.reg.StatisticsInit(); err != nil { + t.Fatal(err) + } + s.up.Statistics = true + now := time.Now() + b := newBox(t) + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + r := b.roster() + r.Devices = nil + r.Grant.Purposes = []string{protocol.CloudPurposeStats} + r.Grant.TermsPolicy, r.Grant.StatsPolicy = cloudwire.StatsPolicy, cloudwire.StatsPolicy + r.Grant.StatsID = bytes.Repeat([]byte{7}, 16) + r.Grant.StatsSinceMs = now.Add(-2 * time.Hour).UnixMilli() + b.send(cloudwire.MsgRoster, r) + report := cloudwire.Stats{Header: b.header(), StatsID: r.Grant.StatsID, Policy: cloudwire.StatsPolicy, HourMS: now.Truncate(time.Hour).Add(-time.Hour).UnixMilli(), Metrics: map[string]int64{"pv_generation_wh": 120}} + b.send(cloudwire.MsgStats, report) + wait(t, "local statistics hour", func() bool { + v, err := s.reg.StatisticsView(now) + return err == nil && v.Installations.State == "withheld" + }) + if _, err := s.store.For(store.Principal{Kind: store.Device, Home: b.home, DeviceKey: ownerKey}); err == nil { + t.Fatal("local statistics admitted a phone") + } +} + +func TestStatisticsOnlyRejectsRemoteMetadata(t *testing.T) { + b := newBox(t) + r := b.roster() + r.Devices = nil + r.Grant.Purposes = []string{protocol.CloudPurposeStats} + r.Grant.TermsPolicy, r.Grant.StatsPolicy = cloudwire.StatsPolicy, cloudwire.StatsPolicy + r.Grant.StatsID = make([]byte, 16) + if !statisticsOnly(r) { + t.Fatal("valid local roster refused") + } + r.Noise = make([]byte, 32) + if statisticsAccess(r) { + t.Fatal("statistics-only exposed a Noise key") + } + r.Noise = nil + r.Devices = b.roster().Devices + if statisticsAccess(r) { + t.Fatal("statistics-only exposed phone recipients") + } +} + +func TestActivatingStatisticsRejectsLegacyCloudAndStoredRoster(t *testing.T) { + s := newService(t) + if err := s.reg.StatisticsInit(); err != nil { + t.Fatal(err) + } + b := newBox(t) + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + b.send(cloudwire.MsgRoster, b.roster(protocol.ScopeLiveRead)) + b.send(cloudwire.MsgLive, cloudwire.Live{Header: b.header(), Fields: map[string]int64{"2": 1200}}) + wait(t, "legacy cache", func() bool { return len(snapshot(t, s, b.home)) > 0 }) + // Restart into a fresh in-memory store: the disk roster must not grant + // old Cloud access after the new terms become mandatory. + fresh := New(store.New(nil), s.reg, s.key, s.up.log) + fresh.Statistics = true + n, err := fresh.Restore() + if err != nil || n != 0 { + t.Fatal("legacy roster survived terms activation", n, err) + } + approved := newService(t) + if err := approved.reg.StatisticsInit(); err != nil { + t.Fatal(err) + } + approved.up.Statistics = true + next := newBox(t) + if _, err := next.connect(approved, nil); err != nil { + t.Fatal(err) + } + r := next.roster(protocol.ScopeLiveRead) + next.send(cloudwire.MsgRoster, r) + if !next.closed() { + t.Fatal("legacy Cloud stayed connected without current terms") + } + if len(snapshot(t, approved, next.home)) > 0 { + t.Fatal("legacy cache remained readable") + } +} diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index 4fc6e90..43251f8 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -374,7 +374,7 @@ func (u *Uplink) Restore() (int, error) { defer wg.Done() for h := range work { r, ok := verifiedRoster(h) - if !ok { + if !ok || (u.Statistics && !statisticsAccess(*r)) { bad.Add(1) continue } @@ -890,9 +890,10 @@ func (c *boxConn) roster(env cloudwire.Envelope) error { } g := r.Grant on := len(g.ID) == cloudwire.GrantIDBytes && slices.Contains(g.Purposes, protocol.CloudPurposeServe) + only := statisticsOnly(r) bare := len(g.Scopes) == 0 && len(g.Purposes) == 0 withdrawn := len(g.ID) == cloudwire.GrantIDBytes && bare - if !(on || withdrawn || (len(g.ID) == 0 && bare)) || g.Controller == "" || r.TTLS <= 0 || + if !(on || only || withdrawn || (len(g.ID) == 0 && bare)) || g.Controller == "" || r.TTLS <= 0 || !within(g.Scopes, protocol.CloudScopes) || !within(g.Purposes, protocol.CloudPurposes) { return fmt.Errorf("%w: a roster outside what FTW Cloud may hold", errProtocol) } @@ -909,14 +910,24 @@ func (c *boxConn) roster(env cloudwire.Envelope) error { return fmt.Errorf("%w: a pairing code the service cannot hold", errProtocol) } } - if !c.handle.SetRoster(&r) { - return nil // older than what is held: ignored + if r.RosterSeq < c.rosterSeq() { + return nil } if c.u.Statistics || c.u.StatisticsStore { + // A signed withdrawal must erase rows even when updated terms also + // require this home to leave Cloud. if err := c.u.registry.StatisticsGrant(c.home, g); err != nil { return err } } + if c.u.Statistics && !statisticsAccess(r) { + c.handle.DropCopy() + c.u.store.Forget(c.home) + return fmt.Errorf("%w: updated Cloud terms including statistics are required", errProtocol) + } + if !c.handle.SetRoster(&r) { + return nil + } if withdrawn { if err := c.dropCopy(g.ID); err != nil { return err @@ -981,3 +992,14 @@ func (c *boxConn) purge() error { } return c.send(purged{AtMs: now.UnixMilli(), What: cloudwire.PurgedHome, GrantID: grantID}) } + +// statisticsOnly exposes the signed upload path and no remote recipient. +func statisticsOnly(r cloudwire.Roster) bool { + g := r.Grant + return len(g.ID) == cloudwire.GrantIDBytes && g.Controller == cloudwire.ControllerSourceful && len(g.Scopes) == 0 && len(g.Purposes) == 1 && g.Purposes[0] == protocol.CloudPurposeStats && g.StatsPolicy == cloudwire.StatsPolicy && g.TermsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && len(r.Devices) == 0 && len(r.Pending) == 0 && len(r.Noise) == 0 && len(r.WebPush) == 0 +} + +func statisticsAccess(r cloudwire.Roster) bool { + g := r.Grant + return statisticsOnly(r) || (len(g.ID) == cloudwire.GrantIDBytes && g.Controller == cloudwire.ControllerSourceful && g.TermsPolicy == cloudwire.CloudTermsPolicy && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && g.StatsSinceMs > 0 && slices.Contains(g.Purposes, protocol.CloudPurposeServe) && slices.Contains(g.Purposes, protocol.CloudPurposeStats)) +} diff --git a/privacy/project-statistics-dpia.md b/privacy/project-statistics-dpia.md index 22aabba..23fb0e7 100644 --- a/privacy/project-statistics-dpia.md +++ b/privacy/project-statistics-dpia.md @@ -3,7 +3,7 @@ Datum: 17 oktober 2026. Status: utkast för granskning. Inte signerat. Detta underlag beskriver den avgränsade behandlingen under policyn -`ftw-project-stats-2026-10-v1`. Det ger inget tillstånd att starta insamlingen. +`ftw-project-stats-2026-10-v2`. Det ger inget tillstånd att starta insamlingen. ADR 0008, C8, kräver signerad konsekvensbedömning. Ansvarig företrädare för Sourceful Labs AB ska ta ställning till underlaget, öppna frågor och kvarvarande risker före aktivering. Juridisk granskning och dataskyddsombudets råd, om ett @@ -12,7 +12,7 @@ en bedömning i just detta fall; projektets eget krav gäller tills vidare. ## Ändamål och omfattning -Sourceful vill förstå hur de hem som frivilligt bidrar använder och flyttar +Sourceful vill förstå hur rapporterande hem använder och flyttar energi, förbättra FTW:s planering och publicera gruppsummor på stats.ftw.energy. Ingen reklam, spårning mellan tjänster, individuell profilering, beslut om ett hushåll eller styrning av utrustning ingår i denna behandling. En assistents @@ -22,16 +22,36 @@ Personuppgiftsansvarig: Sourceful Labs AB, org.nr 559382-0458, Kalmar. Kontakt i den befintliga integritetstexten: support@sourceful.energy. Berörda: de hushåll där en behörig ägare väljer att bidra. Andra boende kan beröras av vad energimönstren avslöjar. Partnerstyrda installationer, inklusive -aktiverad Nova, bidrar inte genom denna kodväg. Appen fungerar utan statistik. +aktiverad Nova, bidrar inte genom denna kodväg. Lokal boxdrift fungerar utan statistik; den nya Cloud-modellen kräver bidrag. + +## Produktbeslut den 17 oktober 2026 + +Fredrik har valt två flöden. Statistik ingår för alla som använder FTW Cloud, +inklusive app- och assistentåtkomst. Den har inget eget av-val inom Cloud. +Ägaren behöver läsa och godta nya, tydliga Cloud-villkor; ett tidigare +Cloud-val eller statistikkryss godtar inte den nya omfattningen. Den som inte +vill bidra ska kunna använda boxen lokalt utan Cloud. + +Den som använder FTW lokalt får i stället frågan om att bidra till samma +projektstatistik. Ja är det föreslagna valet, men ingen ny insamling startar +innan ett aktivt svar. Det är inte en förkryssad ruta eller ett samtycke genom +passivitet. Ett nej stänger av bidraget utan att ändra lokal drift. +Statistikläget publicerar inga telefonnycklar, parkopplingskoder, Noise-nycklar, +läsrättigheter eller livevärden och ger ingen fjärråtkomst. + +Detta ersätter det tidigare förslaget om en separat statistikknapp för +Cloud-användare. Kodgrunden med avstängd insamling och publika dagsrapporter +har rullats ut. Version 2 av villkor och lokalfråga behöver fortfarande +färdig kontroll, UI-granskning och beslut innan den aktiveras. ## Uppgifter och flöde | Steg | Uppgifter och hantering | |---|---| -| Lokal box | Läser senaste avslutade UTC-timmen ur energiboken och konfigurationen. Inget tidigare än det nya samtycket laddas upp. Saknade värden utelämnas. | +| Lokal box | Läser senaste avslutade UTC-timmen ur energiboken och konfigurationen. Inget före de nya Cloud-villkoren eller det lokala svaret laddas upp. Saknade värden utelämnas. | | Rapport | Slumpmässigt 128-bitars statistik-id, policyversion, timstart, registrerad nätimport/export, solproduktion, batteriladdning/urladdning, billaddning/urladdning, inställd batterikapacitet, batteri-/laddareffekt och solkapacitet. Avrundning till 10 Wh eller W. Inga namn, adresser, koordinater, enhets-id:n, fordons-id:n, priser, friare text eller loggar. | -| FTW Cloud | Verifierar boxens signatur och aktuellt samtycke. Kopplar raden till hemmets Cloud-id för radering. En rad per hem och timme; omsändning ersätter raden. Stockholm, AWS eu-north-1 enligt driftsbeskrivningen. | -| Lagring | SQLite på Cloud-värdens beständiga disk. Timrader gallras efter 13 månader. Återkallat samtycke och hemradering tar bort opublicerade rader när Cloud tar emot begäran. Offline återkallelse når Cloud vid nästa anslutning. | +| FTW Cloud | Verifierar boxens signatur och aktuell policy och ägarens svar. Kopplar raden till hemmets Cloud-id för radering. En rad per hem och timme; omsändning ersätter raden. Stockholm, AWS eu-north-1 enligt driftsbeskrivningen. | +| Lagring | SQLite på Cloud-värdens beständiga disk. Timrader gallras efter 13 månader. Avslutat Cloud, stoppad lokal delning och hemradering tar bort opublicerade rader när Cloud tar emot begäran. Offline återkallelse når Cloud vid nästa anslutning. | | Publicering | Senaste 24 avslutade timmarna. Kapacitet från respektive hems senaste rapport; energi från dess registrerade timmar. Minst 20 bidragande hem per värde, inget hem över 30 procent av summan. Worker i Cloudflare får bara redan publika gruppvärden och sparar publicerade ögonblicksbilder i 90 dagar. | Raderna i Cloud är personuppgifter, inte anonym data: tjänsten kan knyta dem @@ -47,16 +67,27 @@ säkerhetskopior kan inte påstås vara verifierad av databasens radtest. ## Nödvändighet, proportionalitet och samtycke -Den föreslagna rättsliga grunden är frivilligt, särskilt och informerat samtycke. -Ansvarig och juridisk granskare ska bekräfta grunden, inklusive information till -andra boende och eventuella regler om åtkomst till terminalutrustning. - -Ägaren får svensk eller engelsk information om fälten, ändamålet, Sourcefuls -åtkomst, lagring, gruppregler och radering. Valet är separat och av som standard. -Äldre val och sparade kryssrutor godkänner inte den nya policyn. Appen skickar -policyns exakta id till boxen vid en ny bekräftelse. Boxen sparar id, tid och -vem som gjorde valet. En ny policy ger nytt statistik-id och nytt startdatum. -Avstängning använder samma inställningsyta. Ett byte av hem är inte samtycke. +Rättslig grund för den obligatoriska Cloud-modellen är ännu inte fastställd. +Att skriva statistik i avtalsvillkoren gör den inte i sig nödvändig för att +fullgöra avtalet. IMY skiljer mellan att godta villkor och att lämna samtycke; +samtycke till ett ändamål som inte behövs för tjänsten kan inte utan +vidare göras till ett krav för tjänsten. Tydlig information löser inte den +frågan. Juridisk granskare och ansvarig ska avgöra om modellen har en giltig +grund, pröva nödvändighet och alternativ samt dokumentera beslutet. Om ingen +grund håller måste Cloud-modellen ändras före start. + +För lokala bidrag är informerat, särskilt och frivilligt samtycke den +föreslagna grunden. Boxen frågar först, med Ja som huvudknapp; den skickar +inte före svaret. Nej och senare avstängning påverkar inte lokal styrning. +Även här ska granskningen pröva andra boendes ställning och eventuella +regler om åtkomst till terminalutrustning. + +Informationen ska finnas på svenska och engelska: fält, ändamål, Sourcefuls +åtkomst, lagring, gruppregler och radering. Boxen sparar version, vem som +svarade och tid. Äldre val godtar inte den nya omfattningen. En ny policy ger +nytt statistik-id och nytt startdatum. Timmar före det bidrar inte. Att stänga +av Cloud stoppar fjärråtkomst och begär radering; lokala användare kan +stänga av bara statistiken. Redan publicerade gruppsummor finns kvar. Alternativ som prövats: GitHub-nedladdningar säger inte om något installerats; anonyma dagsrapporter kan uppskatta rapporterande aktiva installationer men @@ -72,8 +103,9 @@ att årsanalysen faktiskt behövs; den publika 24-timmarsvyn behöver i sig inte | Risk för de boende | Skydd som koden ger | Kvarvarande fråga | |---|---|---| -| Närvaro och vanor framgår av timenergi | Frivilligt val, avslutade timmar, få fält, avrundning, inga platsfält | Timenergi kan ändå avslöja vanor. Ansvarig ska bedöma om risken är rimlig och hur andra boende informeras. | -| En ny policy följer med ett gammalt kryss | Nytt policy-id, ingen automatisk återanvändning, box och Cloud kontrollerar policyn | Granska de renderade svenska och engelska skärmarna samt versionsflödet. | +| Ogiltig grund om Cloud villkoras med statistik | Versionerade, synliga villkor och teknisk aktiveringsspärr | Kritisk öppen fråga: villkor är inte i sig samtycke eller bevis på nödvändighet. Ingen aktivering före dokumenterat beslut. | +| Närvaro och vanor framgår av timenergi | Aktivt val av Cloud-villkor eller frivilligt lokalt bidrag, avslutade timmar, få fält, avrundning, inga platsfält | Timenergi kan ändå avslöja vanor. Ansvarig ska bedöma om risken är rimlig och hur andra boende informeras. | +| En ny policy följer med ett äldre val | Nytt policy-id, ingen automatisk återanvändning, box och Cloud kontrollerar policyn | Granska de renderade svenska och engelska skärmarna samt versionsflödet. | | Återidentifiering ur små eller dominerade grupper | Minst 20 hem, högst 30 procent per hem, inga hushållsrader till Worker | Upprepade offentliga summor kan möjliggöra differensanalys. Trösklarna är inte ett bevis på anonymitet. Bedöm större eller fastare grupper före start om risken kvarstår. | | Intrång eller missbruk av värdåtkomst | Signerade rapporter, snäva fält, ingen personalroute, SSH-fri SSM-drift, separata behörigheter för agentnycklar | Full administratör kan läsa data. Verifiera åtkomstlista, loggning, diskkryptering, patchning och incidentväg. | | Felaktiga slutsatser eller dubbla rapporter | Upsert per hem/timme, partnerfilter, okända värden utelämnas, konfigurering märks som sådan | Delvisa timmar och självrapporterad konfiguration bevisar inte fysisk styrning eller full dygnsproduktion. | @@ -93,14 +125,15 @@ bevisar inte alla produktionskontroller eller ett giltigt rättsligt samtycke. Före aktivering ska ansvarig fylla i eller länka bevis för: -1. Granskad svensk och engelsk information och samtyckesskärm. -2. Rättslig grund, information till berörda boende och behovsbedömning. +1. Granskad svensk och engelsk information, Cloud-villkor och separat lokal fråga. +2. Rättslig grund för båda flödena, med uttrycklig bedömning av obligatorisk statistik i Cloud, andra boende och terminalregler. 3. AWS personuppgiftsbiträdesavtal, underbiträden och överföringsbedömning; Cloudflare får bara gruppvärden, vars återidentifieringsrisk ändå ska bedömas. 4. Diskkryptering, operatörsbehörigheter, loggar och säkerhetskopiors gallring. 5. Praktiskt prov på radering och rättighetsbegäran, inklusive en förlorad box och utlämnande/rättelse av lagrade rader. De vägarna är inte verifierade här. -6. Bedömd risk efter skydd, motiverad lagringstid och dokumenterat beslut om +6. Uppdaterade App Store-integritetsuppgifter innan någon ny behandling tas i bruk. +7. Bedömd risk efter skydd, motiverad lagringstid och dokumenterat beslut om riskerna kan godtas. Eventuellt dataskyddsombuds råd och hur de följts. Aktivering görs först efter beslut genom den exakta policyn i @@ -116,6 +149,8 @@ shards, incident eller ny risk; därefter minst årligen. - [IMY: hur en konsekvensbedömning ska genomföras](https://www.imy.se/verksamhet/dataskydd/det-har-galler-enligt-gdpr/konsekvensbedomning/hur-ska-en-konsekvensbedomning-genomforas/). - [IMY: när en konsekvensbedömning behövs](https://www.imy.se/verksamhet/dataskydd/det-har-galler-enligt-gdpr/konsekvensbedomning/nar-ska-en-konsekvensbedomning-genomforas/). +- [IMY: samtycke och separata ändamål](https://www.imy.se/verksamhet/dataskydd/det-har-galler-enligt-gdpr/rattslig-grund/samtycke/). +- [IMY: avtal och onlinetjänster](https://www.imy.se/verksamhet/dataskydd/dataskydd-pa-olika-omraden/foretag/behandling-av-personuppgifter-vid-tillhandahallande-av-onlinetjanster/). - [EDPB: riktlinjer 05/2020 om samtycke](https://www.edpb.europa.eu/sites/default/files/files/file1/edpb_guidelines_202005_consent_en.pdf). Underlaget skiljer tekniskt verifierade egenskaper från beslut som ansvarig From f20de1defa2b7169b83950e4c955d5becf7057fd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:54:00 +0200 Subject: [PATCH 2/6] docs(privacy): link the reviewed terms changes and remaining decisions --- privacy/project-statistics-dpia.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/privacy/project-statistics-dpia.md b/privacy/project-statistics-dpia.md index 23fb0e7..0765768 100644 --- a/privacy/project-statistics-dpia.md +++ b/privacy/project-statistics-dpia.md @@ -117,8 +117,13 @@ risker får inte döljas med en sammanlagd poäng. Bedöm behovet av ytterligare ## Bevis och återstående beslut -Kod: srcfl/ftw#1693, srcfl/ftw-cloud#31, samtyckesändringen i srcfl/ftw-app -samt integritetstexten i srcfl/ftw-web. Relevanta tester täcker fält och +Kodgrund: srcfl/ftw#1693 och srcfl/ftw-cloud#31. +Den nya modellen: [Core #1700](https://github.com/srcfl/ftw/pull/1700), +[Cloud #32](https://github.com/srcfl/ftw-cloud/pull/32), +[iOS #87](https://github.com/srcfl/ftw-app/pull/87) och +[integritetstext #23](https://github.com/srcfl/ftw-web/pull/23). +Ägaren granskade de renderade Core-vyerna den 17 oktober. Det godkänner inte +att timinsamlingen startar. Relevanta tester täcker fält och signering, äldre samtycke, serverns avstängda läge, gruppstorlek, dominans, dubbletter, okända värden, återkallande, radering och gallring. En lokal testsvit bevisar inte alla produktionskontroller eller ett giltigt rättsligt samtycke. From be78015293d4284423a48811ff920887ba42224f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:57:08 +0200 Subject: [PATCH 3/6] build: pin the merged Core statistics terms protocol --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e53e3e3..aeffb7c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261017144639-11756627d4d7 + github.com/srcfl/ftw/go/protocol v0.0.0-20261017145623-2b1cc2563972 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 48d3887..153446d 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017144639-11756627d4d7 h1:nxxjSNZK7JiQXV/jX1XvjENye6GpFUGJry86HGKZWvo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017144639-11756627d4d7/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017145623-2b1cc2563972 h1:+YZbUeomGm9bDdpVICXMJS8AbgaTaeWrRA+QZjcuNAc= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017145623-2b1cc2563972/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= From fb308079b2857629d162ffcb903764acc1b3b165 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:59:27 +0200 Subject: [PATCH 4/6] docs(privacy): record the scoped disk and network review --- privacy/project-statistics-dpia.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/privacy/project-statistics-dpia.md b/privacy/project-statistics-dpia.md index 0765768..3d960e2 100644 --- a/privacy/project-statistics-dpia.md +++ b/privacy/project-statistics-dpia.md @@ -65,6 +65,24 @@ att logga adresser. Infrastrukturens loggar, säkerhetskopior, underbiträden oc överföringar utanför EES behöver verifieras före beslut. Slutlig gallring i säkerhetskopior kan inte påstås vara verifierad av databasens radtest. +## Avgränsad driftkontroll den 17 oktober 2026 + +EC2:s API i eu-north-1 bekräftade att Cloud-värdens aktuella disk är en +20 GiB gp3-volym med EBS-kryptering och en KMS-nyckel. Den sitter på den +befintliga FTW Cloud-värden. Volymen raderas vid avslut av instansen. +Detta visar kryptering i vila, inte vilka personer som får använda nyckeln. + +Värdens säkerhetsgrupp släpper in TCP och UDP på port 443 från IPv4-internet. +Den har ingen regel för SSH eller andra portar. Utgående IPv4-trafik tillåts. +Detta är en kontroll av säkerhetsgruppen, inte en full nät- eller IAM-granskning. +Drift använder SSM; operatörslistor, loggning och nyckelbehörigheter återstår. + +EC2:s snapshotfråga hittade inga snapshots för just denna volym i det aktuella +kontot och regionen. Andra backupvägar, exempelvis S3 eller AWS Backup, +granskas inte av den frågan. Backupens innehåll, återställning, livstid och +radering är därför fortfarande öppna frågor. Kontrollen läste bara +infrastrukturens metadata och ändrade ingen resurs. + ## Nödvändighet, proportionalitet och samtycke Rättslig grund för den obligatoriska Cloud-modellen är ännu inte fastställd. @@ -107,7 +125,7 @@ att årsanalysen faktiskt behövs; den publika 24-timmarsvyn behöver i sig inte | Närvaro och vanor framgår av timenergi | Aktivt val av Cloud-villkor eller frivilligt lokalt bidrag, avslutade timmar, få fält, avrundning, inga platsfält | Timenergi kan ändå avslöja vanor. Ansvarig ska bedöma om risken är rimlig och hur andra boende informeras. | | En ny policy följer med ett äldre val | Nytt policy-id, ingen automatisk återanvändning, box och Cloud kontrollerar policyn | Granska de renderade svenska och engelska skärmarna samt versionsflödet. | | Återidentifiering ur små eller dominerade grupper | Minst 20 hem, högst 30 procent per hem, inga hushållsrader till Worker | Upprepade offentliga summor kan möjliggöra differensanalys. Trösklarna är inte ett bevis på anonymitet. Bedöm större eller fastare grupper före start om risken kvarstår. | -| Intrång eller missbruk av värdåtkomst | Signerade rapporter, snäva fält, ingen personalroute, SSH-fri SSM-drift, separata behörigheter för agentnycklar | Full administratör kan läsa data. Verifiera åtkomstlista, loggning, diskkryptering, patchning och incidentväg. | +| Intrång eller missbruk av värdåtkomst | Signerade rapporter, snäva fält, ingen personalroute, SSH-fri SSM-drift, separata behörigheter för agentnycklar | Full administratör kan läsa data. EBS-kryptering har verifierats. Verifiera åtkomstlista, loggning, patchning och incidentväg. | | Felaktiga slutsatser eller dubbla rapporter | Upsert per hem/timme, partnerfilter, okända värden utelämnas, konfigurering märks som sådan | Delvisa timmar och självrapporterad konfiguration bevisar inte fysisk styrning eller full dygnsproduktion. | | Uppgifter lever kvar efter avstängning | Radering vid mottaget signerat återkallande, även när insamlingen är pausad; gallring efter 13 månader | Säkerhetskopiors livstid och radering efter förlorad box ska dokumenteras och provas. | @@ -134,7 +152,8 @@ Före aktivering ska ansvarig fylla i eller länka bevis för: 2. Rättslig grund för båda flödena, med uttrycklig bedömning av obligatorisk statistik i Cloud, andra boende och terminalregler. 3. AWS personuppgiftsbiträdesavtal, underbiträden och överföringsbedömning; Cloudflare får bara gruppvärden, vars återidentifieringsrisk ändå ska bedömas. -4. Diskkryptering, operatörsbehörigheter, loggar och säkerhetskopiors gallring. +4. Operatörs- och KMS-behörigheter, loggar och säkerhetskopiors gallring. + EBS-kryptering och den aktuella säkerhetsgruppen har kontrollerats ovan. 5. Praktiskt prov på radering och rättighetsbegäran, inklusive en förlorad box och utlämnande/rättelse av lagrade rader. De vägarna är inte verifierade här. 6. Uppdaterade App Store-integritetsuppgifter innan någon ny behandling tas i bruk. From ff2eb5ccea33b72ae49db1d1cfaf3715c0ad0218 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 17:04:33 +0200 Subject: [PATCH 5/6] test(quic): observe cross-path order before asserting datagrams --- internal/server/quic_e2e_test.go | 37 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/server/quic_e2e_test.go b/internal/server/quic_e2e_test.go index 29996c0..fd82d58 100644 --- a/internal/server/quic_e2e_test.go +++ b/internal/server/quic_e2e_test.go @@ -230,10 +230,23 @@ func TestHbAndLiveTravelAsDatagrams(t *testing.T) { t.Fatalf("before the phone spoke, an hb came as %d", hb[0]) } phone.say(cloudwire.MsgWatch, cloudwire.Watch{Hz: cloudwire.HzLooking}) - time.Sleep(50 * time.Millisecond) - box.writeAs(wire.Datagram, mustSeal(t, box, cloudwire.MsgLive, - cloudwire.Live{Header: box.header(), Fields: map[string]int64{"2": 2000}, SrcRev: 1, Ages: map[string]int64{"m": 50}})) - box.writeAs(wire.Datagram, mustSeal(t, box, cloudwire.MsgHB, cloudwire.HB{Header: box.header(), Clock: cloudwire.ClockNTP, SrcRev: 1})) + // Observing a datagram proves the phone's watch was processed. A sleep + // does not order the independent control stream and datagram path. + eventually(t, "an hb datagram after the phone spoke", func() bool { + if slices.Contains(phone.kindsOf(cloudwire.MsgHB), wire.Datagram) { + return true + } + box.writeAs(wire.Datagram, mustSeal(t, box, cloudwire.MsgHB, cloudwire.HB{Header: box.header(), Clock: cloudwire.ClockNTP, SrcRev: 1})) + return false + }) + eventually(t, "a live datagram before the later source table", func() bool { + if len(phone.kindsOf(cloudwire.MsgLive)) > 0 { + return true + } + box.writeAs(wire.Datagram, mustSeal(t, box, cloudwire.MsgLive, + cloudwire.Live{Header: box.header(), Fields: map[string]int64{"2": 2000}, SrcRev: 1, Ages: map[string]int64{"m": 50}})) + return false + }) box.send(cloudwire.MsgLive, full2(box)) eventually(t, "two lives and an hb at the phone", func() bool { return len(phone.kindsOf(cloudwire.MsgLive)) >= 2 && len(phone.kindsOf(cloudwire.MsgHB)) >= 1 @@ -284,10 +297,24 @@ func TestALateTableStillCounts(t *testing.T) { box.roster(2, grantServe, nil, key.Public()) table := map[string]cloudwire.Source{"m": {Kind: "driver", State: protocol.SourceLive, StaleAfterMs: 10_000}} box.send(cloudwire.MsgLive, cloudwire.Live{Header: box.header(), Fields: map[string]int64{"2": 1}, Sources: table, SrcRev: 1, Ages: map[string]int64{}}) + // The grant and first table must arrive before the overtaking datagram. + waitHeldN(t, s, box.home, key, 1) withTable := mustSeal(t, box, cloudwire.MsgLive, cloudwire.Live{Header: box.header(), Fields: map[string]int64{"2": 2}, Sources: table, FieldSources: map[string][]string{"2": {"m"}}, SrcRev: 2, Ages: map[string]int64{}}) box.writeAs(wire.Datagram, mustSeal(t, box, cloudwire.MsgLive, cloudwire.Live{Header: box.header(), Fields: map[string]int64{"2": 3}, SrcRev: 2, Ages: map[string]int64{}})) - time.Sleep(100 * time.Millisecond) + eventually(t, "the overtaking live held before its late table", func() bool { + for _, raw := range heldSnapshot(t, s, box.home, key).Envelopes { + env, _ := cloudwire.DecodeEnvelope(raw) + if env.T != cloudwire.MsgLive { + continue + } + var live cloudwire.Live + if cloudwire.Unmarshal(env.B, &live) == nil && live.Fields["2"] == 3 { + return true + } + } + return false + }) box.writeAs(websocket.BinaryMessage, withTable) box.send(cloudwire.MsgHB, cloudwire.HB{Header: box.header(), Clock: cloudwire.ClockNTP, SrcRev: 2}) waitHeldN(t, s, box.home, key, 3) From 20c4e27b6c31aaf15e2c324bba1ae9bca921fd21 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 17:17:19 +0200 Subject: [PATCH 6/6] fix(statistics): admit reports only from an accepted current roster --- internal/registry/statistics.go | 4 +- internal/registry/statistics_test.go | 2 +- internal/uplink/statistics_test.go | 126 +++++++++++++++++++++++++++ internal/uplink/uplink.go | 39 ++++++--- 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/internal/registry/statistics.go b/internal/registry/statistics.go index dafd98a..2e6bece 100644 --- a/internal/registry/statistics.go +++ b/internal/registry/statistics.go @@ -37,7 +37,9 @@ func (r *Registry) StatisticsGrant(home string, g cloudwire.Grant) error { if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } - on := g.Controller == cloudwire.ControllerSourceful && g.StatsPolicy == cloudwire.StatsPolicy && + terms := (g.TermsPolicy == cloudwire.CloudTermsPolicy && slices.Contains(g.Purposes, protocol.CloudPurposeServe)) || + (g.TermsPolicy == cloudwire.StatsPolicy && len(g.Scopes) == 0 && len(g.Purposes) == 1 && g.Purposes[0] == protocol.CloudPurposeStats) + on := terms && g.Controller == cloudwire.ControllerSourceful && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && g.StatsSinceMs > 0 && slices.Contains(g.Purposes, protocol.CloudPurposeStats) if !on || !bytes.Equal(old, g.StatsID) { if _, err := tx.Exec(`DELETE FROM statistics_hours WHERE home=?`, home); err != nil { diff --git a/internal/registry/statistics_test.go b/internal/registry/statistics_test.go index 3620aae..2efe525 100644 --- a/internal/registry/statistics_test.go +++ b/internal/registry/statistics_test.go @@ -18,7 +18,7 @@ func statsFixture(t *testing.T) (*Registry, time.Time, cloudwire.Grant) { t.Fatal(err) } now := time.Date(2026, 10, 17, 12, 30, 0, 0, time.UTC) - g := cloudwire.Grant{Controller: cloudwire.ControllerSourceful, Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, StatsID: bytes.Repeat([]byte{1}, 16), StatsPolicy: cloudwire.StatsPolicy, StatsSinceMs: now.Add(-25 * time.Hour).UnixMilli()} + g := cloudwire.Grant{TermsPolicy: cloudwire.CloudTermsPolicy, Controller: cloudwire.ControllerSourceful, Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, StatsID: bytes.Repeat([]byte{1}, 16), StatsPolicy: cloudwire.StatsPolicy, StatsSinceMs: now.Add(-25 * time.Hour).UnixMilli()} return r, now, g } func statsHome(t *testing.T, r *Registry, now time.Time, g cloudwire.Grant, home string, value int64) cloudwire.Stats { diff --git a/internal/uplink/statistics_test.go b/internal/uplink/statistics_test.go index 79ddb85..725a6e7 100644 --- a/internal/uplink/statistics_test.go +++ b/internal/uplink/statistics_test.go @@ -145,9 +145,15 @@ func TestStatisticsOnlyRejectsRemoteMetadata(t *testing.T) { r.Grant.Purposes = []string{protocol.CloudPurposeStats} r.Grant.TermsPolicy, r.Grant.StatsPolicy = cloudwire.StatsPolicy, cloudwire.StatsPolicy r.Grant.StatsID = make([]byte, 16) + r.Grant.StatsSinceMs = time.Now().UnixMilli() if !statisticsOnly(r) { t.Fatal("valid local roster refused") } + r.Grant.StatsSinceMs = 0 + if statisticsAccess(r) { + t.Fatal("local roster accepted without a start timestamp") + } + r.Grant.StatsSinceMs = time.Now().UnixMilli() r.Noise = make([]byte, 32) if statisticsAccess(r) { t.Fatal("statistics-only exposed a Noise key") @@ -197,3 +203,123 @@ func TestActivatingStatisticsRejectsLegacyCloudAndStoredRoster(t *testing.T) { t.Fatal("legacy cache remained readable") } } + +func acceptedStatisticsRoster(b *box, now time.Time) cloudwire.Roster { + r := b.roster(protocol.ScopeLiveRead) + r.Grant.Purposes = append(r.Grant.Purposes, protocol.CloudPurposeStats) + r.Grant.TermsPolicy, r.Grant.StatsPolicy = cloudwire.CloudTermsPolicy, cloudwire.StatsPolicy + r.Grant.StatsID = bytes.Repeat([]byte{8}, 16) + r.Grant.StatsSinceMs = now.Add(-2 * time.Hour).UnixMilli() + return r +} +func statisticsReport(b *box, r cloudwire.Roster, now time.Time) cloudwire.Stats { + return cloudwire.Stats{Header: b.header(), StatsID: r.Grant.StatsID, Policy: cloudwire.StatsPolicy, HourMS: now.Truncate(time.Hour).Add(-time.Hour).UnixMilli(), Metrics: map[string]int64{"pv_generation_wh": 120}} +} + +func TestRejectedRosterCreatesNoStatisticsGrant(t *testing.T) { + s := newService(t) + if err := s.reg.StatisticsInit(); err != nil { + t.Fatal(err) + } + s.up.Statistics = true + now := time.Now() + b := newBox(t) + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + r := acceptedStatisticsRoster(b, now) + r.Grant.TermsPolicy = "old-terms" + b.send(cloudwire.MsgRoster, r) + if !b.closed() { + t.Fatal("rejected terms did not close") + } + // Even a direct registry write must find no grant left by rejection. + if err := s.reg.StatisticsPut(b.home, statisticsReport(b, r, now), now); err != nil { + t.Fatal(err) + } + v, err := s.reg.StatisticsView(now) + if err != nil || v.Installations.State != "empty" { + t.Fatal("rejected roster authorised reports", v, err) + } +} + +func TestStatisticsRequireAnAcceptedRosterOnEveryConnection(t *testing.T) { + s := newService(t) + if err := s.reg.StatisticsInit(); err != nil { + t.Fatal(err) + } + s.up.Statistics = true + now := time.Now() + b := newBox(t) + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + r := acceptedStatisticsRoster(b, now) + b.send(cloudwire.MsgRoster, r) + b.send(cloudwire.MsgStats, statisticsReport(b, r, now)) + wait(t, "initial report", func() bool { v, _ := s.reg.StatisticsView(now); return v.Installations.State == "withheld" }) + // Retain the valid grant while clearing fixture hours, as retention does. + if err := s.reg.StatisticsPrune(now.AddDate(0, 14, 0)); err != nil { + t.Fatal(err) + } + _ = b.conn.Close() + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + b.send(cloudwire.MsgStats, statisticsReport(b, r, now)) + r.Header = b.header() + r.RosterSeq = 2 + b.send(cloudwire.MsgRoster, r) + wait(t, "new connection roster", func() bool { h, ok, _ := s.reg.Get(b.home); return ok && h.RosterSeq == 2 }) + v, err := s.reg.StatisticsView(now) + if err != nil || v.Installations.State != "empty" { + t.Fatal("report before roster was stored", v, err) + } + b.send(cloudwire.MsgStats, statisticsReport(b, r, now)) + wait(t, "report after roster", func() bool { v, _ := s.reg.StatisticsView(now); return v.Installations.State == "withheld" }) +} + +func TestApprovedCollectionAcknowledgesAndPersistsWithdrawal(t *testing.T) { + s := newService(t) + if err := s.reg.StatisticsInit(); err != nil { + t.Fatal(err) + } + s.up.Statistics = true + now := time.Now() + b := newBox(t) + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + r := acceptedStatisticsRoster(b, now) + b.send(cloudwire.MsgRoster, r) + b.send(cloudwire.MsgStats, statisticsReport(b, r, now)) + wait(t, "report before withdrawal", func() bool { v, _ := s.reg.StatisticsView(now); return v.Installations.State == "withheld" }) + r.Header = b.header() + r.RosterSeq = 2 + r.Grant = cloudwire.Grant{ID: r.Grant.ID, Controller: cloudwire.ControllerSourceful} + b.send(cloudwire.MsgRoster, r) + env := b.read() + var receipt cloudwire.Purged + if env.T != cloudwire.MsgPurged || cloudwire.Unmarshal(env.B, &receipt) != nil || receipt.What != cloudwire.PurgedCopy { + t.Fatal("withdrawal had no deletion receipt", env.T) + } + if !b.closed() { + t.Fatal("withdrawn Cloud retained access") + } + h, ok, err := s.reg.Get(b.home) + if err != nil || !ok || h.RosterSeq != 2 { + t.Fatal("withdrawal not persisted", ok, err) + } + saved, err := cloudwire.DecodeEnvelope(h.Roster) + if err != nil { + t.Fatal(err) + } + var withdrawn cloudwire.Roster + if cloudwire.Unmarshal(saved.B, &withdrawn) != nil || len(withdrawn.Grant.Purposes) != 0 { + t.Fatal("old grant persisted") + } + v, err := s.reg.StatisticsView(now) + if err != nil || v.Installations.State != "empty" { + t.Fatal("withdrawn rows retained", v, err) + } +} diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index 43251f8..1298057 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -445,8 +445,9 @@ type boxConn struct { // connection. Over QUIC an hb or a live may come as a datagram, out of // order; one older than what the service holds is dropped. tableRev is // the srcRev of the newest table held from this connection. - newest map[string]uint64 - tableRev uint64 + newest map[string]uint64 + tableRev uint64 + statsAllowed bool // current connection accepted the full statistics roster } // bucket is a token bucket. @@ -760,7 +761,7 @@ func (c *boxConn) apply(env cloudwire.Envelope) (stop bool, err error) { case cloudwire.MsgRoster: return false, c.roster(env) case cloudwire.MsgStats: - if !c.u.Statistics { + if !c.u.Statistics || !c.statsAllowed { return false, nil } var report cloudwire.Stats @@ -913,14 +914,12 @@ func (c *boxConn) roster(env cloudwire.Envelope) error { if r.RosterSeq < c.rosterSeq() { return nil } - if c.u.Statistics || c.u.StatisticsStore { - // A signed withdrawal must erase rows even when updated terms also - // require this home to leave Cloud. - if err := c.u.registry.StatisticsGrant(c.home, g); err != nil { + allowed := statisticsAccess(r) + c.statsAllowed = false + if c.u.Statistics && !allowed && !withdrawn { + if err := c.u.registry.StatisticsGrant(c.home, cloudwire.Grant{}); err != nil { return err } - } - if c.u.Statistics && !statisticsAccess(r) { c.handle.DropCopy() c.u.store.Forget(c.home) return fmt.Errorf("%w: updated Cloud terms including statistics are required", errProtocol) @@ -928,6 +927,15 @@ func (c *boxConn) roster(env cloudwire.Envelope) error { if !c.handle.SetRoster(&r) { return nil } + if c.u.Statistics || c.u.StatisticsStore { + grant := g + if !allowed { + grant = cloudwire.Grant{} + } + if err := c.u.registry.StatisticsGrant(c.home, grant); err != nil { + return err + } + } if withdrawn { if err := c.dropCopy(g.ID); err != nil { return err @@ -949,7 +957,16 @@ func (c *boxConn) roster(env cloudwire.Envelope) error { for _, d := range r.Devices { epochs[d.ID] = d.Epoch } - return c.u.registry.PruneSuspensions(c.home, epochs) + if err := c.u.registry.PruneSuspensions(c.home, epochs); err != nil { + return err + } + c.statsAllowed = allowed + if c.u.Statistics && withdrawn { + // Persist and acknowledge the withdrawal before ending remote access. + c.u.store.Forget(c.home) + return fmt.Errorf("%w: Cloud ended after withdrawal", errProtocol) + } + return nil } // dropCopy deletes what the service holds under a grant its owner withdrew, @@ -996,7 +1013,7 @@ func (c *boxConn) purge() error { // statisticsOnly exposes the signed upload path and no remote recipient. func statisticsOnly(r cloudwire.Roster) bool { g := r.Grant - return len(g.ID) == cloudwire.GrantIDBytes && g.Controller == cloudwire.ControllerSourceful && len(g.Scopes) == 0 && len(g.Purposes) == 1 && g.Purposes[0] == protocol.CloudPurposeStats && g.StatsPolicy == cloudwire.StatsPolicy && g.TermsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && len(r.Devices) == 0 && len(r.Pending) == 0 && len(r.Noise) == 0 && len(r.WebPush) == 0 + return len(g.ID) == cloudwire.GrantIDBytes && g.Controller == cloudwire.ControllerSourceful && len(g.Scopes) == 0 && len(g.Purposes) == 1 && g.Purposes[0] == protocol.CloudPurposeStats && g.StatsPolicy == cloudwire.StatsPolicy && g.TermsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && g.StatsSinceMs > 0 && len(r.Devices) == 0 && len(r.Pending) == 0 && len(r.Noise) == 0 && len(r.WebPush) == 0 } func statisticsAccess(r cloudwire.Roster) bool {