diff --git a/.changeset/cloud-statistics-terms.md b/.changeset/cloud-statistics-terms.md new file mode 100644 index 000000000..6b09b3ca0 --- /dev/null +++ b/.changeset/cloud-statistics-terms.md @@ -0,0 +1,9 @@ +--- +"ftw": minor +--- + +Include project statistics in the updated FTW Cloud terms. Local-only users +get a separate question with Yes suggested; nothing starts before an answer. +Statistics-only sharing grants no app or assistant remote access. Turning +Cloud off ends remote access and remembers that choice across restarts. +Hourly collection still waits for the signed C8 review and service approval. diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index 5ea2c17a8..81ecdb1dc 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -154,33 +154,46 @@ cases. ## Statistics -**The start flow has two switches.** The first turns FTW Cloud on; the iPhone -app needs it, and it lists what leaves the house. The second turns statistics -on; it is off by default, and the app works the same either way. A change to -purpose or fields needs a new policy version and fresh consent. +**Product amendment, 17 October 2026.** Everyone who chooses FTW Cloud +contributes to project statistics. The updated Cloud terms name this scope; +there is no separate statistics opt-out within Cloud. Existing users must +accept the new terms before the approved policy can admit their home. +Owners who decline use the box locally without Cloud. + +Local-only users get a separate question, with Yes as the suggested action. +Nothing starts before an active answer. No leaves local control working. +This statistics-only link publishes no phone keys, pairing codes, Noise key, +read grant, live state or remote access. It reuses the signed upload path. +A policy or purpose change requires a new version and a fresh answer. +This product decision does not establish a lawful basis; C8 stays off until +that question and the DPIA have been reviewed and signed. - **Purpose.** To learn how homes with FTW use and shift energy, to improve FTW's planning and defaults, and to publish figures about homes as a group. Not for advertising, decisions about one home, or handing rows to anyone. -- **Fields.** Per closed hour, the site's energy totals in Wh, rounded to - 10 Wh, under the ledger's flow names; the price zone; which of battery, - solar and charger the site has; the policy version. Nothing finer than an - hour, and no names, addresses, IP addresses, serials, coordinates, VINs, - config or logs. +- **Fields.** Recorded closed-hour grid import/export, solar generation, + battery and vehicle charge/discharge; configured battery capacity, + battery/charger power limits and solar rating. Rounded to 10 Wh or W. + Missing values stay absent. No names, addresses, location, device ids, + prices, free text, configuration documents or logs. - **`statsId`.** 128 random bits drawn by the box at consent, derived from nothing else. A new consent draws a new one. -- **Retention.** Rows go 13 months after the hour they describe. Withdrawal or - a purge deletes them within 24 hours. Published aggregates stay, and the +- **Retention.** Rows go 13 months after the hour they describe. Leaving Cloud, stopping local sharing or + a purge deletes unpublished rows when Cloud receives the signed request. + An offline box sends it on reconnection. Published aggregates stay, and the consent screen says so. -- **Cell rules.** At least 20 homes per figure, nothing finer than a price - zone or an hour, and no home above 30 % of a cell's sum. Code applies the - rules; no person reads raw rows. +- **Cell rules.** At least 20 homes per figure, no per-home output, and no + home above 30 % of a cell's sum. Code applies the rules. No staff route + reads raw rows; full host administrators can read the database. - **Partners.** Homes under a partner's control stay out unless that partner's contract allows them in. Nova keeps B2B partner telemetry. -- **Law.** The service links `statsId` to the home so a purge can reach it, - which makes the rows personal data (CJEU C-413/23 P). Serving the owner's phones - is necessary for the service asked for and meets the exemption in LEK - 9 kap. 28 §; statistics are not, so they need consent. A lawyer confirms. +- **Law.** The service links `statsId` to the home for deletion, so these + rows are personal data. The required Cloud model needs its own documented + lawful-basis assessment. Writing it in terms does not establish contractual + necessity or make conditional consent freely given. Local sharing proposes + informed, separate consent. A legal review must settle both paths, other + household members and terminal-access rules before activation. The review + draft lives in `ftw-cloud/privacy/project-statistics-dpia.md`. ## Security and retention @@ -262,7 +275,9 @@ later step stands on it and ships on its own. | C7 Live Activity | Signed state in the extension view | A forged state shows "Open FTW" | | C8 Statistics | Ingest, backfill, cell rules | DPIA signed; withdrawal purges within 24 hours | -The statistics switch ships with the start flow in C4. The box records consent +The original separate Cloud statistics switch is replaced by the 17 October +product amendment above. The Cloud terms and the local question ship with the +start flow. The box records consent and `statsId` from then on. The October project policy below needs new consent and sends only later hours; it does not backfill the earlier gap. The lawyer also confirms that order: if the DPIA in C8 changes the rules, the app asks again. A public launch needs C2–C4 and the legal work. @@ -296,13 +311,26 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work. ### Project statistics policy, October 2026 -`ftw-project-stats-2026-10-v1` adds configured battery capacity, flexible -power and solar ratings to the closed-hour energy scope. This needs fresh -consent; an earlier statistics choice sends none of these fields. The owner -can read the policy through `GET /api/cloud/stats-policy`, then accept its -exact id through the existing owner-only statistics API. Stats stay off by -default. Changing the policy rotates the random stats id and starts a new -consent window. Hours before that window never contribute. +`ftw-project-stats-2026-10-v2` covers configured capacities, flexible power, +solar rating and closed-hour energy. `ftw-cloud-2026-10-v2` includes that scope +for every Cloud home. An owner reads `GET /api/cloud/stats-policy`, then +accepts the Cloud terms through `POST /api/cloud/grant`. Core records the +scope, random stats id and start time in the same grant. An older Cloud +answer or saved statistics checkbox does not accept this version. + +A local owner can instead accept the statistics policy through +`POST /api/cloud/stats`, without granting readable state or remote access. +`DELETE /api/cloud/stats` stops that local contribution. Within Cloud, +statistics cannot be turned off alone; `DELETE /api/cloud` ends Cloud, +requests home deletion and remembers the off choice across restarts. +Phones can need pairing again after the home key is deleted. An app user +who declines its terms pauses Cloud on that phone and keeps its local keys. + +The daily anonymous report also waits for the current project choice. +Its saved config switch cannot bypass a missing answer or a local No. +A new policy rotates the stats id and starts a new reporting window. An +unset wall clock starts that window only once time is known; no earlier +hours are filled in. The public view counts installations reporting in the last 24 closed hours. It sums the recorded hours, which may be incomplete; it does not claim all diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 27403fcec..50aa500f1 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -731,6 +731,7 @@ every call. | `purposes` | [text] | A subset of `cloud_purposes`: `serve` while the copy is on, and `stats` while the owner contributes to statistics. Empty while the copy is off or being withdrawn | | `statsId` | bstr, 16 B, optional | Random id for the separately accepted statistics policy; omitted when off | | `statsPolicy` | text, optional | Exact policy the owner accepted | +| `termsPolicy` | text, optional | Cloud terms accepted by the owner; the current Cloud policy includes statistics | | `statsSinceMs` | uint, optional | Consent start in UTC milliseconds; only later closed hours may contribute | | `controller` | text | The data controller: `sourceful` unless a partner pairing flow sets another. No such flow exists yet | @@ -1627,3 +1628,19 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand. | Statistics rows kept | 13 months from the hour they describe | | Rule N clock lead allowed | 2 s | | Rule U drift margin, lifetime | 0.01 % of the time since learned; 24 hours | + + +### Cloud terms and local statistics, policy version 2 + +`ftw-cloud-2026-10-v2` includes `ftw-project-stats-2026-10-v2` for every Cloud +home. `Grant.termsPolicy` carries the owner-accepted terms. Once the operator +approves collection, Cloud admits only that bundled grant or a statistics-only +roster. Old terms are not silently upgraded; they need an owner answer. +Restoring a roster after restart applies the same rule. + +A statistics-only roster names the statistics policy as its terms policy, +contains the stats purpose alone and has no read scopes, devices, pending +codes, Noise key or browser subscriptions. It grants no remote access. Core +also rejects tunnels locally in that mode and emits no live or event data. +Collection stays off until the signed C8 review; the default operator setting +is empty. Withdrawal and retention continue while it is paused. diff --git a/go/cmd/ftw/fleet_ping.go b/go/cmd/ftw/fleet_ping.go index 99ed92a70..592ebed7e 100644 --- a/go/cmd/ftw/fleet_ping.go +++ b/go/cmd/ftw/fleet_ping.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/fleetping" @@ -76,7 +77,7 @@ func startFleetPing( installedAt := firstSeen(st, time.Now()) pinger, err := fleetping.New(fleetping.Options{ - Enabled: fleetPingEnabled(cfg, cfgMu), + Enabled: func() bool { return fleetPingEnabled(cfg, cfgMu)() && cloudlink.StatisticsChoice(st) }, Facts: func() fleetping.Facts { // Drivers can be installed without restarting FTW. Read the signed // catalogue at the same time as the config so tomorrow's report and diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 7378aa964..6b9bb28a6 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -29,10 +29,12 @@ type CloudGrant interface { Revoke(by string) error Stats(cloudlink.StatsRequest) error StopStats(by string) error + End(by string) error } func (s *Server) cloudRoutes() { s.handle("GET /api/cloud", Read, s.handleCloudStatus) + s.handle("DELETE /api/cloud", Configure, s.handleCloudEnd, NoStepUp) s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview) s.handle("GET /api/cloud/stats-policy", Read, s.handleCloudStatsPolicy) s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp) @@ -204,6 +206,10 @@ func (s *Server) handleCloudStopStats(w http.ResponseWriter, r *http.Request) { return } if err := link.StopStats(cloudCaller(r)); err != nil { + if errors.Is(err, cloudlink.ErrCloudNeedsStatistics) { + writeAppLinkError(w, http.StatusConflict, err.Error()) + return + } writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics off") return } @@ -228,3 +234,18 @@ func (s *Server) CloudLoadpoints(now time.Time) (states []loadpoint.State, feedb s.decorateLoadpointsWithPlan(states) return states, s.controlFeedback(now), true } + +func (s *Server) handleCloudEnd(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + if err := link.End(cloudCaller(r)); err != nil { + writeAppLinkError(w, http.StatusInternalServerError, "could not stop FTW Cloud") + return + } + writeJSON(w, http.StatusOK, link.Status()) +} diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go index 5f149a640..969be28e2 100644 --- a/go/internal/api/api_cloud_test.go +++ b/go/internal/api/api_cloud_test.go @@ -34,6 +34,8 @@ func (c *stubCloud) Grant(req cloudlink.GrantRequest) error { c.granted = append(c.granted, req) return c.err } +func (c *stubCloud) End(by string) error { return c.Revoke(by) } + func (c *stubCloud) Revoke(by string) error { c.revoked = append(c.revoked, by) return nil @@ -281,6 +283,7 @@ func TestAViewerReachesNoCloudRoute(t *testing.T) { {http.MethodGet, "/api/cloud/preview", "", s.handleCloudPreview}, {http.MethodPost, "/api/cloud/grant", grantBody, s.handleCloudGrant}, {http.MethodDelete, "/api/cloud/grant", "", s.handleCloudRevoke}, + {http.MethodDelete, "/api/cloud", "", s.handleCloudEnd}, {http.MethodPost, "/api/cloud/stats", statsBody, s.handleCloudStats}, {http.MethodDelete, "/api/cloud/stats", "", s.handleCloudStopStats}, } { @@ -386,3 +389,15 @@ func (k *cloudKV) SaveConfig(key, value string) error { k.m[key] = value return nil } + +func TestWholeCloudOffUsesTheExistingNarrowingAccessRule(t *testing.T) { + for _, req := range []*http.Request{sessionRequest(http.MethodDelete, "/api/cloud", ""), localRequest(http.MethodDelete, "/api/cloud", "")} { + c := &stubCloud{} + s := New(&Deps{Cloud: c}) + w := httptest.NewRecorder() + s.handleCloudEnd(w, req) + if w.Code != http.StatusOK || len(c.revoked) != 1 { + t.Fatal("whole Cloud off failed", w.Code) + } + } +} diff --git a/go/internal/api/cloud_statistics.go b/go/internal/api/cloud_statistics.go index fdf8c2246..97712bad2 100644 --- a/go/internal/api/cloud_statistics.go +++ b/go/internal/api/cloud_statistics.go @@ -7,11 +7,12 @@ import ( "time" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) -// CloudStatistics contains only the fields named by the separate statistics -// consent. Configuration and ledger ids never cross this boundary. +// CloudStatistics contains only the fields named by the versioned project +// statistics terms. Configuration and ledger ids never cross this boundary. func (s *Server) CloudStatistics(ctx context.Context, now time.Time) (cloudwire.Stats, bool) { hour := now.UTC().Truncate(time.Hour).Add(-time.Hour) out := cloudwire.Stats{HourMS: hour.UnixMilli(), Metrics: map[string]int64{}} @@ -128,13 +129,17 @@ func (s *Server) handleCloudStatsPolicy(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{ "available": available, "policy": cloudwire.StatsPolicy, + "cloud_scopes": protocol.CloudScopes, + "cloud_policy": cloudwire.CloudTermsPolicy, + "cloud_includes_statistics": true, + "standalone": true, "purpose": "Publish group totals for FTW use and energy; improve planning. No ads or household decisions.", "fields": cloudwire.StatsMetricKeys, - "meaning": "One opted-in installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.", + "meaning": "One reporting installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.", "resolution": "Closed UTC hours, energy rounded to 10 Wh, capacities and limits rounded to 10 Wh or W. No names, addresses, device ids or location.", - "retention": "13 months. Switching statistics off deletes unpublished household rows; published group totals remain.", + "retention": "13 months. Leaving Cloud or stopping local sharing deletes unpublished household rows; published group totals remain.", "publication_minimum_homes": cloudwire.StatsMinimumHomes, "publication_max_home_share": 0.3, - "controller": "sourceful", "default": "off", "optional": true, + "controller": "sourceful", "local_default": "ask_yes", "local_optional": true, "cloud_required": true, }) } diff --git a/go/internal/cloudlink/choice.go b/go/internal/cloudlink/choice.go new file mode 100644 index 000000000..c5c1bf0ae --- /dev/null +++ b/go/internal/cloudlink/choice.go @@ -0,0 +1,125 @@ +package cloudlink + +import ( + "errors" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +var ErrCloudNeedsStatistics = errors.New("cloudlink: statistics are part of FTW Cloud; turn FTW Cloud off to stop contributing") + +// includeStatistics records the exact scope the owner read. Service approval +// still gates every report. The caller holds mu. +func (l *Link) includeStatistics(g *grantRecord, fresh bool) error { + if fresh || g.StatsPolicy != cloudwire.StatsPolicy || len(g.StatsID) != 16 { + id, err := randomBytes(statsIDBytes) + if err != nil { + return err + } + g.StatsID, g.StatsAtMs = id, 0 + if l.clockSynced() { + g.StatsAtMs = l.opts.Now().UnixMilli() + } + } + g.StatsPolicy = cloudwire.StatsPolicy + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, true) + return nil +} + +func (l *Link) enableLinkChoice() error { + if err := l.opts.KV.SaveConfig(disabledKey, "false"); err != nil { + return err + } + l.disabled = false + return nil +} + +// startStatisticsOnly reuses the signed uplink, without publishing any phone, +// pairing code, Noise key, live reading or grant for remote access. +func (l *Link) startStatisticsOnly(req StatsRequest) error { + if err := l.linkLocked(); err != nil { + return err + } + if l.link.Ending { + return ErrLinkEnding + } + id, err := randomBytes(cloudwire.GrantIDBytes) + if err != nil { + return err + } + key, err := randomBytes(cloudwire.GrantKeyBytes) + if err != nil { + return err + } + g := grantRecord{ID: id, GrantKey: key, KeyFp: l.link.KeyFp, Policy: req.Policy, By: req.By, Controller: cloudwire.ControllerSourceful, Scopes: []string{}, Purposes: []string{protocol.CloudPurposeStats}} + if l.clockSynced() { + g.AtMs = l.opts.Now().UnixMilli() + } + if err := l.includeStatistics(&g, true); err != nil { + return err + } + if err := l.enableLinkChoice(); err != nil { + return err + } + return l.storeLocked(g, "local statistics accepted") +} + +// End stops all Cloud functions, purges the home and remembers the choice so +// a paired phone or a restart cannot silently turn the link back on. +func (l *Link) End(by string) error { + l.mu.Lock() + defer l.mu.Unlock() + if err := l.opts.KV.SaveConfig(disabledKey, "true"); err != nil { + return err + } + l.disabled = true + if err := l.opts.KV.SaveConfig(statsDecisionKey, ""); err != nil { + return err + } + if l.link == nil { + return nil + } + rec := *l.link + rec.Ending = true + if err := saveJSON(l.opts.KV, linkKey, rec); err != nil { + return err + } + l.link = &rec + if l.grant != nil { + g := *l.grant + g.Revoked, g.By = true, by + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + return err + } + l.grant = &g + } + l.rosterDue = true + l.signal() + return nil +} + +// StatisticsChoice is the saved answer for both project reports. The daily +// report must not turn itself on before the owner answers the new question. +func StatisticsChoice(kv KV) bool { + if kv == nil { + return false + } + if disabled, _ := kv.LoadConfig(disabledKey); disabled == "true" { + return false + } + g, err := loadJSON[grantRecord](kv, grantKey) + return err == nil && g != nil && !g.Revoked && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 +} + +// An unset clock cannot place a choice in UTC. Start its upload window only +// once the clock is known, never by filling earlier hours after NTP arrives. +func (l *Link) startStatisticsClock() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.disabled || l.grant == nil || l.grant.Revoked || l.grant.StatsPolicy != cloudwire.StatsPolicy || l.grant.StatsAtMs != 0 || !l.clockSynced() { + return nil + } + g := *l.grant + g.StatsAtMs = l.opts.Now().UnixMilli() + return l.storeLocked(g, "statistics start clock set") +} diff --git a/go/internal/cloudlink/choice_test.go b/go/internal/cloudlink/choice_test.go new file mode 100644 index 000000000..e87fb5089 --- /dev/null +++ b/go/internal/cloudlink/choice_test.go @@ -0,0 +1,138 @@ +package cloudlink + +import ( + "errors" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" + "log/slog" + "testing" + "time" +) + +func TestCloudTermsIncludeStatisticsAndCannotOptOutSeparately(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: cloudwire.CloudTermsPolicy, By: "owner"}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if r.Grant.TermsPolicy != cloudwire.CloudTermsPolicy || r.Grant.StatsPolicy != cloudwire.StatsPolicy || len(r.Grant.StatsID) != 16 || r.Grant.StatsSinceMs <= 0 { + t.Fatal("Cloud did not include the accepted statistics scope") + } + if err := h.link.StopStats("owner"); !errors.Is(err, ErrCloudNeedsStatistics) { + t.Fatal("Cloud could opt out independently", err) + } + if !h.link.Status().CloudTermsAccepted || !h.link.Status().StatisticsActive { + t.Fatal("new terms not active") + } +} + +func TestLocalStatisticsPublishesNoRemoteAccess(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy, By: "local owner"}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if len(r.Devices) != 0 || len(r.Pending) != 0 || len(r.Noise) != 0 || len(r.WebPush) != 0 || len(r.Grant.Scopes) != 0 || len(r.Grant.Purposes) != 1 || r.Grant.Purposes[0] != protocol.CloudPurposeStats { + t.Fatal("statistics-only exposed Cloud access", r) + } + if h.link.TakesTun() || h.link.Home() != "" || h.link.SessionInfo() != nil || h.link.current().copyOn() { + t.Fatal("local statistics enabled Cloud functions") + } + if err := h.link.SendTun(cloudwire.Tun{}, false); !errors.Is(err, ErrNoUplink) { + t.Fatal("statistics-only queued a tunnel", err) + } + if !h.link.Status().StatisticsOnly || h.link.Status().State != "statistics" { + t.Fatal("local statistics labelled Cloud") + } +} + +func TestDecliningLocalStatisticsRecordsTheAnswerWithoutCreatingALink(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if !h.link.Status().StatisticsDecisionRequired { + t.Fatal("an unanswered choice looked accepted") + } + if err := h.link.StopStats("local owner"); err != nil { + t.Fatal(err) + } + s := h.link.Status() + if s.StatisticsDecisionRequired || s.Link != "off" || s.StatisticsActive { + t.Fatal("decline did not stay local", s) + } +} + +func TestEndingCloudPreventsAutomaticRelinkFromPairedPhones(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + h.nextRoster(t) + if err := h.link.End("owner"); err != nil { + t.Fatal(err) + } + if h.link.TakesTun() || h.link.wantsLink() { + t.Fatal("disabled Cloud still wanted remote access") + } + h.link.RosterChanged() + disabled, ok := h.link.opts.KV.LoadConfig(disabledKey) + if !ok || disabled != "true" { + t.Fatal("off choice was not durable") + } +} + +func TestDailyReportRequiresCurrentAnswerAndHonoursDurableOff(t *testing.T) { + kv := &memKV{} + if StatisticsChoice(kv) { + t.Fatal("unanswered box may send") + } + g := grantRecord{StatsPolicy: "ftw-project-stats-2026-10-v1", StatsID: make([]byte, 16)} + if err := saveJSON(kv, grantKey, g); err != nil { + t.Fatal(err) + } + if StatisticsChoice(kv) { + t.Fatal("old choice may send new report") + } + g.StatsPolicy = cloudwire.StatsPolicy + if err := saveJSON(kv, grantKey, g); err != nil { + t.Fatal(err) + } + if !StatisticsChoice(kv) { + t.Fatal("current answer may not send") + } + // This flag is saved first, even if writing a later purge record fails. + if err := kv.SaveConfig(disabledKey, "true"); err != nil { + t.Fatal(err) + } + if StatisticsChoice(kv) { + t.Fatal("durable Cloud off did not stop daily report") + } +} + +func TestStatisticsClockStartsAfterNTPWithoutBackfill(t *testing.T) { + now := time.Date(2026, 10, 17, 13, 45, 0, 0, time.UTC) + synced := false + kv := &memKV{} + g := grantRecord{StatsPolicy: cloudwire.StatsPolicy, StatsID: make([]byte, 16)} + l := &Link{log: slog.Default(), opts: Options{KV: kv, Now: func() time.Time { return now }, ClockSynced: func() bool { return synced }}, grant: &g} + if err := l.startStatisticsClock(); err != nil { + t.Fatal(err) + } + if l.grant.StatsAtMs != 0 { + t.Fatal("an unknown clock started collection") + } + synced = true + if err := l.startStatisticsClock(); err != nil { + t.Fatal(err) + } + if l.grant.StatsAtMs != now.UnixMilli() { + t.Fatal("clock backfilled the earlier choice") + } + now = now.Add(time.Hour) + if err := l.startStatisticsClock(); err != nil { + t.Fatal(err) + } + if l.grant.StatsAtMs != now.Add(-time.Hour).UnixMilli() { + t.Fatal("clock moved on reconnect") + } +} diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 66bc2e0e2..c31f29d82 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -438,7 +438,7 @@ func (l *Link) read(conn uplinkConn, snap grantSnapshot, events chan<- cloudwire if len(env.Sig) != 0 || cloudwire.Unmarshal(env.B, &t) != nil || len(t.D) > cloudwire.TunDataMax { return fmt.Errorf("%w: a tun the box cannot read", errService) } - if l.opts.Tunnel != nil { + if l.TakesTun() { l.opts.Tunnel.Deliver(t) } case cloudwire.MsgWatch, cloudwire.MsgResync: @@ -523,7 +523,7 @@ func (l *Link) SendTun(t cloudwire.Tun, bulk bool) error { q = l.tunBulk } switch { - case q == nil: + case q == nil || l.disabled || l.grant.statisticsOnly(): l.tunDropped++ return ErrNoUplink case err != nil || l.tunBudget.total(l.uptimeMs()) >= tunBudgetBytes: diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go index aa7c742e1..30f2a722c 100644 --- a/go/internal/cloudlink/events.go +++ b/go/internal/cloudlink/events.go @@ -250,6 +250,9 @@ func (l *Link) Notice(kind, client string) bool { // sendNotices sends what waits, oldest first. func (w *writer) sendNotices(snap grantSnapshot) error { + if snap.g.statisticsOnly() { + return nil + } l := w.link l.mu.Lock() queued := l.notices diff --git a/go/internal/cloudlink/grant.go b/go/internal/cloudlink/grant.go index b0f300c0d..625ad0c8b 100644 --- a/go/internal/cloudlink/grant.go +++ b/go/internal/cloudlink/grant.go @@ -22,10 +22,12 @@ import ( // document, so a whole-document config write cannot reset them. A missing // record reads as off. const ( - linkKey = "cloud/link_v1" - grantKey = "cloud/grant_v1" - rosterKey = "cloud/roster_v1" - purgedKey = "cloud/purged_v1" + linkKey = "cloud/link_v1" + grantKey = "cloud/grant_v1" + rosterKey = "cloud/roster_v1" + purgedKey = "cloud/purged_v1" + disabledKey = "cloud/disabled_v1" + statsDecisionKey = "cloud/statistics_decision_v1" ) // linkRecord is the box's link to FTW Cloud: which cloud.key it holds. The diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 759d0a12a..35623bf7b 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -248,6 +248,7 @@ type Link struct { home string link *linkRecord grant *grantRecord + disabled bool notice string connected bool serviceStatsPolicy string @@ -392,6 +393,8 @@ func (l *Link) load() error { l.mu.Lock() defer l.mu.Unlock() l.link, l.grant, l.key, l.pk, l.home, l.notice = nil, nil, nil, nil, "", "" + disabled, _ := l.opts.KV.LoadConfig(disabledKey) + l.disabled = disabled == "true" if r, err := loadJSON[receipt](l.opts.KV, purgedKey); err == nil && r != nil { l.purgedAt = r.AtMs } @@ -439,6 +442,12 @@ func (l *Link) load() error { l.log.Warn("FTW Cloud is off: cloud.key is not the key the link was made with") return nil } + if l.disabled { + link.Ending = true + if g != nil { + g.Revoked = true + } + } l.link, l.key, l.pk, l.home = link, key, pk, cloudwire.HomeID(pk) switch { case g == nil: @@ -484,6 +493,12 @@ func (l *Link) RosterChanged() { // page. A box that takes no tun cannot serve an agent, so its rows want no // link. A grant needs the link too, and makes it itself. func (l *Link) wantsLink() bool { + l.mu.Lock() + blocked := l.disabled || (l.grant != nil && l.grant.statisticsOnly()) + l.mu.Unlock() + if blocked { + return false + } if _, _, ok := l.opts.Roster.Pending(); ok { return true } @@ -593,7 +608,19 @@ func (l *Link) Grant(req GrantRequest) error { return ErrLinkEnding case l.grant != nil: g := *l.grant + fresh := g.Policy != req.Policy g.Scopes, g.Policy, g.By = scopes, req.Policy, req.By + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeServe, true) + if req.Policy == cloudwire.CloudTermsPolicy { + if err := l.includeStatistics(&g, fresh); err != nil { + l.mu.Unlock() + return err + } + } + if err := l.enableLinkChoice(); err != nil { + l.mu.Unlock() + return err + } if err := saveJSON(l.opts.KV, grantKey, g); err != nil { l.mu.Unlock() return err @@ -627,6 +654,17 @@ func (l *Link) Grant(req GrantRequest) error { if l.clockSynced() { g.AtMs = time.Now().UnixMilli() } + if req.Policy == cloudwire.CloudTermsPolicy { + if err := l.includeStatistics(&g, true); err != nil { + l.mu.Unlock() + return err + } + } + if err := l.enableLinkChoice(); err != nil { + l.mu.Unlock() + return err + } + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { l.mu.Unlock() return err @@ -646,6 +684,12 @@ func (l *Link) Grant(req GrantRequest) error { // record stays until the service's signed receipt arrives. The link, the // home id and the tunnel stay. func (l *Link) Revoke(by string) error { + l.mu.Lock() + whole := l.grant != nil && (l.grant.Policy == cloudwire.CloudTermsPolicy || l.grant.statisticsOnly()) + l.mu.Unlock() + if whole { + return l.End(by) + } l.mu.Lock() if l.grant == nil || l.grant.Revoked { l.mu.Unlock() @@ -692,7 +736,10 @@ func (l *Link) Stats(req StatsRequest) error { defer l.mu.Unlock() switch { case l.grant == nil: - return ErrNotGranted + if req.Policy != cloudwire.StatsPolicy { + return ErrNotGranted + } + return l.startStatisticsOnly(req) case l.grant.Revoked: return ErrPurgePending } @@ -718,8 +765,24 @@ func (l *Link) Stats(req StatsRequest) error { // StopStats removes the stats purpose and forgets the statsId. Turning it on // again draws a new one. func (l *Link) StopStats(by string) error { + l.mu.Lock() + if l.grant != nil && !l.grant.Revoked && l.grant.Policy == cloudwire.CloudTermsPolicy { + l.mu.Unlock() + return ErrCloudNeedsStatistics + } + only := l.grant != nil && l.grant.statisticsOnly() + l.mu.Unlock() + if only { + if err := l.End(by); err != nil { + return err + } + return l.opts.KV.SaveConfig(statsDecisionKey, cloudwire.StatsPolicy) + } l.mu.Lock() defer l.mu.Unlock() + if err := l.opts.KV.SaveConfig(statsDecisionKey, cloudwire.StatsPolicy); err != nil { + return err + } if l.grant == nil || l.grant.Revoked || (len(l.grant.StatsID) == 0 && !slices.Contains(l.grant.Purposes, protocol.CloudPurposeStats)) { return nil @@ -820,6 +883,10 @@ func (l *Link) clockSynced() bool { // Status is what GET /api/cloud shows. type Status struct { + CloudPolicy string `json:"cloudPolicy"` + CloudTermsAccepted bool `json:"cloudTermsAccepted"` + StatisticsOnly bool `json:"statisticsOnly"` + StatisticsDecisionRequired bool `json:"statisticsDecisionRequired"` // State is the readable copy's: off, on or revoking. State string `json:"state"` StatisticsPolicy string `json:"statisticsPolicy"` @@ -877,6 +944,14 @@ func (l *Link) Status() Status { Link: "off", TunBytesLast24h: l.tunBudget.total(l.uptimeMs()), TunBudgetBytes: tunBudgetBytes, TunDropped: l.tunDropped, } + s.CloudPolicy = cloudwire.CloudTermsPolicy + decision, _ := l.opts.KV.LoadConfig(statsDecisionKey) + s.StatisticsDecisionRequired = decision != cloudwire.StatsPolicy + if l.grant != nil && !l.grant.Revoked && l.grant.StatsPolicy == cloudwire.StatsPolicy && len(l.grant.StatsID) == 16 { + s.StatisticsDecisionRequired = false + } + s.StatisticsOnly = l.grant.statisticsOnly() + s.CloudTermsAccepted = l.grant != nil && !l.grant.Revoked && l.grant.Policy == cloudwire.CloudTermsPolicy s.StatisticsPolicy = cloudwire.StatsPolicy s.StatisticsReady = l.connected && l.serviceStatsPolicy == cloudwire.StatsPolicy if g := l.grant; g != nil { @@ -895,6 +970,9 @@ func (l *Link) Status() Status { if g := l.grant; g != nil { s.State, s.Scopes, s.Purposes = "on", g.Scopes, g.Purposes s.GrantedAtMs, s.GrantedBy, s.Policy = g.AtMs, g.By, g.Policy + if g.statisticsOnly() { + s.State = "statistics" + } if g.Revoked { s.State = "revoking" } @@ -907,7 +985,7 @@ func (l *Link) Status() Status { func (l *Link) Home() string { l.mu.Lock() defer l.mu.Unlock() - if l.link == nil || l.link.Ending { + if l.link == nil || l.link.Ending || l.disabled || l.grant.statisticsOnly() { return "" } return l.home @@ -919,7 +997,11 @@ func (l *Link) Available() bool { return len(l.opts.ServiceKeys) > 0 } // TakesTun reports whether the link carries the phones' own sessions as tun, // so a phone that knows the home id can reach the box without the relay. -func (l *Link) TakesTun() bool { return l.Available() && l.opts.Tunnel != nil } +func (l *Link) TakesTun() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.Available() && l.opts.Tunnel != nil && !l.disabled && !l.grant.statisticsOnly() +} // SessionInfo is what a phone needs, over its Noise session with the box, to // reach this home through FTW Cloud: the home id, the key that signs the @@ -930,6 +1012,9 @@ func (l *Link) TakesTun() bool { return l.Available() && l.opts.Tunnel != nil } // The tables name every driver and loadpoint the box knows now. One that // appears later has no name on a phone until its next session. func (l *Link) SessionInfo() *protocol.CloudSession { + if l.Home() == "" { + return nil + } snap := l.current() if snap.link == nil || snap.link.Ending || len(snap.pk) != cloudwire.PublicKeyBytes { return nil @@ -1000,11 +1085,16 @@ func (l *Link) current() grantSnapshot { } // copyOn reports whether the readable copy is on and not being withdrawn. -func (snap grantSnapshot) copyOn() bool { return snap.g != nil && !snap.g.Revoked } +func (g *grantRecord) statisticsOnly() bool { + return g != nil && !g.Revoked && slices.Contains(g.Purposes, protocol.CloudPurposeStats) && !slices.Contains(g.Purposes, protocol.CloudPurposeServe) +} +func (snap grantSnapshot) copyOn() bool { + return snap.g != nil && !snap.g.Revoked && slices.Contains(snap.g.Purposes, protocol.CloudPurposeServe) +} // grantID is the grant hello names: the readable copy's, or none. func (snap grantSnapshot) grantID() []byte { - if !snap.copyOn() { + if snap.g == nil || snap.g.Revoked { return nil } return snap.g.ID diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index e4124bf86..93bc57789 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -121,6 +121,9 @@ func (w *writer) run(ctx context.Context, events <-chan cloudwire.Envelope) erro var slot <-chan time.Time for { + if err := l.startStatisticsClock(); err != nil { + return err + } snap := l.current() if snap.link == nil || snap.home != connHome { return errors.New("cloudlink: the link changed under the connection") @@ -692,34 +695,36 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { case g.Revoked: r.Grant.ID = g.ID default: - r.Grant = cloudwire.Grant{ID: g.ID, Scopes: g.Scopes, Purposes: g.Purposes, Controller: g.Controller, StatsID: g.StatsID, StatsPolicy: g.StatsPolicy, StatsSinceMs: g.StatsAtMs} - } - for _, d := range l.opts.Roster.Devices() { - var scopes []string - for _, s := range protocol.CloudScopes { - if slices.Contains(d.Scopes, s) { - scopes = append(scopes, s) + r.Grant = cloudwire.Grant{ID: g.ID, Scopes: g.Scopes, Purposes: g.Purposes, Controller: g.Controller, TermsPolicy: g.Policy, StatsID: g.StatsID, StatsPolicy: g.StatsPolicy, StatsSinceMs: g.StatsAtMs} + } + if !snap.g.statisticsOnly() { + for _, d := range l.opts.Roster.Devices() { + var scopes []string + for _, s := range protocol.CloudScopes { + if slices.Contains(d.Scopes, s) { + scopes = append(scopes, s) + } + } + r.Devices = append(r.Devices, cloudwire.Device{ID: d.ID, Key: d.Key, Role: d.Role, Scopes: scopes, + Epoch: d.Epoch, Tun: d.Tun, Agent: d.Agent}) + } + sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) + r.Noise = l.opts.Roster.Noise() + if l.opts.WebPush != nil { + var err error + r.WebPush, err = l.opts.WebPush.WebPushRows() + if err != nil { + l.log.Warn("could not prepare browser offline notices") + } + sort.Slice(r.WebPush, func(a, b int) bool { return r.WebPush[a].ID < r.WebPush[b].ID }) + } + if code, expires, ok := l.opts.Roster.Pending(); ok && snap.home != "" { + if left := time.Until(expires); left > 0 { + r.Pending = append(r.Pending, cloudwire.Pending{ + Hash: cloudwire.PairingHash(snap.home, code), + TTLS: int64((left + time.Second - 1) / time.Second), + }) } - } - r.Devices = append(r.Devices, cloudwire.Device{ID: d.ID, Key: d.Key, Role: d.Role, Scopes: scopes, - Epoch: d.Epoch, Tun: d.Tun, Agent: d.Agent}) - } - sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) - r.Noise = l.opts.Roster.Noise() - if l.opts.WebPush != nil { - var err error - r.WebPush, err = l.opts.WebPush.WebPushRows() - if err != nil { - l.log.Warn("could not prepare browser offline notices") - } - sort.Slice(r.WebPush, func(a, b int) bool { return r.WebPush[a].ID < r.WebPush[b].ID }) - } - if code, expires, ok := l.opts.Roster.Pending(); ok && snap.home != "" { - if left := time.Until(expires); left > 0 { - r.Pending = append(r.Pending, cloudwire.Pending{ - Hash: cloudwire.PairingHash(snap.home, code), - TTLS: int64((left + time.Second - 1) / time.Second), - }) } } // The number follows what the roster says, not how long a code has @@ -828,7 +833,7 @@ func (l *Link) Preview() Preview { func (l *Link) sample() []sentFrame { snap := l.current() if snap.g == nil { - snap.g = &grantRecord{GrantKey: make([]byte, cloudwire.GrantKeyBytes), Scopes: protocol.CloudScopes} + snap.g = &grantRecord{GrantKey: make([]byte, cloudwire.GrantKeyBytes), Scopes: protocol.CloudScopes, Purposes: []string{protocol.CloudPurposeServe}} } up := l.uptimeMs() var out []sentFrame diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 3474f8f05..66befad80 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -48,7 +48,7 @@ var Fields = map[string][]string{ "Agent": {"scopes", "ops", "caps", "expiresAtMs", "principal"}, "Caps": {"holdS", "maxW", "perMin"}, "Principal": {"client", "clientId", "ticket"}, - "Grant": {"id", "scopes", "purposes", "controller", "statsId", "statsPolicy", "statsSinceMs"}, + "Grant": {"id", "scopes", "purposes", "controller", "termsPolicy", "statsId", "statsPolicy", "statsSinceMs"}, "Pending": {"hash", "ttlS"}, "WebPushRow": {"id", "dev", "epoch", "endpoint", "ct", "auth"}, diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 196b1bc8f..80f403ba0 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -394,6 +394,7 @@ type Pending struct { // id, scopes or purposes. While a revoke waits for its receipt it keeps the // id and has neither scopes nor purposes. type Grant struct { + TermsPolicy string `cbor:"termsPolicy,omitempty"` ID []byte `cbor:"id"` Scopes []string `cbor:"scopes"` Purposes []string `cbor:"purposes"` diff --git a/go/protocol/cloudwire/stats.go b/go/protocol/cloudwire/stats.go index 651d09d4d..03daebf56 100644 --- a/go/protocol/cloudwire/stats.go +++ b/go/protocol/cloudwire/stats.go @@ -2,9 +2,11 @@ package cloudwire import "slices" -// StatsPolicy requires fresh consent: the earlier hourly-energy policy did -// not include configured equipment capacity or power limits. -const StatsPolicy = "ftw-project-stats-2026-10-v1" +// StatsPolicy names the scope included in the current Cloud terms, or +// accepted through the separate local statistics question. Earlier choices +// never accept this policy. Collection still requires service approval. +const StatsPolicy = "ftw-project-stats-2026-10-v2" +const CloudTermsPolicy = "ftw-cloud-2026-10-v2" const MsgStats = "stats" const StatsHourMS = int64(3_600_000) const StatsMinimumHomes = 20 diff --git a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json index ce4e763bc..6ede8d105 100644 --- a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json @@ -250,17 +250,22 @@ }, { "name": "statistics service ready", - "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", + "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763261746868656c6c6f5f6f6b637369675840a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e", "datagram": false }, { "name": "statistics closed hour", - "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", + "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758406147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6", + "datagram": false + }, + { + "name": "statistics only roster", + "envelope": "a36162590133aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107617466726f7374657263736967584018851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7", "datagram": false }, { "name": "roster statistics consent", - "envelope": "a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", + "envelope": "a36162590130aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7", "datagram": false } ], diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index 7b6930d34..e74fdcae9 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -326,31 +326,41 @@ "name": "statistics service ready", "signer": "service", "t": "hello_ok", - "body": "a563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d7631", - "signedInputSha256": "6777900dc405724488293496cd480b243af7dd7852170a1db355d136e4269ab8", - "sig": "629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", - "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", - "frame": "0101000000e3a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33" + "body": "a563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d7632", + "signedInputSha256": "03f2f4413566430718560c06ef743aba96dd6e268f4df9e5fcd6105505729284", + "sig": "a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e", + "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763261746868656c6c6f5f6f6b637369675840a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e", + "frame": "0101000000e3a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763261746868656c6c6f5f6f6b637369675840a838159a22c63e291c08d701b0ce0f7c0fd155c45fdcfa9b50e1253bf2fa312917ecd76379072c9bbaf3d62d1bbf95b93879177765f4b0adb73dd7ef0e2dd91e" }, { "name": "statistics closed hour", "signer": "box", "t": "stats", - "body": "a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a00014438", - "signedInputSha256": "0cd18bce4afac18cf16427e52e734fb06151f01adfedec4f58cae328a31e7127", - "sig": "3363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", - "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", - "frame": "01010000010ba3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec" + "body": "a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a00014438", + "signedInputSha256": "401bd24650ca3cb49fa76096e3b40d310c6e31d3952bf312e005b88937f81643", + "sig": "6147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6", + "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758406147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6", + "frame": "01010000010ba3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758406147f18ed9f5927d3abf1599168322a021194369ad42d8b199abbd894040cc7e87c4fcff89e551c210863d2ed4f2bdeaddd6304f5fdf00188a5232b3246c23d6" + }, + { + "name": "statistics only roster", + "signer": "box", + "t": "roster", + "body": "aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107", + "signedInputSha256": "35b78f290505d453192680ada46310c9f2119b4f7dcdb3ca7d65d540fcc77075", + "sig": "18851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7", + "envelope": "a36162590133aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107617466726f7374657263736967584018851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7", + "frame": "010100000188a36162590133aa637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573816573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d805906764657669636573806770656e64696e678068757074696d654d731a00014ff069726f7374657253657107617466726f7374657263736967584018851c1cb544043d4e18b2609e5b2a3f6c321bd1ba04a136b2af103992876c90bc33c8d584840917a2d7a1fb0427b22df6deae7fad456c664e8e33f95654b4f7" }, { "name": "roster statistics consent", "signer": "box", "t": "roster", - "body": "aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106", - "signedInputSha256": "cb06814081a6ab56c1265fde264a4240e12fb3233cc410673a3399b0afdca884", - "sig": "479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", - "envelope": "a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", - "frame": "010100000164a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c" + "body": "aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106", + "signedInputSha256": "af8b572e3c64efa52a4c52514e9e1d31c3906f19376ab5ddc9c8c61c6c5c0312", + "sig": "a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7", + "envelope": "a36162590130aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7", + "frame": "010100000185a36162590130aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a862696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76326b7465726d73506f6c696379746674772d636c6f75642d323032362d31302d76326c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840a043311a41972afcec17e4aa0eae3251e985b503677b2802e5b520cd73acd472050cfe6f660ae85058d0dea532ff832759929c048f3738e4588794447552ded7" } ] } diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index 67ffdd57b..56876c0e2 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -266,7 +266,8 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { {"roster browser notice", "box", MsgRoster, Roster{Header: h(19, 82_000), RosterSeq: 5, TTLS: 86400, Devices: []Device{{ID: "browser", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{protocol.ScopeEventsRead}, Epoch: 1}}, WebPush: []WebPushRow{{ID: "opaque-subscription", Device: "browser", Epoch: 1, Endpoint: "https://push.example/opaque", CT: bytes.Repeat([]byte{0x66}, 128), Auth: "vapid box-signed"}}}}, {"statistics service ready", "service", MsgHelloOK, HelloOK{BoxNonce: bytes.Repeat([]byte{0x33}, NonceBytes), EKM: bytes.Repeat([]byte{0x22}, EKMBytes), Have: map[string]string{}, RosterSeq: 4, StatsPolicy: StatsPolicy}}, {"statistics closed hour", "box", MsgStats, Stats{Header: h(20, 83_000), StatsID: bytes.Repeat([]byte{0x88}, 16), Policy: StatsPolicy, HourMS: wall/StatsHourMS*StatsHourMS - StatsHourMS, Metrics: map[string]int64{"flexible_power_w": 11000, "pv_generation_wh": 120}}}, - {"roster statistics consent", "box", MsgRoster, Roster{Header: h(21, 84_000), RosterSeq: 6, TTLS: 86400, Grant: Grant{ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, Controller: ControllerSourceful, StatsID: bytes.Repeat([]byte{0x88}, 16), StatsPolicy: StatsPolicy, StatsSinceMs: wall - 2*StatsHourMS}}}, + {"statistics only roster", "box", MsgRoster, Roster{Header: h(23, 86_000), RosterSeq: 7, TTLS: 86400, Grant: Grant{ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), Scopes: []string{}, Purposes: []string{protocol.CloudPurposeStats}, Controller: ControllerSourceful, TermsPolicy: StatsPolicy, StatsID: bytes.Repeat([]byte{0x88}, 16), StatsPolicy: StatsPolicy, StatsSinceMs: wall - 2*StatsHourMS}}}, + {"roster statistics consent", "box", MsgRoster, Roster{Header: h(21, 84_000), RosterSeq: 6, TTLS: 86400, Grant: Grant{TermsPolicy: CloudTermsPolicy, ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, Controller: ControllerSourceful, StatsID: bytes.Repeat([]byte{0x88}, 16), StatsPolicy: StatsPolicy, StatsSinceMs: wall - 2*StatsHourMS}}}, } } diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs index 8207506ac..86085d255 100644 --- a/web/app-link-tab.test.mjs +++ b/web/app-link-tab.test.mjs @@ -96,7 +96,7 @@ describe("the app tab", () => { const config = { app_link: { enabled: false } }; const html = appWithFleet.render({ config }); - assert.match(html, /