diff --git a/.changeset/cloud-project-statistics.md b/.changeset/cloud-project-statistics.md new file mode 100644 index 000000000..0f3f7a964 --- /dev/null +++ b/.changeset/cloud-project-statistics.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +Add a separate, versioned Cloud statistics path for reporting installations, +configured capacity and recorded hourly energy. Older consents stay inactive +for these fields; the owner must accept the new policy. diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index 104f17f14..5ea2c17a8 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -263,8 +263,8 @@ later step stands on it and ships on its own. | 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 -and `statsId` from then on, nothing is sent until C8, and the backfill covers -the gap. The lawyer also confirms that order: if the DPIA in C8 changes the +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. ## Alternatives considered @@ -293,3 +293,31 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work. | Claims or defaults drift back | Claim tests; a missing grant reads as off | | A paid app meets App Store purchase rules | Decide the payment path before charging | | AGPL code in the App Store | An App Store exception in `ftw-app` before outside code arrives | + +### 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. + +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 +FTW users, full-day production or measured control capacity. Configured +limits remain distinct from measured command results. Unknown values stay +absent. Cloud keeps the consented hourly rows for 13 months and publishes +only the existing group thresholds. Withdrawal deletes unpublished rows +when Cloud receives the signed roster; offline withdrawal takes effect on +reconnect. Published group totals remain. Native consent UI must adopt the +new policy before it can enable this scope. + +C8 stays off until its signed DPIA is recorded. The service starts with no +approved statistics policy. Only an explicit operator setting enables the +exact policy in signed `hello_ok`; Core sends no hours without that signal, +even if an owner previously chose to contribute. The owner policy response +reports this readiness so native clients can explain the wait. Pausing the +service policy stops collection while withdrawal and retention still run. +Deploying this code does not sign the DPIA or accept a household's consent. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index bdf7f7519..27403fcec 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -362,6 +362,7 @@ with two different values, so neither side's check passes through it. | `ekm` | bstr, 32 B | The service's exporter value for this TLS session | | `have` | map text→text | Tile id to etag, for the tiles the service holds | | `rosterSeq` | uint | The roster number the service holds | + | `statsPolicy` | text, optional | The statistics policy this service is approved to ingest. Missing means no statistics; the box sends none | `caps` and `grantId` describe the connection as it starts. The readable copy can turn on or off while the link stays up, so the service takes @@ -728,6 +729,9 @@ every call. | `id` | bstr, 16 B or empty | Grant id. Empty while the readable copy is off | | `scopes` | [text] | A subset of `cloud_scopes`. Empty while the copy is off or being withdrawn | | `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 | +| `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 | A grant has three shapes. With an id and `serve`, the readable copy is on. @@ -745,26 +749,32 @@ each time it receives such a roster. With no id, there is no copy. Only a QR code is listed. A spoken code has 40 bits, and its hash would let the service find it offline; it pairs over the relay or the home network. -**`stats`**, only while `grant.purposes` contains `stats`. +**`stats`** + +Signed box to service only. Never forwarded to phones or agents. The box +sends at most one report per closed UTC hour, after fresh consent to +`ftw-project-stats-2026-10-v1`. Older policies send nothing. | Field | Type | Meaning | |---|---|---| -| `statsId` | bstr, 16 B | Random, drawn by the box at consent | -| `policy` | text | Version of the consent text the owner accepted | -| `zone` | text | Price zone | -| `has` | [text] | Which of `battery`, `pv` and `ev_charger` the site has | -| `hours` | [Hour] | At most 168 per message | - -**Hour.** `startMs`, then Wh totals for the site, rounded to 10 Wh, under the -energy ledger's flow names: `grid_import`, `grid_export`, `pv_generation`, -`battery_charge`, `battery_discharge`, `consumer_use`, `vehicle_charge` and -`vehicle_discharge`. A flow the site lacks is absent. - -The service refuses `stats` for a `statsId` it has deleted, so an old grant -record cannot turn a withdrawn consent back on. - -**`stats.withdraw`** `{statsId}`. The service deletes that id's rows within -24 hours. +| `statsId` | bstr, 16 B | Must match the current signed grant | +| `policy` | text | Exact statistics policy version | +| `hourMs` | uint | Closed UTC hour start, no older than 24 hours and not before consent | +| `metrics` | map(text, uint) | Allowlisted values, rounded to 10; missing means unknown | + +Keys: `battery_capacity_wh`, `flexible_power_w`, `pv_rated_w`, +`grid_import_wh`, `grid_export_wh`, `pv_generation_wh`, `battery_charge_wh`, +`battery_discharge_wh`, `vehicle_charge_wh`, `vehicle_discharge_wh`. Each +value is at most 1,000,000. Flexible power sums configured battery and charger +limits, with shared inverter groups counted once; it does not prove physical +control. Energy is recorded ledger energy and may cover only part of an hour. +No names, hardware ids, location, prices or live readings leave this path. + +The service upserts by home and hour, keeps these consented rows for 13 months, +and deletes them when it receives statistics withdrawal or a home purge. +Only cells with at least 20 homes and no home above 30 percent of the sum +are public. The public view counts reporting installations, never people. +Partner-controlled sites do not send. **`notice`**, whenever the box is linked, with the readable copy on or off. @@ -1394,6 +1404,7 @@ consent text. | Route | Who may call it | |---|---| | `GET /api/cloud` | An owner's session, or a client on the LAN. State, scopes, purposes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached | +| `GET /api/cloud/stats-policy` | The same. Exact fields, purpose and group rules for a fresh consent | | `GET /api/cloud/preview` | The same. The exact bytes that leave the house | | `POST /api/cloud/grant {scopes, policy}` | An owner's session. On the LAN, only from loopback or with the house password. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` | | `POST /api/cloud/stats {policy}` | The same gate, as a separate consent. 409 while the grant is off or a revoke waits for its receipt | @@ -1408,11 +1419,11 @@ app's, so the grant screen claims no more than an owner's session. or off, push nothing. Every change writes an audit line on the box. A grant, a change of scopes and a statistics change each send a new roster. - `POST /api/cloud/stats` draws a `statsId` the first time and keeps it while - statistics stay on. Turning statistics on again after a withdrawal draws a - new one. + statistics stay on under the same policy. A new policy draws a new id. + Turning statistics on again after a withdrawal draws a new one. - `DELETE /api/cloud/stats` removes `stats` from the purposes at once. The - box keeps the `statsId` until `purged {what: "stats"}` arrives, so that - `stats.withdraw` can name it. + box forgets its stats id and sends a roster without statistics metadata. + Cloud deletes the rows when it receives that signed roster. ## Revocation and deletion @@ -1432,9 +1443,9 @@ app's, so the grant screen claims no more than an owner's session. `cloud.key` and sends only `hello` and `bye` on each connect. With a valid `purged` it deletes the key. A box of an earlier release still ends its grant this way. -- **Statistics withdrawal:** `stats.withdraw`. The service deletes the rows - within 24 hours and sends `purged {what: "stats"}` when done, on the same or - a later connection. +- **Statistics withdrawal:** a signed roster without the statistics consent + deletes the home's unpublished rows before accepting another report. If + the box is offline, this happens on reconnect. - **Silence:** after an hour without the box's socket, the service serves only `lastSeenMs` and no readings. When the roster runs out, it closes sessions and refuses reads. After 30 days it drops tokens and APNs registrations. diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 948c2648f..0a45f4d26 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -63,6 +63,13 @@ type plugSession struct { n int } +func (c *cloudSite) Statistics(ctx context.Context, now time.Time) (cloudwire.Stats, bool) { + if srv := c.api.srv.Load(); srv != nil { + return srv.CloudStatistics(ctx, now) + } + return cloudwire.Stats{}, false +} + func (c *cloudSite) Live() cloudlink.Live { snap := c.site.Snapshot() uptime := time.Since(c.site.started).Milliseconds() diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 39a5d21ae..7378aa964 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -34,6 +34,7 @@ type CloudGrant interface { func (s *Server) cloudRoutes() { s.handle("GET /api/cloud", Read, s.handleCloudStatus) 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) s.handle("DELETE /api/cloud/grant", Configure, s.handleCloudRevoke, NoStepUp) s.handle("POST /api/cloud/stats", Configure, s.handleCloudStats, NoStepUp) diff --git a/go/internal/api/cloud_statistics.go b/go/internal/api/cloud_statistics.go new file mode 100644 index 000000000..fdf8c2246 --- /dev/null +++ b/go/internal/api/cloud_statistics.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "math" + "net/http" + "time" + + "github.com/srcfl/ftw/go/internal/state" + "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. +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{}} + if s.deps == nil || s.deps.Cfg == nil || s.deps.CfgMu == nil { + return out, false + } + s.deps.CfgMu.RLock() + cfg := s.deps.Cfg + // A partner-controlled site's contract must allow statistics first. + if cfg.Nova != nil && cfg.Nova.Enabled { + s.deps.CfgMu.RUnlock() + return out, false + } + put := func(key string, value float64) { + if !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 && value <= 1_000_000 { + out.Metrics[key] = int64(math.Round(value/10)) * 10 + } + } + batteryWh, flexibleW, powerKnown := 0.0, 0.0, true + groups := map[string]float64{} + drivers := map[string]bool{} + for _, d := range cfg.Drivers { + if d.Disabled { + continue + } + drivers[d.Name] = !d.ObserveOnly && !d.BatteryTelemetryOnly + if d.BatteryCapacityWh > 0 { + batteryWh += d.BatteryCapacityWh + if !drivers[d.Name] { + continue + } + cap := math.Max(d.MaxChargeW, d.MaxDischargeW) + if cap <= 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { + powerKnown = false + continue + } + group := d.InverterGroup + if group == "" { + group = d.Name + } + groups[group] = math.Max(groups[group], cap) + } + } + for _, cap := range groups { + flexibleW += cap + } + chargers := map[string]float64{} + for _, lp := range cfg.Loadpoints { + if !drivers[lp.DriverName] { + continue + } + if lp.MaxChargeW <= 0 || math.IsNaN(lp.MaxChargeW) || math.IsInf(lp.MaxChargeW, 0) { + powerKnown = false + continue + } + chargers[lp.DriverName] = math.Max(chargers[lp.DriverName], lp.MaxChargeW) + } + for _, cap := range chargers { + flexibleW += cap + } + put("battery_capacity_wh", batteryWh) + if powerKnown { + put("flexible_power_w", flexibleW) + } + if cfg.Weather != nil { + rated := cfg.Weather.PVRatedW + if len(cfg.Weather.PVArrays) > 0 { + rated = 0 + for _, array := range cfg.Weather.PVArrays { + rated += array.RatedWatts() + } + } + if rated > 0 { + put("pv_rated_w", rated) + } + } + s.deps.CfgMu.RUnlock() + if s.deps.State == nil { + return out, true + } + points, truncated, err := s.deps.State.LoadEnergyHistoryContext(ctx, state.EnergyHistoryQuery{ + SinceMS: hour.UnixMilli(), UntilMS: hour.Add(time.Hour).UnixMilli(), BucketMS: cloudwire.StatsHourMS, Limit: 1, + }) + if err != nil || truncated { + return out, true + } + totals := map[string]float64{} + for _, point := range points { + if point.BucketStartMS != hour.UnixMilli() || point.BucketLenMS != cloudwire.StatsHourMS || + (point.Quality != "measured" && point.Quality != "estimated" && point.Quality != "recovered") { + continue + } + key := string(point.Flow) + "_wh" + for _, allowed := range cloudwire.StatsMetricKeys { + if key == allowed { + if point.EnergyWh >= 0 && !math.IsNaN(point.EnergyWh) && !math.IsInf(point.EnergyWh, 0) { + totals[key] += point.EnergyWh + } + } + } + } + for key, value := range totals { + put(key, value) + } + return out, true +} + +// The owner reads this before POST /api/cloud/stats with this policy id. +func (s *Server) handleCloudStatsPolicy(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + available := s.deps != nil && s.deps.Cloud != nil && s.deps.Cloud.Status().StatisticsReady + writeJSON(w, http.StatusOK, map[string]any{ + "available": available, + "policy": cloudwire.StatsPolicy, + "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.", + "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.", + "publication_minimum_homes": cloudwire.StatsMinimumHomes, + "publication_max_home_share": 0.3, + "controller": "sourceful", "default": "off", "optional": true, + }) +} diff --git a/go/internal/api/cloud_statistics_test.go b/go/internal/api/cloud_statistics_test.go new file mode 100644 index 000000000..65caeed91 --- /dev/null +++ b/go/internal/api/cloud_statistics_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestCloudStatisticsConfiguredLimitsReadOnlyUnknownAndPartner(t *testing.T) { + cfg := &config.Config{Drivers: []config.Driver{ + {Name: "bat1", BatteryCapacityWh: 10000, MaxChargeW: 3000, MaxDischargeW: 5000, InverterGroup: "shared"}, + {Name: "bat2", BatteryCapacityWh: 5000, MaxChargeW: 4000, InverterGroup: "shared"}, + {Name: "observed", BatteryCapacityWh: 20000, MaxChargeW: 10000, ObserveOnly: true}, + {Name: "disabled", BatteryCapacityWh: 99999, Disabled: true}, {Name: "ev"}, + }, Loadpoints: []config.Loadpoint{{DriverName: "ev", MaxChargeW: 11000}, {DriverName: "ev", MaxChargeW: 7000}}, Weather: &config.Weather{PVRatedW: 12345}} + s := &Server{deps: &Deps{Cfg: cfg, CfgMu: &sync.RWMutex{}}} + report, ok := s.CloudStatistics(context.Background(), time.Now()) + if !ok { + t.Fatal("no report") + } + if report.Metrics["battery_capacity_wh"] != 35000 || report.Metrics["flexible_power_w"] != 16000 || report.Metrics["pv_rated_w"] != 12350 { + t.Fatalf("wrong totals %+v", report.Metrics) + } + if _, ok := report.Metrics["pv_generation_wh"]; ok { + t.Fatal("missing energy became zero") + } + cfg.Drivers[0].MaxChargeW = 0 + cfg.Drivers[0].MaxDischargeW = 0 + report, _ = s.CloudStatistics(context.Background(), time.Now()) + if _, ok := report.Metrics["flexible_power_w"]; ok { + t.Fatal("unknown limit invented") + } + cfg.Nova = &config.Nova{Enabled: true} + if _, ok := s.CloudStatistics(context.Background(), time.Now()); ok { + t.Fatal("partner site sent statistics") + } +} + +func TestCloudStatisticsReadsClosedLedgerHourAndRoundsEnergy(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + now := time.Date(2026, 10, 17, 12, 30, 0, 0, time.UTC) + base := now.Truncate(time.Hour).Add(-time.Hour) + for i, value := range []float64{1000, 1123} { + at := base.Add(time.Duration(i+1) * time.Minute).UnixMilli() + err := st.RecordTickWithEnergy(state.HistoryPoint{TsMs: at, JSON: "{}"}, nil, []state.EnergyObservation{{AssetID: "private-hardware", DeviceID: "private-serial", AssetKind: state.AssetGridMeter, Flow: state.FlowGridImport, AtMs: at, CounterWh: &value}}) + if err != nil { + t.Fatal(err) + } + } + s := &Server{deps: &Deps{Cfg: &config.Config{}, CfgMu: &sync.RWMutex{}, State: st}} + report, ok := s.CloudStatistics(context.Background(), now) + if !ok || report.Metrics["grid_import_wh"] != 120 { + t.Fatalf("ledger energy %+v", report) + } + if _, ok := report.Metrics["pv_generation_wh"]; ok { + t.Fatal("unknown solar became zero") + } +} diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 7af1636fe..66bc2e0e2 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -67,6 +67,7 @@ func (l *Link) once(ctx context.Context, snap grantSnapshot) (time.Duration, err up := l.connected upFor := time.Since(l.upAt) l.connected = false + l.serviceStatsPolicy = "" if time.Since(connectedAt) >= backoffCap { l.attempt = 0 } @@ -303,6 +304,7 @@ func (l *Link) serve(ctx context.Context, conn uplinkConn, snap grantSnapshot) e tunOut, tunBulk := make(chan []byte, tunQueue), make(chan []byte, tunBulkQueue) l.mu.Lock() l.connected = true + l.serviceStatsPolicy = ok.StatsPolicy l.upAt = time.Now() l.transport = transportOf(conn) l.shardHops, l.hostFails = 0, 0 diff --git a/go/internal/cloudlink/grant.go b/go/internal/cloudlink/grant.go index 9b825025c..b0f300c0d 100644 --- a/go/internal/cloudlink/grant.go +++ b/go/internal/cloudlink/grant.go @@ -71,6 +71,7 @@ type grantRecord struct { StatsID []byte `json:"statsId,omitempty"` // StatsPolicy names the consent text the owner read for statistics. StatsPolicy string `json:"statsPolicy,omitempty"` + StatsAtMs int64 `json:"statsAtMs,omitempty"` // Revoked means the owner turned the readable copy off and the service // has not yet confirmed that the copy is gone. The record stays until it // has; the link and its key stay after. diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 4e52372ba..759d0a12a 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -241,15 +241,16 @@ type Link struct { // sender acts within one tick. wake chan struct{} - mu sync.Mutex - seq uint64 - key *ecdsa.PrivateKey - pk []byte - home string - link *linkRecord - grant *grantRecord - notice string - connected bool + mu sync.Mutex + seq uint64 + key *ecdsa.PrivateKey + pk []byte + home string + link *linkRecord + grant *grantRecord + notice string + connected bool + serviceStatsPolicy string // transport is how the uplink travels, or last travelled: quic or // websocket. winners is the race's memory, the transport that opened // first on each network. @@ -696,7 +697,7 @@ func (l *Link) Stats(req StatsRequest) error { return ErrPurgePending } g := *l.grant - if len(g.StatsID) == 0 { + if len(g.StatsID) == 0 || g.StatsPolicy != req.Policy { id, err := randomBytes(statsIDBytes) if err != nil { return err @@ -705,6 +706,12 @@ func (l *Link) Stats(req StatsRequest) error { } g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, true) g.StatsPolicy, g.By = req.Policy, req.By + if g.StatsAtMs == 0 || l.grant.StatsPolicy != req.Policy { + g.StatsAtMs = 0 + if l.clockSynced() { + g.StatsAtMs = l.opts.Now().UnixMilli() + } + } return l.storeLocked(g, "statistics on") } @@ -719,7 +726,7 @@ func (l *Link) StopStats(by string) error { } g := *l.grant g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, false) - g.StatsID, g.StatsPolicy, g.By = nil, "", by + g.StatsID, g.StatsPolicy, g.StatsAtMs, g.By = nil, "", 0, by return l.storeLocked(g, "statistics off") } @@ -814,7 +821,10 @@ func (l *Link) clockSynced() bool { // Status is what GET /api/cloud shows. type Status struct { // State is the readable copy's: off, on or revoking. - State string `json:"state"` + State string `json:"state"` + StatisticsPolicy string `json:"statisticsPolicy"` + StatisticsActive bool `json:"statisticsActive"` + StatisticsReady bool `json:"statisticsReady"` // Link is off, on or ending: whether the box is linked, which the // phones' tunnels need and the readable copy rides on. Link string `json:"link"` @@ -867,6 +877,11 @@ func (l *Link) Status() Status { Link: "off", TunBytesLast24h: l.tunBudget.total(l.uptimeMs()), TunBudgetBytes: tunBudgetBytes, TunDropped: l.tunDropped, } + s.StatisticsPolicy = cloudwire.StatsPolicy + s.StatisticsReady = l.connected && l.serviceStatsPolicy == cloudwire.StatsPolicy + if g := l.grant; g != nil { + s.StatisticsActive = !g.Revoked && g.StatsAtMs > 0 && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && slices.Contains(g.Purposes, protocol.CloudPurposeStats) + } s.BudgetReached = s.BytesLast24h >= budgetBytes if !l.lastSend.IsZero() { s.LastSendAgeMs = time.Since(l.lastSend).Milliseconds() diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index 5dec9dc0c..bcc5f51f8 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -215,7 +215,8 @@ type fakeService struct { transports map[string]int // kick drops this many connections right after hello_ok, so the box // dials again. - kick int + kick int + statsPolicy string // usedEarly says, per QUIC connection, whether it took early data. usedEarly []bool } @@ -326,7 +327,7 @@ func (s *fakeService) serve(conn uplinkConn, ekm []byte) { signer, _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) } s.write(conn, signer, home, cloudwire.MsgHelloOK, cloudwire.HelloOK{ - BoxNonce: hello.BoxNonce, EKM: okEKM, Have: map[string]string{}, RosterSeq: s.rosterSeqOK}) + BoxNonce: hello.BoxNonce, EKM: okEKM, Have: map[string]string{}, RosterSeq: s.rosterSeqOK, StatsPolicy: s.statsPolicy}) if s.afterHello != nil { go s.afterHello(conn, home) } @@ -1169,11 +1170,11 @@ func TestStatisticsAreASecondConsentUnderTheGrant(t *testing.T) { t.Fatalf("grant record %+v", g) } - // Asked again, the same id. + // A new policy needs a new id and consent start. if err := h.link.Stats(StatsRequest{Policy: "stats-2026-11"}); err != nil { t.Fatal(err) } - if again := h.record(t); !bytes.Equal(again.StatsID, g.StatsID) || again.StatsPolicy != "stats-2026-11" { + if again := h.record(t); bytes.Equal(again.StatsID, g.StatsID) || again.StatsPolicy != "stats-2026-11" { t.Fatalf("statsId %x became %x", g.StatsID, again.StatsID) } diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 50e94efbb..e4124bf86 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -73,19 +73,21 @@ func (b *budget) total(uptimeMs int64) int64 { // cadence is one connection's view of what it last sent. type cadence struct { - full bool - lastFull time.Time - liveKey string - liveAt time.Time - lpLast *cloudwire.LP - lpAt time.Time - carLast *cloudwire.Cars - carAt time.Time - pricesKey string - pricesAt time.Time - anyAt time.Time // last live or hb - rosterAt time.Time - watchUntil time.Time + statsHour int64 + statsCheckedAt time.Time + full bool + lastFull time.Time + liveKey string + liveAt time.Time + lpLast *cloudwire.LP + lpAt time.Time + carLast *cloudwire.Cars + carAt time.Time + pricesKey string + pricesAt time.Time + anyAt time.Time // last live or hb + rosterAt time.Time + watchUntil time.Time // pendingUntil is when the pairing code the last roster listed runs // out, zero when it listed none. pendingUntil time.Time @@ -263,6 +265,9 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { } } + if err := w.statistics(snap, st, now); err != nil { + return err + } period := periodIdle if now.Before(st.watchUntil) { period = periodWatched @@ -687,7 +692,7 @@ 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} + 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 diff --git a/go/internal/cloudlink/stats.go b/go/internal/cloudlink/stats.go new file mode 100644 index 000000000..7edd28e12 --- /dev/null +++ b/go/internal/cloudlink/stats.go @@ -0,0 +1,58 @@ +package cloudlink + +import ( + "context" + "slices" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// StatisticsSite reads the last closed hour, without names or hardware ids. +type StatisticsSite interface { + Statistics(context.Context, time.Time) (cloudwire.Stats, bool) +} + +func (w *writer) statistics(snap grantSnapshot, st *cadence, now time.Time) error { + w.link.mu.Lock() + ready := w.link.connected && w.link.serviceStatsPolicy == cloudwire.StatsPolicy + w.link.mu.Unlock() + if !ready { + return nil + } + source, ok := w.link.opts.Site.(StatisticsSite) + g := snap.g + if !ok || g == nil || g.Revoked || g.Controller != cloudwire.ControllerSourceful || + g.StatsPolicy != cloudwire.StatsPolicy || len(g.StatsID) != 16 || g.StatsAtMs <= 0 || + !slices.Contains(g.Purposes, protocol.CloudPurposeStats) || !w.link.clockSynced() { + return nil + } + hour := now.UTC().Truncate(time.Hour).Add(-time.Hour).UnixMilli() + if hour < g.StatsAtMs || st.statsHour == hour || now.Sub(st.statsCheckedAt) < time.Minute { + return nil + } + st.statsCheckedAt = now + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + report, ok := source.Statistics(ctx, now) + if !ok || report.HourMS != hour { + return nil + } + report.StatsID, report.Policy = g.StatsID, cloudwire.StatsPolicy + if !report.Valid(now.UnixMilli()) { + return nil + } + // Recheck consent after the read: withdrawing it must not emit one more + // hour while a database query is finishing. + cur := w.link.current() + if cur.g == nil || cur.g.Revoked || cur.g.Controller != cloudwire.ControllerSourceful || cur.g.StatsPolicy != g.StatsPolicy || !slices.Equal(cur.g.StatsID, g.StatsID) || + !slices.Contains(cur.g.Purposes, protocol.CloudPurposeStats) { + return nil + } + sent, err := w.sendBody(cur, cloudwire.MsgStats, &report, false) + if sent { + st.statsHour = hour + } + return err +} diff --git a/go/internal/cloudlink/stats_test.go b/go/internal/cloudlink/stats_test.go new file mode 100644 index 000000000..80b784e74 --- /dev/null +++ b/go/internal/cloudlink/stats_test.go @@ -0,0 +1,78 @@ +package cloudlink + +import ( + "context" + "crypto/ecdsa" + "github.com/srcfl/ftw/go/protocol/cloudwire" + "testing" + "time" +) + +type statsSite struct{ *fakeSite } + +func (s statsSite) Statistics(_ context.Context, now time.Time) (cloudwire.Stats, bool) { + return cloudwire.Stats{HourMS: now.UTC().Truncate(time.Hour).Add(-time.Hour).UnixMilli(), Metrics: map[string]int64{"pv_generation_wh": 120}}, true +} +func TestStatisticsSendsSignedClosedHourOnlyWithCurrentConsent(t *testing.T) { + svc := newFakeService(t) + svc.statsPolicy = cloudwire.StatsPolicy + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { + o.Site = statsSite{&fakeSite{}} + o.Now = func() time.Time { return time.Now().Add(-2 * time.Hour) } + }) + h.grant(t) + h.nextRoster(t) + if err := h.link.Stats(StatsRequest{Policy: "old-policy"}); err != nil { + t.Fatal(err) + } + h.nextRoster(t) + for _, e := range svc.received() { + if e.T == cloudwire.MsgStats { + t.Fatal("old consent sent statistics") + } + } + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + e := svc.waitFor(cloudwire.MsgStats, 3*time.Second) + var report cloudwire.Stats + if err := cloudwire.Unmarshal(e.B, &report); err != nil { + t.Fatal(err) + } + if !report.Valid(time.Now().UnixMilli()) || len(r.Grant.StatsID) != 16 || r.Grant.StatsPolicy != cloudwire.StatsPolicy { + t.Fatalf("invalid report or roster %+v %+v", report, r.Grant) + } + if !h.link.Status().StatisticsActive { + t.Fatal("current consent inactive") + } + if err := h.link.StopStats("box"); err != nil { + t.Fatal(err) + } + r = h.nextRoster(t) + if len(r.Grant.StatsID) != 0 || h.link.Status().StatisticsActive { + t.Fatal("withdrawn consent remained active") + } +} + +func TestStatisticsWaitsForTheServicesApprovedPolicy(t *testing.T) { + svc := newFakeService(t) + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { + o.Site = statsSite{&fakeSite{}} + o.Now = func() time.Time { return time.Now().Add(-2 * time.Hour) } + }) + h.grant(t) + h.nextRoster(t) + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); err != nil { + t.Fatal(err) + } + h.nextRoster(t) + if h.link.Status().StatisticsReady { + t.Fatal("unapproved service declared ready") + } + for _, e := range svc.received() { + if e.T == cloudwire.MsgStats { + t.Fatal("statistics sent without service approval") + } + } +} diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 6808b38b2..3474f8f05 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"}, + "Grant": {"id", "scopes", "purposes", "controller", "statsId", "statsPolicy", "statsSinceMs"}, "Pending": {"hash", "ttlS"}, "WebPushRow": {"id", "dev", "epoch", "endpoint", "ct", "auth"}, @@ -58,6 +58,7 @@ var Fields = map[string][]string{ // client an owner gave access, on agent.added while the copy carries // events, which FTW Cloud's own consent page showed. MsgNotice: {"kind", "client"}, + MsgStats: {"statsId", "policy", "hourMs", "metrics"}, // tun carries no header and no signature: d is Noise ciphertext its two // ends authenticate, and the service reads only dev and sid. @@ -79,6 +80,7 @@ var Bodies = map[string]any{ MsgRoster: Roster{}, MsgBye: Bye{}, MsgNotice: Notice{}, + MsgStats: Stats{}, } // Unheaded are the box's messages without the common header. diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index d169c8e0f..196b1bc8f 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -62,7 +62,7 @@ var ClassScope = map[string]string{ } // ServiceOnly are box messages the service acts on and never forwards. -var ServiceOnly = []string{MsgHello, MsgRoster, MsgBye, MsgNotice} +var ServiceOnly = []string{MsgHello, MsgRoster, MsgBye, MsgNotice, MsgStats} // Clock values for hello.clock and hb.clock. const ( @@ -106,7 +106,8 @@ type Challenge struct { // HelloOK answers hello. The service signs it, and the box sends nothing but // hello before it has checked it. type HelloOK struct { - BoxNonce []byte `cbor:"boxNonce"` + StatsPolicy string `cbor:"statsPolicy,omitempty"` + BoxNonce []byte `cbor:"boxNonce"` // EKM is the service's TLS exporter value for this session. A proxy that // ends TLS holds two sessions with two values, so the check fails there. EKM []byte `cbor:"ekm"` @@ -393,10 +394,13 @@ 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 { - ID []byte `cbor:"id"` - Scopes []string `cbor:"scopes"` - Purposes []string `cbor:"purposes"` - Controller string `cbor:"controller"` + ID []byte `cbor:"id"` + Scopes []string `cbor:"scopes"` + Purposes []string `cbor:"purposes"` + Controller string `cbor:"controller"` + StatsID []byte `cbor:"statsId,omitempty"` + StatsPolicy string `cbor:"statsPolicy,omitempty"` + StatsSinceMs int64 `cbor:"statsSinceMs,omitempty"` } // Notice is a security notice for the owners: a push kind from the diff --git a/go/protocol/cloudwire/stats.go b/go/protocol/cloudwire/stats.go new file mode 100644 index 000000000..651d09d4d --- /dev/null +++ b/go/protocol/cloudwire/stats.go @@ -0,0 +1,40 @@ +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" +const MsgStats = "stats" +const StatsHourMS = int64(3_600_000) +const StatsMinimumHomes = 20 + +var StatsMetricKeys = []string{ + "battery_capacity_wh", "flexible_power_w", "pv_rated_w", + "grid_import_wh", "grid_export_wh", "pv_generation_wh", + "battery_charge_wh", "battery_discharge_wh", "vehicle_charge_wh", "vehicle_discharge_wh", +} + +// Stats carries one closed UTC hour. Missing values are unknown. Capacity +// and power are configured limits, never proof that a command took effect. +// It is service-only; no phone or agent receives this message. +type Stats struct { + Header + StatsID []byte `cbor:"statsId"` + Policy string `cbor:"policy"` + HourMS int64 `cbor:"hourMs"` + Metrics map[string]int64 `cbor:"metrics"` +} + +func (s Stats) Valid(nowMS int64) bool { + if len(s.StatsID) != 16 || s.Policy != StatsPolicy || s.HourMS <= 0 || s.HourMS%StatsHourMS != 0 || + s.HourMS+StatsHourMS > nowMS || s.HourMS < nowMS-24*StatsHourMS || len(s.Metrics) > len(StatsMetricKeys) { + return false + } + for key, value := range s.Metrics { + if !slices.Contains(StatsMetricKeys, key) || value < 0 || value > 1_000_000 || value%10 != 0 { + return false + } + } + return true +} diff --git a/go/protocol/cloudwire/stats_test.go b/go/protocol/cloudwire/stats_test.go new file mode 100644 index 000000000..a8bc53b39 --- /dev/null +++ b/go/protocol/cloudwire/stats_test.go @@ -0,0 +1,30 @@ +package cloudwire + +import ( + "bytes" + "testing" + "time" +) + +func TestStatisticsOnlyClosedAllowlistedQuantizedHours(t *testing.T) { + now := time.Date(2026, 10, 17, 12, 30, 0, 0, time.UTC).UnixMilli() + valid := func() Stats { + return Stats{StatsID: bytes.Repeat([]byte{1}, 16), Policy: StatsPolicy, HourMS: now/StatsHourMS*StatsHourMS - StatsHourMS, Metrics: map[string]int64{"pv_generation_wh": 120}} + } + if !valid().Valid(now) { + t.Fatal("valid closed hour rejected") + } + cases := map[string]func(*Stats){"open hour": func(s *Stats) { s.HourMS += StatsHourMS }, "before window": func(s *Stats) { s.HourMS -= 24 * StatsHourMS }, "fine time": func(s *Stats) { s.HourMS++ }, "new field": func(s *Stats) { s.Metrics["address"] = 0 }, "fine energy": func(s *Stats) { s.Metrics["pv_generation_wh"] = 121 }, "too large": func(s *Stats) { s.Metrics["pv_generation_wh"] = 1000010 }, "old policy": func(s *Stats) { s.Policy = "old" }, "no id": func(s *Stats) { s.StatsID = nil }} + for name, change := range cases { + t.Run(name, func(t *testing.T) { + s := valid() + change(&s) + if s.Valid(now) { + t.Fatal("invalid report accepted") + } + }) + } + if _, ok := ClassScope[MsgStats]; ok { + t.Fatal("statistics forwarded to clients") + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json index 583c52cec..ce4e763bc 100644 --- a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json @@ -247,6 +247,21 @@ "name": "roster browser notice", "envelope": "a361625901e7ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105617466726f737465726373696758409c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9", "datagram": false + }, + { + "name": "statistics service ready", + "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", + "datagram": false + }, + { + "name": "statistics closed hour", + "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", + "datagram": false + }, + { + "name": "roster statistics consent", + "envelope": "a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", + "datagram": false } ], "notes": { diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index 93e72a987..7b6930d34 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -321,6 +321,36 @@ "sig": "9c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9", "envelope": "a361625901e7ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105617466726f737465726373696758409c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9", "frame": "01010000023ca361625901e7ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105617466726f737465726373696758409c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9" + }, + { + "name": "statistics service ready", + "signer": "service", + "t": "hello_ok", + "body": "a563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d7631", + "signedInputSha256": "6777900dc405724488293496cd480b243af7dd7852170a1db355d136e4269ab8", + "sig": "629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", + "envelope": "a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33", + "frame": "0101000000e3a36162588da563656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f73746572536571046b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d763161746868656c6c6f5f6f6b637369675840629d7e428ae3b7ed50f3e49a27f7281b2779d64373683bf3a6ed0037e6db4ce2c03211770b7d9649b1f2a957a040878174ae57c9f291ba57d3f0c0fc95476f33" + }, + { + "name": "statistics closed hour", + "signer": "box", + "t": "stats", + "body": "a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a00014438", + "signedInputSha256": "0cd18bce4afac18cf16427e52e734fb06151f01adfedec4f58cae328a31e7127", + "sig": "3363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", + "envelope": "a3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec", + "frame": "01010000010ba3616258b8a8637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b066686f75724d731b000001a130a0b18066706f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316677616c6c4d731b000001a130d7f9d8676d657472696373a270666c657869626c655f706f7765725f77192af87070765f67656e65726174696f6e5f776818786773746174734964508888888888888888888888888888888868757074696d654d731a0001443861746573746174736373696758403363381c7f7d5291f1e2f27423b26c7d2a7b9f3cc81dbd094c0bb94af12ae73db6b83769403a3f4ac673975767468d7d03540c2f9d45ac345f2f7bb13c0d92ec" + }, + { + "name": "roster statistics consent", + "signer": "box", + "t": "roster", + "body": "aa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106", + "signedInputSha256": "cb06814081a6ab56c1265fde264a4240e12fb3233cc410673a3399b0afdca884", + "sig": "479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", + "envelope": "a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c", + "frame": "010100000164a3616259010faa637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a762696450444444444444444444444444444444446673636f706573806773746174734964508888888888888888888888888888888868707572706f736573826573657276656573746174736a636f6e74726f6c6c657269736f7572636566756c6b7374617473506f6c696379781c6674772d70726f6a6563742d73746174732d323032362d31302d76316c737461747353696e63654d731b000001a13069c300656e6f697365406677616c6c4d731b000001a130d7fdc06764657669636573806770656e64696e678068757074696d654d731a0001482069726f7374657253657106617466726f73746572637369675840479ea1acf685d509e1066c47d03668dde1ee42f5721afcd26a252fb1c1fe230436ff071cc1748b26547422e86491014b4f3fd4947d27af0d56b3de0f7857155c" } ] } diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index 28bf436e2..67ffdd57b 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -264,6 +264,9 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { Feedback: []Feedback{}, }}, {"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}}}, } }