diff --git a/.changeset/ftw-cloud-app-session.md b/.changeset/ftw-cloud-app-session.md new file mode 100644 index 000000000..aec2119bc --- /dev/null +++ b/.changeset/ftw-cloud-app-session.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Let phones read their home from FTW Cloud. While FTW Cloud is on, the box tells every paired phone, over its own encrypted session, the key it signs with and the names behind its anonymous ids, so a phone can check what the service forwards. Boxes that can run the uplink say so to the app. `POST` and `DELETE /api/cloud/stats` record and withdraw an owner's consent to statistics; nothing is sent for it yet. diff --git a/.changeset/ftw-cloud-events.md b/.changeset/ftw-cloud-events.md new file mode 100644 index 000000000..250b8348c --- /dev/null +++ b/.changeset/ftw-cloud-events.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +With FTW Cloud on and events in the grant, the box sends its notifications as signed events for the owners' phones; names never leave the house. A restart the box chooses, an update among them, tells FTW Cloud so it holds back "box out of reach". A new notification, "The car may not be ready", tells once per plug-in when the box knows the car's level and the plan cannot reach the goal by the ready time. A car that reports nothing itself gets "Car charged" in kWh once the box's estimate from a level someone set reaches the target. "Car plugged in" now waits 90 seconds and is sent only when the box needs the level or a goal. Each notification transport now has its own 10-second limit, so one that hangs no longer delays the others. diff --git a/.changeset/ftw-cloud-uplink.md b/.changeset/ftw-cloud-uplink.md new file mode 100644 index 000000000..f882da72d --- /dev/null +++ b/.changeset/ftw-cloud-uplink.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Add the FTW Cloud uplink, off unless an owner turns it on. `GET /api/cloud` shows the grant and the bytes sent in the last 24 hours, `GET /api/cloud/preview` shows the exact bytes that leave the house, and `POST` and `DELETE /api/cloud/grant` turn it on and off. Release builds name no FTW Cloud service yet, so the box refuses a grant and never dials. Backups no longer include `cloud.key`. diff --git a/.github/scripts/check-cloudlink-imports.sh b/.github/scripts/check-cloudlink-imports.sh new file mode 100755 index 000000000..0c061cc5f --- /dev/null +++ b/.github/scripts/check-cloudlink-imports.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# go/internal/cloudlink streams the box's state to FTW Cloud, and FTW Cloud +# reads and never controls. This check keeps the uplink's build graph honest: +# it may import go/protocol and nothing else of the box's own, so no control, +# API, loadpoint, planner or driver code can be reached from the code that +# talks to the service. Its tests are held to the same rule. + +set -euo pipefail + +cd "${1:-go}" + +reached=$(go list -deps -test -f '{{.ImportPath}}' ./internal/cloudlink | awk ' + $1 ~ /^github\.com\/srcfl\/ftw\/go\// && + $1 !~ /^github\.com\/srcfl\/ftw\/go\/protocol(\/|$)/ && + $1 !~ /^github\.com\/srcfl\/ftw\/go\/internal\/cloudlink(\.test| |$)/ { print $1 } +' | sort -u) + +if [ -n "${reached}" ]; then + echo "go/internal/cloudlink may import only go/protocol from the box. It reaches:" >&2 + printf '%s\n' "${reached}" >&2 + exit 1 +fi + +echo "go/internal/cloudlink: $(go list -deps ./internal/cloudlink | wc -l | tr -d ' ') packages, none from the box but go/protocol" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f4d50e33..c2d99c058 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,6 +108,10 @@ jobs: go test -count=1 ./... - name: Protocol module imports nothing from the box run: bash .github/scripts/check-protocol-boundary.sh go/protocol + # FTW Cloud reads and never controls. The uplink's build graph says so: + # it reaches no control, API or loadpoint code. + - name: FTW Cloud uplink imports nothing that controls + run: bash .github/scripts/check-cloudlink-imports.sh go web: name: web diff --git a/Makefile b/Makefile index 8865765ce..1667f74e3 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ .PHONY: help test compose-migration-test container-boundary-test release-workflow-test build build-arm64 build-amd64 release release-linux release-linux-arm64 release-linux-amd64 \ run-sim sim-ocpp dev fmt vet clean e2e ci ci-ui ci-hw-pi docs \ - verify verify-all install-hooks driver-repository-validate driver-versions protocol-boundary \ + verify verify-all install-hooks driver-repository-validate driver-versions protocol-boundary cloudlink-boundary \ drivers drivers-present driver-versions-across-pin VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) @@ -48,6 +48,7 @@ help: @echo " ci-hw-pi deploy candidate to Pi CI slot + browser smoke" @echo " fmt vet Go format + static checks" @echo " protocol-boundary prove go/protocol imports nothing from the box" + @echo " cloudlink-boundary prove the FTW Cloud uplink reaches no control code" @echo " drivers fetch drivers/ from the pinned device-drivers commit" @echo " clean nuke build artifacts" @@ -147,7 +148,7 @@ ci-hw-pi: # # verify-all checks both supported Linux targets on the developer host. -verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test protocol-boundary +verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test protocol-boundary cloudlink-boundary cd go && go vet -tags=$(GO_TAGS) ./... cd go/protocol && go vet ./... cd go && go build -tags=$(GO_TAGS) ./... @@ -247,6 +248,9 @@ vet: protocol-boundary: bash .github/scripts/check-protocol-boundary.sh go/protocol +cloudlink-boundary: + bash .github/scripts/check-cloudlink-imports.sh go + clean: rm -rf bin release cd go && go clean diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index f34ab4c1a..8473df44a 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -19,6 +19,12 @@ events: - kind: charging.session_complete title: Car charged body: "{kwh} kWh delivered — ready to go." + - kind: charging.goal_at_risk + # Only when the box knows the car's level, from the car or from someone + # who set it, and the plan cannot reach the goal by the ready time. An + # assumed level would make this a guess. Once per plug-in. + title: The car may not be ready + body: "It will not reach {target} % by {by} at this pace." - kind: charging.interrupted # Only after the hysteresis window the box applies: a charge that had # been running steadily and then stopped without finishing. A flapping @@ -45,3 +51,12 @@ events: # has. The box encrypts it before it ever leaves home. title: Your box is out of reach body: "It stopped answering. Power cut, or the internet at home is down." + - kind: cloud.granted + # To every owner, when an owner turns FTW Cloud on. Sharing a readable + # copy of the house is a choice every owner should hear about. + title: FTW Cloud is on + body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box." + - kind: cloud.revoked + # To every owner, when FTW Cloud is turned off at either door. + title: FTW Cloud is off + body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy." diff --git a/contract/registry.yaml b/contract/registry.yaml index d407bcd0b..5d71d3a7c 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -3,8 +3,8 @@ # The single source for every name shared between this app and the box, and # the same file in both repositories — byte for byte. # -# In srcfl/ftw it generates Go constants: go/internal/appproto/contract_gen.go -# and go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`. +# In srcfl/ftw it generates Go constants: go/protocol/contract_gen.go and +# go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`. # In srcfl/ftw-webapp there is no generator — src/lib/protocol/contract.ts and # the error table in src/lib/protocol/messages.ts are written by hand and read # back against this file by tests/registry-contract.test.ts. @@ -76,6 +76,10 @@ capabilities: # history.5m. Present does not mean every path is reachable: reads and # configuration go through, anything that moves energy stays on cmd. - api.passthrough + # The box can run the FTW Cloud uplink. Absent means the app uses the relay + # only and offers no FTW Cloud. It says nothing about whether the owner + # turned FTW Cloud on; the box's grant decides that. + - cloud.link # --------------------------------------------------------------------------- # Scopes. One object axis, two verb axes: .. @@ -87,6 +91,7 @@ scopes: - { name: ftw.plan.read, desc: Dispatch plan and its reasoning } - { name: ftw.health.read, desc: Box and driver health } - { name: ftw.assets.read, desc: Devices and their capabilities } + - { name: ftw.events.read, desc: Events behind notifications } - { name: ftw.dispatch.write, desc: Control energy assets } - { name: ftw.mode.write, desc: Change site operating mode } - { name: ftw.members.read, desc: See who has access } @@ -100,6 +105,22 @@ roles: label: Viewer scopes: [ftw.live.read] +# --------------------------------------------------------------------------- +# FTW Cloud. What an owner can let the box share with FTW Cloud, and why. +# docs/protocol-cloud.md in srcfl/ftw is the wire format. +# +# cloud_scopes are the scopes a grant may name: the box sends a class only +# when the grant holds its scope, and a device reads it only when its own +# role carries the scope too. ftw.assets.read is left out on purpose: the +# box sends no device inventory, names or capabilities to FTW Cloud. +# +# cloud_purposes are what the data may be used for. serve is the owner's own +# phones and widgets. stats is household statistics, a separate consent that +# is off unless the owner turns it on. +# --------------------------------------------------------------------------- +cloud_scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read] +cloud_purposes: [serve, stats] + # --------------------------------------------------------------------------- # Command operations. What a `cmd` frame may name, and the scope the grant # must carry before the box acts on it. @@ -219,7 +240,7 @@ client_errors: # the two into one enum cannot express "connected, but the inverter is quiet". # --------------------------------------------------------------------------- source_states: [live, lagging, stale, down, never] -carrier_states: [webrtc, relay, cache, none] +carrier_states: [webrtc, relay, cloud, cache, none] # --------------------------------------------------------------------------- # History resolutions backed by what the box actually stores. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 747354aa2..80b4ebed8 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -519,6 +519,7 @@ func startAppLink( siteMeterStale time.Duration, gateway *lateAPI, webPush *notifications.WebPush, + cloud *cloudSession, ) (*appenroll.Identity, *appuplink.Uplink, bool, error) { enabled := cfg != nil && cfg.AppLink.On() @@ -610,9 +611,12 @@ func startAppLink( API: gateway, Caller: caller, Grants: grants, - Caps: caps, - Codec: appuplink.Codec(), - Sender: sender, + // FTW Cloud is asked per session: its link is bound after + // this returns, and its grant moves while the box runs. + Caps: cloud.caps(caps), + Cloud: cloud.info, + Codec: appuplink.Codec(), + Sender: sender, // The three frozen power fields point at the source whose // freshness governs them. The site meter is the only one the // box can name without knowing the site's hardware. diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go new file mode 100644 index 000000000..9f04f1066 --- /dev/null +++ b/go/cmd/ftw/cloud_link.go @@ -0,0 +1,398 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "math" + "path/filepath" + "slices" + "sort" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/notifications" + "github.com/srcfl/ftw/go/internal/prices" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// FTW Cloud's uplink, wired to the box. +// +// Everything here reads. cloudlink may import nothing from the box, so these +// adapters answer its questions from the same sources the app session and the +// HTTP API use: the live snapshot, the loadpoint view GET /api/loadpoints +// composes, and the price store. None of them is handed anything that can +// change state. + +// cloudSite is the box's state as the uplink wants it. +type cloudSite struct { + site *appSite + api *lateAPI + prices *appPrices + + sessMu sync.Mutex + sessions map[string]*plugSession +} + +// plugSession numbers plug-ins per loadpoint for the life of this process. +// The phone keys what it holds by boot, so a number per boot is enough to +// tell one charging session from the next. +type plugSession struct { + plugged bool + n int +} + +func (c *cloudSite) Live() cloudlink.Live { + snap := c.site.Snapshot() + uptime := time.Since(c.site.started).Milliseconds() + fields, sources := appproto.LiveView(snap, uptime) + + out := cloudlink.Live{ + Fields: fields, + Sources: make(map[string]cloudlink.Source, len(sources)), + FieldSources: map[string][]string{}, + DispatchBlockedBy: snap.DispatchBlockedBy, + } + for name, s := range sources { + lastOk := s.LastOkMs + if s.State == appproto.SourceNever { + lastOk = -1 + } + out.Sources[name] = cloudlink.Source{Kind: s.Kind, State: s.State, LastOkUptimeMs: lastOk, StaleAfterMs: s.StaleAfterMs} + } + + // Each field follows the drivers that produce it. Load is what is left + // of the grid after every other flow, so it follows all of them. + meter := siteMeterName(c.site.ctrl, c.site.ctrlMu) + byType := func(t telemetry.DerType) []string { + var names []string + for _, r := range c.site.tel.ReadingsByType(t) { + names = append(names, r.Driver) + } + sort.Strings(names) + return names + } + pv, battery, ev := byType(telemetry.DerPV), byType(telemetry.DerBattery), byType(telemetry.DerEV) + set := func(fid appproto.Fid, names []string) { + key := strconv.Itoa(int(fid)) + if _, sent := fields[key]; sent && len(names) > 0 { + out.FieldSources[key] = names + } + } + var meterOnly []string + if meter != "" { + meterOnly = []string{meter} + } + set(appproto.FidGridW, meterOnly) + set(appproto.FidPvW, pv) + set(appproto.FidBatteryW, battery) + set(appproto.FidBatterySoc, battery) + set(appproto.FidEvW, ev) + set(appproto.FidLoadW, uniqueSorted(meterOnly, pv, battery, ev)) + return out +} + +func uniqueSorted(lists ...[]string) []string { + seen := map[string]bool{} + var out []string + for _, l := range lists { + for _, s := range l { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + } + sort.Strings(out) + return out +} + +func (c *cloudSite) Loadpoints() (cloudwire.LP, bool) { + srv := c.api.srv.Load() + if srv == nil { + return cloudwire.LP{}, false + } + now := time.Now() + states, feedback, ok := srv.CloudLoadpoints(now) + if !ok || len(states) == 0 { + return cloudwire.LP{}, false + } + c.site.ctrlMu.Lock() + rev := c.site.revision.Observe(c.site.ctrl) + c.site.ctrlMu.Unlock() + + out := cloudwire.LP{ControlRev: rev} + for _, st := range states { + out.Loadpoints = append(out.Loadpoints, c.loadpoint(st, now)) + } + for _, f := range feedback { + out.Feedback = append(out.Feedback, cloudFeedback(f, now)) + } + return out, true +} + +func (c *cloudSite) session(id string, plugged bool) string { + c.sessMu.Lock() + defer c.sessMu.Unlock() + if c.sessions == nil { + c.sessions = map[string]*plugSession{} + } + s := c.sessions[id] + if s == nil { + s = &plugSession{} + c.sessions[id] = s + } + if plugged && !s.plugged { + s.n++ + } + s.plugged = plugged + if !plugged { + return "" + } + return strconv.Itoa(s.n) +} + +// loadpoint maps the API's view onto the wire. Capabilities (charge limits, +// phases, battery size) and the vehicle's name are not in the shape. +func (c *cloudSite) loadpoint(st loadpoint.State, now time.Time) cloudwire.Loadpoint { + lp := cloudwire.Loadpoint{ + ID: st.ID, Src: st.DriverName, Plugged: st.PluggedIn, + SocSource: st.SoCSource, Recurring: !st.Schedule.Empty(), + PowerW: roundW(st.CurrentPowerW), SessionWh: roundW(st.DeliveredWhSession), + Session: c.session(st.ID, st.PluggedIn), + CommandedReason: st.CommandedReason, SurplusOnly: st.SurplusOnly, + GridDeferred: st.GridDeferred, PlanPending: st.PlanPending, Boost: st.BatteryBoost.Active, + } + if st.PluggedIn { + lp.SocPermille = ptr(units.PermilleFromFraction(st.CurrentSoC)) + if st.SoCSource == "vehicle" { + lp.SocAgeS = ptr(st.VehicleSoCAgeS) + } + } + if st.TargetSoC > 0 { + lp.TargetPermille = ptr(units.PermilleFromFraction(st.TargetSoC)) + } + if !st.TargetTime.IsZero() { + lp.ReadyByMs = ptr(st.TargetTime.UnixMilli()) + } + if st.CommandedKnown { + lp.CommandedW = ptr(roundW(st.CommandedW)) + } + if m := st.Manual; m.Active { + manual := &cloudwire.Manual{State: m.State, Reason: m.LimitReason, RequestedW: ptr(roundW(m.RequestedW))} + if m.SinceMs > 0 { + manual.SinceAgeMs = max(0, now.UnixMilli()-m.SinceMs) + } + if m.CommandedW > 0 { + manual.CommandedW = ptr(roundW(m.CommandedW)) + } + lp.Manual = manual + } + if st.PlanNextStartMs > 0 { + lp.NextStartMs, lp.NextEndMs = ptr(st.PlanNextStartMs), ptr(st.PlanNextEndMs) + lp.NextWh = ptr(roundW(st.PlanNextWh)) + } + return lp +} + +func cloudFeedback(f api.ControlFeedback, now time.Time) cloudwire.Feedback { + out := cloudwire.Feedback{ + Kind: f.Kind, Src: f.Driver, Status: f.Status, Reason: f.Reason, + Severity: f.Severity, Evidence: f.Evidence, Fresh: f.ReadingsFresh, + } + if f.ActualW != nil { + out.ActualW = ptr(roundW(*f.ActualW)) + } + if f.ObservedAtMs > 0 { + out.AgeMs = ptr(max(0, now.UnixMilli()-f.ObservedAtMs)) + } + return out +} + +// Prices is the window from the start of today, on the box's own clock, to +// the last stored slot. Starting at midnight rather than now keeps the hash, +// and so the message, still until new prices land. +func (c *cloudSite) Prices() (cloudwire.Prices, bool) { + if c.prices == nil { + return cloudwire.Prices{}, false + } + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + slots, err := c.prices.Slots(context.Background(), midnight.UnixMilli(), midnight.AddDate(0, 0, 2).UnixMilli()) + if err != nil || len(slots) == 0 { + return cloudwire.Prices{}, false + } + return priceColumns(c.prices.Zone(), c.prices.Currency(), slots), true +} + +// priceColumns lays slots out at the shortest slot length. A longer slot's +// price repeats across the columns it covers; a gap is null. +func priceColumns(zone, currency string, slots []appproto.PricePoint) cloudwire.Prices { + sort.Slice(slots, func(a, b int) bool { return slots[a].StartMs < slots[b].StartMs }) + slotMs := int64(math.MaxInt64) + for _, s := range slots { + slotMs = min(slotMs, int64(s.LenMin)*60_000) + } + p := cloudwire.Prices{Zone: zone, Currency: currency, T0Ms: slots[0].StartMs, SlotMs: slotMs} + for _, s := range slots { + for at := s.StartMs; at < s.StartMs+int64(s.LenMin)*60_000; at += slotMs { + i := int((at - p.T0Ms) / slotMs) + for len(p.SpotMinor) < i { + p.SpotMinor, p.TotalMinor = append(p.SpotMinor, nil), append(p.TotalMinor, nil) + } + if len(p.SpotMinor) == i { + p.SpotMinor = append(p.SpotMinor, ptr(roundW(s.SpotMinor))) + p.TotalMinor = append(p.TotalMinor, ptr(roundW(s.TotalMinor))) + } + } + } + return p +} + +func roundW(v float64) int64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + return int64(math.Round(v)) +} + +func ptr[T any](v T) *T { return &v } + +// cloudRoster lists paired phones with the scopes their roles carry. +type cloudRoster struct { + enroll *appenroll.Identity +} + +func (r cloudRoster) Devices() []cloudlink.RosterDevice { + var out []cloudlink.RosterDevice + for _, e := range r.enroll.Roster() { + out = append(out, cloudlink.RosterDevice{ID: e.ID, Key: e.Key, Role: e.Role, Epoch: e.Epoch, + Scopes: apiauth.RoleScopes[e.Role]}) + } + return out +} + +// cloudNotices reaches the notifications service once it exists; it is built +// after the link starts. +type cloudNotices struct { + svc atomic.Pointer[notifications.Service] +} + +func (n *cloudNotices) notify(kind string) { + if svc := n.svc.Load(); svc != nil { + svc.NotifyCloudGrant(kind) + } +} + +// startCloudLink loads the FTW Cloud grant and keeps the uplink up while an +// owner has it on. It needs the state store, which holds the grant, and +// enrolment, which holds the phones the roster lists; without either it +// returns nil and the routes say FTW Cloud is unavailable. +func startCloudLink( + ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity, + tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision, + siteMeterStale time.Duration, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices, +) *cloudlink.Link { + if st == nil || enroll == nil { + return nil + } + site := &cloudSite{api: gateway, site: &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: ctrlMu, revision: revision, + started: processStarted, siteMeterStale: siteMeterStale, + }} + if priceSvc != nil && priceSvc.Zone != "" && priceSvc.Store != nil { + site.prices = &appPrices{svc: priceSvc} + } + link, err := cloudlink.New(cloudlink.Options{ + Site: site, + Roster: cloudRoster{enroll: enroll}, + KV: st, + KeyPath: filepath.Join(filepath.Dir(statePath), "cloud.key"), + Build: build, + Started: processStarted, + ClockSynced: cloudlink.ClockSynced, + Notify: notices.notify, + }) + if err != nil { + slog.Warn("FTW Cloud unavailable", "err", err) + return nil + } + enroll.OnChange(link.RosterChanged) + go func() { + if err := link.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("FTW Cloud uplink stopped", "err", err) + } + }() + return link +} + +// cloudSession is FTW Cloud as an app session sees it. The app link starts +// first, because the cloud link's roster needs its enrolment, so the link is +// bound here once it exists. A session that opens before then hears no +// cloud.link and no session information, which is what a box without FTW +// Cloud says. +type cloudSession struct { + link atomic.Pointer[cloudlink.Link] +} + +func (c *cloudSession) bind(link *cloudlink.Link) { c.link.Store(link) } + +func (c *cloudSession) load() *cloudlink.Link { + if c == nil { + return nil + } + return c.link.Load() +} + +// caps adds cloud.link to an app session's capabilities when this build can +// run the uplink. A release build names no service and says nothing. +func (c *cloudSession) caps(caps []string) []string { + if link := c.load(); link != nil && link.Available() { + return append(slices.Clip(caps), appproto.CapCloudLink) + } + return caps +} + +// info is hello_ok's FTW Cloud session information, nil while FTW Cloud is +// off. +func (c *cloudSession) info() *appproto.CloudSession { + if link := c.load(); link != nil { + return link.SessionInfo() + } + return nil +} + +// cloudByeWait bounds how long shutdown waits for the bye to leave. +const cloudByeWait = 2 * time.Second + +// cloudEventQueue turns a rendered notification into an event for the +// uplink, which cuts the names out before it signs. +func cloudEventQueue(link *cloudlink.Link) func(notifications.Message) bool { + return func(m notifications.Message) bool { + return link.QueueEvent(cloudlink.EventIn{ + Kind: m.Kind, Priority: m.Priority, Args: m.Args, + Driver: m.Device, Loadpoint: m.LoadpointID, At: m.At, + }) + } +} + +// cloudForAPI hands the link to the API, or nothing: a typed nil in an +// interface would pass the routes' nil check and panic on first use. +func cloudForAPI(link *cloudlink.Link) api.CloudGrant { + if link == nil { + return nil + } + return link +} diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go new file mode 100644 index 000000000..c268185be --- /dev/null +++ b/go/cmd/ftw/cloud_link_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "io" + "log/slog" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +func TestPriceColumnsRepeatLongSlotsAndLeaveGapsNull(t *testing.T) { + t0 := int64(1_791_763_200_000) + q := int64(15 * 60_000) + p := priceColumns("SE3", "SEK", []appproto.PricePoint{ + {StartMs: t0 + 4*q, LenMin: 60, SpotMinor: 40.4, TotalMinor: 110.6}, + {StartMs: t0, LenMin: 15, SpotMinor: 41.5, TotalMinor: 112}, + {StartMs: t0 + q, LenMin: 15, SpotMinor: -2.4, TotalMinor: 60}, + }) + if p.T0Ms != t0 || p.SlotMs != q || len(p.SpotMinor) != 8 || len(p.TotalMinor) != 8 { + t.Fatalf("columns %+v", p) + } + want := []any{int64(42), int64(-2), nil, nil, int64(40), int64(40), int64(40), int64(40)} + for i, w := range want { + got := p.SpotMinor[i] + if (w == nil) != (got == nil) || (got != nil && *got != w.(int64)) { + t.Fatalf("slot %d = %v, want %v", i, got, w) + } + } +} + +func TestEachPlugInIsANewSession(t *testing.T) { + c := &cloudSite{} + if s := c.session("garage", false); s != "" { + t.Fatalf("unplugged session %q", s) + } + first := c.session("garage", true) + if again := c.session("garage", true); again != first || first == "" { + t.Fatalf("one plug-in gave %q then %q", first, again) + } + c.session("garage", false) + if next := c.session("garage", true); next == first { + t.Fatal("a second plug-in kept the first session") + } + if other := c.session("drive", true); other == "" { + t.Fatal("another loadpoint has no session") + } +} + +// The wire shape has no room for limits, phases, battery size or the car's +// name, and an unplugged car has no level. +func TestCloudLoadpointLeavesCapabilitiesAndNamesBehind(t *testing.T) { + c := &cloudSite{} + st := loadpoint.State{ + ID: "garage", DriverName: "easee", PluggedIn: false, CurrentSoC: 0.5, + MaxChargeW: 11_000, MinChargeW: 1_400, Phases: 3, VehicleCapacityWh: 77_000, + VehicleName: "Familjebilen", VehicleDriver: "tesla-fleet", + } + lp := c.loadpoint(st, time.Now()) + if lp.SocPermille != nil || lp.Session != "" { + t.Fatalf("unplugged loadpoint carries a level or a session: %+v", lp) + } + raw, err := cloudwire.Marshal(lp) + if err != nil { + t.Fatal(err) + } + for _, leak := range []string{"Familjebilen", "tesla-fleet"} { + if bytes.Contains(raw, []byte(leak)) { + t.Fatalf("%q is in the loadpoint", leak) + } + } + + st.PluggedIn, st.SoCSource, st.VehicleSoCAgeS = true, "vehicle", 90 + lp = c.loadpoint(st, time.Now()) + if lp.SocPermille == nil || *lp.SocPermille != 500 || lp.SocAgeS == nil || *lp.SocAgeS != 90 { + t.Fatalf("plugged loadpoint %+v", lp) + } +} + +type quietCloudSite struct{} + +func (quietCloudSite) Live() cloudlink.Live { + return cloudlink.Live{Sources: map[string]cloudlink.Source{"p1": {Kind: "driver"}}} +} +func (quietCloudSite) Loadpoints() (cloudwire.LP, bool) { return cloudwire.LP{}, false } +func (quietCloudSite) Prices() (cloudwire.Prices, bool) { return cloudwire.Prices{}, false } + +type noPhones struct{} + +func (noPhones) Devices() []cloudlink.RosterDevice { return nil } + +func newTestCloudLink(t *testing.T, keys []*ecdsa.PublicKey) *cloudlink.Link { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + link, err := cloudlink.New(cloudlink.Options{ + Site: quietCloudSite{}, Roster: noPhones{}, KV: st, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: keys, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + return link +} + +// An app session hears cloud.link only from a build that can run the uplink, +// and the session information only while an owner has FTW Cloud on. +func TestAppSessionsHearOfFTWCloudOnlyWhenItCanRun(t *testing.T) { + base := appproto.DefaultCaps() + var none *cloudSession + unbound := &cloudSession{} + release := &cloudSession{} + release.bind(newTestCloudLink(t, []*ecdsa.PublicKey{})) + for name, c := range map[string]*cloudSession{"no link": none, "not bound yet": unbound, "release build": release} { + if slices.Contains(c.caps(base), appproto.CapCloudLink) || c.info() != nil { + t.Fatalf("%s: caps %v, info %+v", name, c.caps(base), c.info()) + } + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + link := newTestCloudLink(t, []*ecdsa.PublicKey{&key.PublicKey}) + withService := &cloudSession{} + withService.bind(link) + caps := withService.caps(base) + if !slices.Contains(caps, appproto.CapCloudLink) || slices.Contains(base, appproto.CapCloudLink) { + t.Fatalf("caps %v from %v", caps, base) + } + if withService.info() != nil { + t.Fatal("session information before a grant") + } + if err := link.Grant(cloudlink.GrantRequest{Scopes: []string{appproto.ScopeLiveRead}, Policy: "2026-10"}); err != nil { + t.Fatal(err) + } + info := withService.info() + if info == nil || info.Home != link.Status().Home || len(info.Sources) != 1 { + t.Fatalf("session information %+v", info) + } + for id, name := range info.Sources { + if name != "p1" || len(id) != 11 { + t.Fatalf("source %q named %q", id, name) + } + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 60f44120c..da5997e12 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -68,6 +68,7 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // Version gets injected at build time via -ldflags. Defaults to "dev" for @@ -2423,10 +2424,12 @@ func main() { // The app's window onto this box's own HTTP API. Handed to the uplink // now and filled in below, once the API server exists. appAPI := &lateAPI{} + // FTW Cloud as app sessions see it, bound below once the link exists. + appCloud := &cloudSession{} appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, plannerPrefs, - controlRev, appLinkWatchdog, appAPI, webPush, + controlRev, appLinkWatchdog, appAPI, webPush, appCloud, ) switch { case appLinkErr != nil: @@ -2438,6 +2441,13 @@ func main() { slog.Info("app link disabled — set app_link.enabled to turn it on") } + // FTW Cloud: a signed, readable copy of the house for the owner's phones, + // off until an owner turns it on. It reads; it never controls. + cloudNotify := &cloudNotices{} + cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll, + tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify) + appCloud.bind(cloudLink) + // The anonymous fleet ping: how many boxes, which versions, which drivers. // Its separate relay HTTP door keeps daily totals; the encrypted app path // stays opaque and stores no routed traffic. @@ -2471,6 +2481,7 @@ func main() { BatteryIdentity: batteryIdentity, AppEnroll: appEnrollForAPI(appEnroll, appUplink, st, appLinkEnabled), FleetPing: fleetPinger, + Cloud: cloudForAPI(cloudLink), CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath, ConfigApplier: applyConfigChange, DriverDir: resolveDriverDir(), @@ -2621,6 +2632,7 @@ func main() { return amps, limitA, true }) notifSvc.Subscribe(bus) + cloudNotify.svc.Store(notifSvc) // Persist every dispatch to state.notification_log via a bus // subscriber so the notifications package stays free of storage // logic. The UI reads this table through /api/notifications/history. @@ -2649,6 +2661,20 @@ func main() { notifSvc.AddPublisher(webPush) deps.WebPush = webPush } + // FTW Cloud carries the catalogue's events to the owners' phones, + // signed, when the grant includes them. The publisher only queues. + if cloudLink != nil { + notifSvc.AddPublisher(notifications.CloudPublisher{Queue: cloudEventQueue(cloudLink)}) + } + notifSvc.SetChargingFacts(func(id string) (notifications.ChargingFacts, bool) { + lp, ok := lpMgr.State(id) + if !ok { + return notifications.ChargingFacts{}, false + } + return notifications.ChargingFacts{ + Plugged: lp.PluggedIn, LevelKnown: lp.PluggedIn && lp.SoCSource != "assumed", HasGoal: lp.TargetSoC > 0, + }, true + }) if appUplink != nil { deps.PushResync = appUplink.ResyncDeadman } @@ -2765,6 +2791,7 @@ func main() { select { case <-sigc: slog.Info("shutting down") + cloudLink.Bye(cloudwire.ByeShutdown, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("shutdown"); err != nil { @@ -2777,6 +2804,9 @@ func main() { } else { slog.Info("restart requested via API — exiting cleanly so the supervisor brings us back") } + // A restart the box chose, an update among them: FTW Cloud holds + // back "box out of reach" until the box is back. + cloudLink.Bye(cloudwire.ByeUpdate, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("restart"); err != nil { @@ -3187,6 +3217,15 @@ func main() { } } + // A car whose plan cannot make its goal: the loadpoint manager + // tells the household once per plug-in, from a plan that is + // neither being remade nor out of date. + if mpcSvc != nil { + if snap := mpcSvc.PlanSnapshot(); snap.Plan != nil && !snap.Pending && !snap.Outdated { + lpMgr.ObservePlanShortfall(snap.Plan.LoadpointShortfallWh, tickNow) + } + } + // First-vehicle-SoC replan trigger: as soon as any // DerVehicle driver is online and reporting SoC, redo the // plan once with measured-truth instead of the pluginSoC diff --git a/go/cmd/sim-ocpp/main.go b/go/cmd/sim-ocpp/main.go index bef67bd43..92a1b46c9 100644 --- a/go/cmd/sim-ocpp/main.go +++ b/go/cmd/sim-ocpp/main.go @@ -121,6 +121,17 @@ func main() { } w.WriteHeader(204) }) + for path, stalled := range map[string]bool{"stall": true, "resume": false} { + mux.HandleFunc("POST /charger/{id}/"+path, func(w http.ResponseWriter, r *http.Request) { + sim := findSim(sims, r.PathValue("id")) + if sim == nil { + http.NotFound(w, r) + return + } + sim.Stall(stalled) + w.WriteHeader(204) + }) + } go func() { slog.Info("control listening", "addr", *control) if err := http.ListenAndServe(*control, mux); err != nil { diff --git a/go/cmd/sim-ocpp/ocppcp/physics.go b/go/cmd/sim-ocpp/ocppcp/physics.go index 40377afa1..badd76fb1 100644 --- a/go/cmd/sim-ocpp/ocppcp/physics.go +++ b/go/cmd/sim-ocpp/ocppcp/physics.go @@ -19,6 +19,9 @@ type Physics struct { DrawA float64 EnergyWh float64 Plugged bool + // Stalled is a charger that stops delivering while the car still wants + // energy, as a tripped relay or a cloud-side fault does. + Stalled bool } func newPhysics(m Model, tauS float64) Physics { @@ -39,7 +42,7 @@ func (p Physics) PowerW() float64 { } func (p Physics) targetA() float64 { - if !p.Plugged || p.LimitA < minChargeAmps { + if !p.Plugged || p.Stalled || p.LimitA < minChargeAmps { return 0 } if p.LimitA > p.MaxAmps { diff --git a/go/cmd/sim-ocpp/ocppcp/sim.go b/go/cmd/sim-ocpp/ocppcp/sim.go index 685135a05..59886a807 100644 --- a/go/cmd/sim-ocpp/ocppcp/sim.go +++ b/go/cmd/sim-ocpp/ocppcp/sim.go @@ -169,6 +169,13 @@ func (s *Sim) Unplug() error { return s.statusPlugged(false) } +// Stall stops delivery with the car still asking, or lets it go on. +func (s *Sim) Stall(stalled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.physics.Stalled = stalled +} + // Tick advances physics. Tests call this with a positive dt and TauS=0 to settle. func (s *Sim) Tick(dt time.Duration) { s.mu.Lock() diff --git a/go/internal/api/api.go b/go/internal/api/api.go index badf089af..8a30689d3 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -108,6 +108,10 @@ type Deps struct { // embeddings; GET /api/fleet-ping then says so. FleetPing *fleetping.Pinger + // Cloud is the box's FTW Cloud grant and uplink. Nil in tests and + // minimal embeddings; the /api/cloud routes then say it is unavailable. + Cloud CloudGrant + CfgMu *sync.RWMutex Cfg *config.Config ConfigPath string @@ -424,6 +428,7 @@ func (s *Server) routes() { s.handle("DELETE /api/app-link/devices/{id}", Configure, s.handleAppLinkDeviceRevoke) s.handle("PATCH /api/app-link/devices/{id}", Configure, s.handleAppLinkDeviceRole) s.handle("GET /api/fleet-ping", Read, s.handleFleetPing) + s.cloudRoutes() s.handle("POST /api/mode", Actuate, s.handleSetMode, Via(appproto.OpSetMode)) s.handle("GET /api/planner/prefs", Read, s.handleGetPlannerPrefs) s.handle("POST /api/planner/prefs", Actuate, s.handleSetPlannerPrefs, Via(appproto.OpPlannerPrefsSet)) diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go new file mode 100644 index 000000000..e73e2e016 --- /dev/null +++ b/go/internal/api/api_cloud.go @@ -0,0 +1,231 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// FTW Cloud, at the HTTP door. +// +// The grant lets the box send a readable copy of the house to a Sourceful +// service, so who may turn it on is the narrowest door this API has: an owner +// over an app session, or on the LAN only from the box itself or with the +// house password. Turning it off narrows access, so any door may do that. The +// box cannot verify the app's step-up, so the grant asks for none and the +// app's consent screen claims no more than an owner's session. Statistics are +// a second consent under the grant, behind the same doors. + +// CloudGrant is the box's FTW Cloud link as the routes see it. +type CloudGrant interface { + Status() cloudlink.Status + Preview() cloudlink.Preview + Grant(cloudlink.GrantRequest) error + Revoke(by string) error + Stats(cloudlink.StatsRequest) error + StopStats(by string) error +} + +func (s *Server) cloudRoutes() { + s.handle("GET /api/cloud", Read, s.handleCloudStatus) + s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview) + 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) + s.handle("DELETE /api/cloud/stats", Configure, s.handleCloudStopStats, NoStepUp) +} + +// cloudGate admits an owner's session, and on the LAN a local client. With +// toGrant, the LAN needs more: the box itself, or the house password. +func (s *Server) cloudGate(w http.ResponseWriter, r *http.Request, toGrant bool) bool { + if hasForwardingHeader(r.Header) { + writeAppLinkError(w, http.StatusForbidden, "FTW Cloud is managed in the app or on your home network") + return false + } + if appLinkOverSession(r) { + caller, _ := apiauth.FromRequest(r) + if caller.Role != apiauth.RoleOwner { + writeAppLinkError(w, http.StatusForbidden, "only this home's owner can see or change FTW Cloud") + return false + } + return true + } + auth, err := parseAuthority(r.Host) + if err != nil || !isLocalAuthority(auth) || !isLocalClient(r.RemoteAddr) { + writeAppLinkError(w, http.StatusForbidden, "FTW Cloud is managed in the app or on your home network") + return false + } + if !toGrant || isLoopbackClient(r.RemoteAddr) { + return true + } + if ok, checked := lanSecretFrom(r.Context()); checked && ok { + return true + } + writeAppLinkError(w, http.StatusForbidden, + "turn FTW Cloud on in the app, or on your home network with the house password on") + return false +} + +// cloudCaller names the door for the audit line. +func cloudCaller(r *http.Request) string { + if caller, named := apiauth.FromRequest(r); named && caller.Kind != apiauth.KindLAN { + return caller.Subject + } + if isLoopbackClient(r.RemoteAddr) { + return "box" + } + return "lan" +} + +func (s *Server) cloudLink(w http.ResponseWriter) (CloudGrant, bool) { + if s.deps == nil || s.deps.Cloud == nil { + writeAppLinkError(w, http.StatusServiceUnavailable, "FTW Cloud is not available on this box") + return nil, false + } + return s.deps.Cloud, true +} + +// GET /api/cloud — the grant, the link and the bytes sent in the last 24 +// hours of uptime. +func (s *Server) handleCloudStatus(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + writeJSON(w, http.StatusOK, link.Status()) +} + +// GET /api/cloud/preview — the exact bytes that leave the house: the last +// frame of each class, or before a grant what the box would send. +func (s *Server) handleCloudPreview(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + writeJSON(w, http.StatusOK, link.Preview()) +} + +// POST /api/cloud/grant {scopes, policy} +func (s *Server) handleCloudGrant(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, true) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + var req cloudlink.GrantRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeAppLinkError(w, http.StatusBadRequest, "send {scopes, policy}") + return + } + req.By = cloudCaller(r) + switch err := link.Grant(req); { + case err == nil: + writeJSON(w, http.StatusOK, link.Status()) + case errors.Is(err, cloudlink.ErrGrantRequest): + writeAppLinkError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, cloudlink.ErrPurgePending): + writeAppLinkError(w, http.StatusConflict, err.Error()) + case errors.Is(err, cloudlink.ErrNoService): + writeAppLinkError(w, http.StatusServiceUnavailable, err.Error()) + default: + writeAppLinkError(w, http.StatusInternalServerError, "could not turn FTW Cloud on") + } +} + +// DELETE /api/cloud/grant — FTW Cloud off, and the service asked to delete +// its copy. +func (s *Server) handleCloudRevoke(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + if err := link.Revoke(cloudCaller(r)); err != nil { + writeAppLinkError(w, http.StatusInternalServerError, "could not turn FTW Cloud off") + return + } + writeJSON(w, http.StatusOK, link.Status()) +} + +// POST /api/cloud/stats {policy} — contribute to statistics, a separate +// consent under a grant already on. Same gate as the grant. +func (s *Server) handleCloudStats(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, true) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + var req cloudlink.StatsRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeAppLinkError(w, http.StatusBadRequest, "send {policy}") + return + } + req.By = cloudCaller(r) + switch err := link.Stats(req); { + case err == nil: + writeJSON(w, http.StatusOK, link.Status()) + case errors.Is(err, cloudlink.ErrGrantRequest): + writeAppLinkError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, cloudlink.ErrNotGranted), errors.Is(err, cloudlink.ErrPurgePending): + writeAppLinkError(w, http.StatusConflict, err.Error()) + case errors.Is(err, cloudlink.ErrNoService): + writeAppLinkError(w, http.StatusServiceUnavailable, err.Error()) + default: + writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics on") + } +} + +// DELETE /api/cloud/stats — stop contributing to statistics. Any door, as for +// the grant. +func (s *Server) handleCloudStopStats(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + if err := link.StopStats(cloudCaller(r)); err != nil { + writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics off") + return + } + writeJSON(w, http.StatusOK, link.Status()) +} + +// CloudLoadpoints is the loadpoint state GET /api/loadpoints composes, and +// the control feedback beside it, for the FTW Cloud uplink. ok is false on a +// box without loadpoints. +// +// The decoration order is handleLoadpoints'. #1337 moves that sequence into +// one shared function; this should call it once that lands. +func (s *Server) CloudLoadpoints(now time.Time) (states []loadpoint.State, feedback []ControlFeedback, ok bool) { + if s.deps == nil || s.deps.Loadpoints == nil { + return nil, nil, false + } + states = s.deps.Loadpoints.States() + if s.deps.Tel != nil { + decorateLoadpointsWithVehicle(states, s.deps.Tel) + } + s.applyPlanningSoC(states, now) + s.decorateLoadpointsWithManual(states) + s.decorateLoadpointsWithBatteryBoost(states) + s.decorateLoadpointsWithPlan(states) + return states, s.controlFeedback(now), true +} diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go new file mode 100644 index 000000000..58be0f419 --- /dev/null +++ b/go/internal/api/api_cloud_test.go @@ -0,0 +1,383 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +type stubCloud struct { + granted []cloudlink.GrantRequest + revoked []string + stats []cloudlink.StatsRequest + stopped []string + err error +} + +func (c *stubCloud) Status() cloudlink.Status { return cloudlink.Status{State: "off"} } +func (c *stubCloud) Preview() cloudlink.Preview { return cloudlink.Preview{State: "off", Sample: true} } +func (c *stubCloud) Grant(req cloudlink.GrantRequest) error { + c.granted = append(c.granted, req) + return c.err +} +func (c *stubCloud) Revoke(by string) error { + c.revoked = append(c.revoked, by) + return nil +} +func (c *stubCloud) Stats(req cloudlink.StatsRequest) error { + c.stats = append(c.stats, req) + return c.err +} +func (c *stubCloud) StopStats(by string) error { + c.stopped = append(c.stopped, by) + return nil +} + +func viewerRequest(method, target, body string) *http.Request { + r := sessionRequest(method, target, body) + caller, _ := apiauth.FromRequest(r) + caller.Role = apiauth.RoleViewer + caller.Scopes = apiauth.NewScopeSet(apiauth.RoleScopes[apiauth.RoleViewer]...) + return r.WithContext(apiauth.WithCaller(r.Context(), caller)) +} + +func loopbackRequest(method, target, body string) *http.Request { + r := localRequest(method, target, body) + r.Host = "localhost:8080" + r.RemoteAddr = "127.0.0.1:5555" + return r +} + +const grantBody = `{"scopes":["ftw.live.read"],"policy":"2026-10"}` + +// Turning FTW Cloud on is an owner's act: over an app session, or at the box. +// A LAN client without the house password is refused, unlike the rest of the +// LAN API, because this sends the house out of it. +func TestWhoMayTurnFTWCloudOn(t *testing.T) { + cases := []struct { + name string + req *http.Request + want int + }{ + {"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK}, + {"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusForbidden}, + {"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK}, + {"a LAN client", localRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusForbidden}, + {"a LAN client with the house password", func() *http.Request { + r := localRequest(http.MethodPost, "/api/cloud/grant", grantBody) + return r.WithContext(withLANSecret(r.Context(), true)) + }(), http.StatusOK}, + {"through a proxy", func() *http.Request { + r := loopbackRequest(http.MethodPost, "/api/cloud/grant", grantBody) + r.Header.Set("X-Forwarded-For", "203.0.113.9") + return r + }(), http.StatusForbidden}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + w := httptest.NewRecorder() + s.handleCloudGrant(w, c.req) + if w.Code != c.want { + t.Fatalf("got %d, want %d: %s", w.Code, c.want, w.Body.String()) + } + if granted := len(cloud.granted) == 1; granted != (c.want == http.StatusOK) { + t.Fatalf("granted = %v with status %d", granted, w.Code) + } + }) + } +} + +// The audit line names the door, never anything the caller sent. +func TestTheGrantRecordsWhichDoorAsked(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + s.handleCloudGrant(httptest.NewRecorder(), sessionRequest(http.MethodPost, "/api/cloud/grant", + `{"scopes":["ftw.live.read"],"policy":"p","By":"someone else"}`)) + s.handleCloudGrant(httptest.NewRecorder(), loopbackRequest(http.MethodPost, "/api/cloud/grant", grantBody)) + if len(cloud.granted) != 2 || cloud.granted[0].By != "app:aaaa1111" || cloud.granted[1].By != "box" { + t.Fatalf("granted %+v", cloud.granted) + } +} + +// Turning it off narrows access, so the LAN may, without the password. +func TestAnyDoorMayTurnFTWCloudOff(t *testing.T) { + for name, req := range map[string]*http.Request{ + "owner over a session": sessionRequest(http.MethodDelete, "/api/cloud/grant", ""), + "a LAN client": localRequest(http.MethodDelete, "/api/cloud/grant", ""), + } { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + w := httptest.NewRecorder() + s.handleCloudRevoke(w, req) + if w.Code != http.StatusOK || len(cloud.revoked) != 1 { + t.Fatalf("%s: %d, revoked %v", name, w.Code, cloud.revoked) + } + } +} + +// Only an owner reads what leaves the house; a viewer sees no grant screen. +func TestOnlyAnOwnerReadsTheCloudStatusAndPreview(t *testing.T) { + s := New(&Deps{Cloud: &stubCloud{}}) + for _, target := range []string{"/api/cloud", "/api/cloud/preview"} { + handler := s.handleCloudStatus + if target == "/api/cloud/preview" { + handler = s.handleCloudPreview + } + w := httptest.NewRecorder() + handler(w, sessionRequest(http.MethodGet, target, "")) + if w.Code != http.StatusOK { + t.Fatalf("owner %s: %d", target, w.Code) + } + w = httptest.NewRecorder() + handler(w, viewerRequest(http.MethodGet, target, "")) + if w.Code != http.StatusForbidden { + t.Fatalf("viewer %s: %d", target, w.Code) + } + } +} + +func TestCloudRoutesSayWhenThereIsNoCloud(t *testing.T) { + s := New(&Deps{}) + w := httptest.NewRecorder() + s.handleCloudStatus(w, sessionRequest(http.MethodGet, "/api/cloud", "")) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("got %d", w.Code) + } +} + +func TestCloudGrantRefusalsKeepTheirMeaning(t *testing.T) { + for err, want := range map[error]int{ + cloudlink.ErrGrantRequest: http.StatusBadRequest, + cloudlink.ErrPurgePending: http.StatusConflict, + cloudlink.ErrNoService: http.StatusServiceUnavailable, + } { + s := New(&Deps{Cloud: &stubCloud{err: err}}) + w := httptest.NewRecorder() + s.handleCloudGrant(w, sessionRequest(http.MethodPost, "/api/cloud/grant", grantBody)) + if w.Code != want { + t.Errorf("%v: got %d, want %d", err, w.Code, want) + } + } +} + +// Through the router, so the tiers are the ones the passthrough reads. +func TestCloudRoutesAreRegisteredWithTheirTiers(t *testing.T) { + s := New(&Deps{Cloud: &stubCloud{}}) + for _, c := range []struct { + method, path string + tier apiauth.Tier + noStepUp bool + }{ + {http.MethodGet, "/api/cloud", apiauth.TierRead, false}, + {http.MethodGet, "/api/cloud/preview", apiauth.TierRead, false}, + {http.MethodPost, "/api/cloud/grant", apiauth.TierConfigure, true}, + {http.MethodDelete, "/api/cloud/grant", apiauth.TierConfigure, true}, + {http.MethodPost, "/api/cloud/stats", apiauth.TierConfigure, true}, + {http.MethodDelete, "/api/cloud/stats", apiauth.TierConfigure, true}, + } { + facts := s.Route(httptest.NewRequest(c.method, c.path, nil)) + if facts.Tier != c.tier || facts.NoStepUp != c.noStepUp { + t.Errorf("%s %s: %+v", c.method, c.path, facts) + } + } +} + +const statsBody = `{"policy":"stats-2026-10"}` + +// Statistics are a separate consent behind the grant's own door. +func TestWhoMayTurnStatisticsOn(t *testing.T) { + cases := []struct { + name string + req *http.Request + want int + }{ + {"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, + {"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden}, + {"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, + {"a LAN client", localRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden}, + {"a LAN client with the house password", func() *http.Request { + r := localRequest(http.MethodPost, "/api/cloud/stats", statsBody) + return r.WithContext(withLANSecret(r.Context(), true)) + }(), http.StatusOK}, + {"through a proxy", func() *http.Request { + r := loopbackRequest(http.MethodPost, "/api/cloud/stats", statsBody) + r.Header.Set("X-Forwarded-For", "203.0.113.9") + return r + }(), http.StatusForbidden}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + w := httptest.NewRecorder() + s.handleCloudStats(w, c.req) + if w.Code != c.want { + t.Fatalf("got %d, want %d: %s", w.Code, c.want, w.Body.String()) + } + if asked := len(cloud.stats) == 1; asked != (c.want == http.StatusOK) { + t.Fatalf("stats asked = %v with status %d", asked, w.Code) + } + }) + } +} + +func TestStatisticsRecordWhichDoorAskedAndThePolicy(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + s.handleCloudStats(httptest.NewRecorder(), sessionRequest(http.MethodPost, "/api/cloud/stats", + `{"policy":"stats-2026-10","By":"someone else"}`)) + if len(cloud.stats) != 1 || cloud.stats[0].By != "app:aaaa1111" || cloud.stats[0].Policy != "stats-2026-10" { + t.Fatalf("stats %+v", cloud.stats) + } + w := httptest.NewRecorder() + s.handleCloudStats(w, sessionRequest(http.MethodPost, "/api/cloud/stats", "not json")) + if w.Code != http.StatusBadRequest { + t.Fatalf("a body that is not JSON: %d", w.Code) + } +} + +func TestAnyDoorMayTurnStatisticsOff(t *testing.T) { + for name, req := range map[string]*http.Request{ + "owner over a session": sessionRequest(http.MethodDelete, "/api/cloud/stats", ""), + "a LAN client": localRequest(http.MethodDelete, "/api/cloud/stats", ""), + } { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + w := httptest.NewRecorder() + s.handleCloudStopStats(w, req) + if w.Code != http.StatusOK || len(cloud.stopped) != 1 { + t.Fatalf("%s: %d, stopped %v", name, w.Code, cloud.stopped) + } + } +} + +// A viewer's session sees no FTW Cloud screen and changes nothing in it, even +// where turning something off would narrow access. +func TestAViewerReachesNoCloudRoute(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + for _, c := range []struct { + method, target, body string + handler http.HandlerFunc + }{ + {http.MethodGet, "/api/cloud", "", s.handleCloudStatus}, + {http.MethodGet, "/api/cloud/preview", "", s.handleCloudPreview}, + {http.MethodPost, "/api/cloud/grant", grantBody, s.handleCloudGrant}, + {http.MethodDelete, "/api/cloud/grant", "", s.handleCloudRevoke}, + {http.MethodPost, "/api/cloud/stats", statsBody, s.handleCloudStats}, + {http.MethodDelete, "/api/cloud/stats", "", s.handleCloudStopStats}, + } { + w := httptest.NewRecorder() + c.handler(w, viewerRequest(c.method, c.target, c.body)) + if w.Code != http.StatusForbidden { + t.Errorf("viewer %s %s: %d", c.method, c.target, w.Code) + } + } + if len(cloud.granted)+len(cloud.revoked)+len(cloud.stats)+len(cloud.stopped) != 0 { + t.Fatalf("a viewer changed FTW Cloud: %+v", cloud) + } +} + +func TestStatisticsRefusalsKeepTheirMeaning(t *testing.T) { + for err, want := range map[error]int{ + cloudlink.ErrGrantRequest: http.StatusBadRequest, + cloudlink.ErrNotGranted: http.StatusConflict, + cloudlink.ErrPurgePending: http.StatusConflict, + cloudlink.ErrNoService: http.StatusServiceUnavailable, + } { + s := New(&Deps{Cloud: &stubCloud{err: err}}) + w := httptest.NewRecorder() + s.handleCloudStats(w, sessionRequest(http.MethodPost, "/api/cloud/stats", statsBody)) + if w.Code != want { + t.Errorf("%v: got %d, want %d", err, w.Code, want) + } + } +} + +// The consent screen's whole round trip on a real link: on, statistics on, +// GET shows both purposes, statistics off, FTW Cloud off. +func TestTheConsentRoundTripShowsThePurposes(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + link, err := cloudlink.New(cloudlink.Options{ + Site: cloudSiteStub{}, Roster: cloudRosterStub{}, KV: &cloudKV{m: map[string]string{}}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{&key.PublicKey}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + s := New(&Deps{Cloud: link}) + call := func(h http.HandlerFunc, method, target, body string, want int) cloudlink.Status { + t.Helper() + w := httptest.NewRecorder() + h(w, sessionRequest(method, target, body)) + if w.Code != want { + t.Fatalf("%s %s: %d, want %d: %s", method, target, w.Code, want, w.Body.String()) + } + var st cloudlink.Status + _ = json.Unmarshal(w.Body.Bytes(), &st) + return st + } + + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusConflict) + call(s.handleCloudGrant, http.MethodPost, "/api/cloud/grant", grantBody, http.StatusOK) + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusOK) + if st := call(s.handleCloudStatus, http.MethodGet, "/api/cloud", "", http.StatusOK); st.State != "on" || + strings.Join(st.Purposes, ",") != "serve,stats" { + t.Fatalf("status with statistics on: %+v", st) + } + if st := call(s.handleCloudStopStats, http.MethodDelete, "/api/cloud/stats", "", http.StatusOK); strings.Join(st.Purposes, ",") != "serve" { + t.Fatalf("status with statistics off: %+v", st) + } + if st := call(s.handleCloudRevoke, http.MethodDelete, "/api/cloud/grant", "", http.StatusOK); st.State != "revoking" { + t.Fatalf("status after FTW Cloud off: %+v", st) + } + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusConflict) +} + +type cloudSiteStub struct{} + +func (cloudSiteStub) Live() cloudlink.Live { return cloudlink.Live{} } +func (cloudSiteStub) Loadpoints() (cloudwire.LP, bool) { return cloudwire.LP{}, false } +func (cloudSiteStub) Prices() (cloudwire.Prices, bool) { return cloudwire.Prices{}, false } + +type cloudRosterStub struct{} + +func (cloudRosterStub) Devices() []cloudlink.RosterDevice { return nil } + +type cloudKV struct { + mu sync.Mutex + m map[string]string +} + +func (k *cloudKV) LoadConfig(key string) (string, bool) { + k.mu.Lock() + defer k.mu.Unlock() + v, ok := k.m[key] + return v, ok +} + +func (k *cloudKV) SaveConfig(key, value string) error { + k.mu.Lock() + defer k.mu.Unlock() + k.m[key] = value + return nil +} diff --git a/go/internal/api/api_notifications_push_test.go b/go/internal/api/api_notifications_push_test.go index 78e3e6373..3479c010c 100644 --- a/go/internal/api/api_notifications_push_test.go +++ b/go/internal/api/api_notifications_push_test.go @@ -334,8 +334,8 @@ func TestRulesGetShowsDefaultsAndStoresNothing(t *testing.T) { if e.Type != defaults[i].Type { t.Fatalf("event %d is %q, want %q", i, e.Type, defaults[i].Type) } - if e.Enabled { - t.Fatalf("default rule %q came back enabled", e.Type) + if e.Enabled != defaults[i].Enabled { + t.Fatalf("default rule %q came back enabled=%v", e.Type, e.Enabled) } } @@ -386,9 +386,12 @@ func TestRulesPutSeedsDefaultsOnFirstTouch(t *testing.T) { if notif == nil || len(notif.Events) < len(notifications.DefaultRules()) { t.Fatalf("defaults not seeded: %+v", notif) } + // Everything is opt-in except the FTW Cloud notices, which every owner + // should hear without having asked. + onByDefault := map[string]bool{notifications.PushCloudGranted: true, notifications.PushCloudRevoked: true} enabled := 0 for _, e := range notif.Events { - if e.Enabled { + if e.Enabled && !onByDefault[e.Type] { enabled++ if e.Type != notifications.PushUpdateInstalled { t.Fatalf("seeded rule %q came up enabled", e.Type) diff --git a/go/internal/apiauth/contract_gen.go b/go/internal/apiauth/contract_gen.go index b95645f18..515c5e6b7 100644 --- a/go/internal/apiauth/contract_gen.go +++ b/go/internal/apiauth/contract_gen.go @@ -22,6 +22,7 @@ var RoleScopes = map[string][]string{ "ftw.plan.read", "ftw.health.read", "ftw.assets.read", + "ftw.events.read", "ftw.dispatch.write", "ftw.mode.write", "ftw.members.read", diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 9d470b032..9763331d3 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -192,6 +192,9 @@ type Identity struct { // randRead is the entropy source. Injectable so a test can prove the // failure path instead of only the happy one. randRead func([]byte) (int, error) + // onChange hears about every change to who is paired and what they + // may do, after it is saved. Not about lastSeen stamps. + onChange []func() } // appMeta is what the box remembers about one paired phone. @@ -587,7 +590,9 @@ func (i *Identity) Authorise(appStatic, payload []byte) (Grant, error) { i.pairing = nil i.mu.Unlock() - return Grant{DeviceID: deviceID(key), Role: role, Epoch: firstEpoch}, i.save() + err := i.save() + i.changed() + return Grant{DeviceID: deviceID(key), Role: role, Epoch: firstEpoch}, err } // hasOwnerLocked reports whether any enrolment can still administer this box. @@ -694,7 +699,9 @@ func (i *Identity) SetRole(id, role string) error { meta.epoch++ i.mu.Unlock() - return i.save() + err := i.save() + i.changed() + return err } // Devices lists every paired phone, most recently seen first. @@ -773,7 +780,9 @@ func (i *Identity) Revoke(id string, from Presence) ([]byte, error) { delete(i.authorised, fullKey) i.mu.Unlock() - if err := i.save(); err != nil { + err := i.save() + i.changed() + if err != nil { return nil, err } raw, err := base64.RawURLEncoding.DecodeString(fullKey) @@ -785,6 +794,49 @@ func (i *Identity) Revoke(id string, from Presence) ([]byte, error) { return raw, nil } +// RosterEntry is one paired phone with its whole key, for the roster the box +// signs for FTW Cloud. The service learns devices from nothing else, and it +// needs the key to recognise the phone's Noise handshake. +type RosterEntry struct { + ID string + Key []byte + Role string + Epoch uint64 +} + +// Roster lists every paired phone, sorted by id. +func (i *Identity) Roster() []RosterEntry { + i.mu.Lock() + out := make([]RosterEntry, 0, len(i.authorised)) + for key, meta := range i.authorised { + raw, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + continue + } + out = append(out, RosterEntry{ID: deviceID(key), Key: raw, Role: meta.role, Epoch: meta.epoch}) + } + i.mu.Unlock() + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out +} + +// OnChange registers fn to run after a pairing, a role change or a revoke has +// been saved. A reconnect's lastSeen stamp is not a change. +func (i *Identity) OnChange(fn func()) { + i.mu.Lock() + defer i.mu.Unlock() + i.onChange = append(i.onChange, fn) +} + +func (i *Identity) changed() { + i.mu.Lock() + fns := append([]func(){}, i.onChange...) + i.mu.Unlock() + for _, fn := range fns { + fn() + } +} + // AuthorisedCount is for the API and for tests. The keys themselves stay here. func (i *Identity) AuthorisedCount() int { i.mu.Lock() diff --git a/go/internal/appenroll/roles_test.go b/go/internal/appenroll/roles_test.go index 7e54562d7..d1342d08b 100644 --- a/go/internal/appenroll/roles_test.go +++ b/go/internal/appenroll/roles_test.go @@ -657,3 +657,60 @@ func TestABoxCodeExpiresSoonerThanAScannedOne(t *testing.T) { t.Fatalf("an expired box code was redeemed: %v", err) } } + +// -------------------------------------------------------------------------- +// The roster FTW Cloud learns devices from +// -------------------------------------------------------------------------- + +// Every change to who is paired, and as what, is heard about after it is +// saved; a reconnect's lastSeen stamp is not. The roster carries whole keys, +// roles and epochs, because the service has nothing else to go on. +func TestRosterChangesAreHeardAndStampsAreNot(t *testing.T) { + id, _ := newIdentity(t) + changes := 0 + id.OnChange(func() { changes++ }) + + owner := pair(t, id, apiauth.RoleOwner) + viewerKey := appKey(t) + code, _, _ := id.MintPairingCode(apiauth.RoleViewer, InviteTTL) + viewer, err := id.Authorise(viewerKey, code) + if err != nil { + t.Fatal(err) + } + if changes != 2 { + t.Fatalf("two pairings made %d changes", changes) + } + + if _, err := id.Authorise(viewerKey, nil); err != nil { + t.Fatal(err) + } + if changes != 2 { + t.Fatal("a reconnect counted as a roster change") + } + + roster := id.Roster() + if len(roster) != 2 { + t.Fatalf("roster %+v", roster) + } + for _, r := range roster { + if r.ID == viewer.DeviceID && (r.Role != apiauth.RoleViewer || string(r.Key) != string(viewerKey) || r.Epoch != 1) { + t.Fatalf("viewer entry %+v", r) + } + } + + if err := id.SetRole(viewer.DeviceID, apiauth.RoleOwner); err != nil { + t.Fatal(err) + } + if err := id.SetRole(viewer.DeviceID, apiauth.RoleOwner); err != nil { + t.Fatal(err) + } + if changes != 3 { + t.Fatalf("one real role change made %d changes in all", changes) + } + if _, err := id.Revoke(owner.DeviceID, AtTheBox); err != nil { + t.Fatal(err) + } + if changes != 4 || len(id.Roster()) != 1 { + t.Fatalf("revoke: %d changes, roster %+v", changes, id.Roster()) + } +} diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index 5249ce449..979d173ea 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -53,6 +53,10 @@ type Registry struct { Desc string `yaml:"desc"` } `yaml:"errors"` SourceStates []string `yaml:"source_states"` + // CloudScopes and CloudPurposes bound what an owner can let the box share + // with FTW Cloud, and what for. + CloudScopes []string `yaml:"cloud_scopes"` + CloudPurposes []string `yaml:"cloud_purposes"` Resolutions map[string]struct { TileSpanMs int64 `yaml:"tile_span_ms"` RetentionDays int `yaml:"retention_days"` @@ -78,6 +82,7 @@ func Generate(raw []byte) ([]byte, error) { writeFields(&b, reg) writeCaps(&b, reg) writeScopes(&b, reg) + writeCloud(&b, reg) writeOps(&b, reg) writeErrors(&b, reg) writeSourceStates(&b, reg) @@ -185,6 +190,32 @@ func writeScopes(b *bytes.Buffer, reg Registry) { b.WriteString("}\n\n") } +// writeCloud emits what an FTW Cloud grant may name. The scopes are listed +// by their constants, so a cloud scope the registry does not declare as a +// scope fails to compile rather than being shared under a misspelled name. +func writeCloud(b *bytes.Buffer, reg Registry) { + b.WriteString("// CloudScopes are the scopes an FTW Cloud grant may name.\n") + b.WriteString("var CloudScopes = []string{\n") + for _, s := range reg.CloudScopes { + fmt.Fprintf(b, "\tScope%s,\n", goName(strings.TrimPrefix(s, "ftw."))) + } + b.WriteString("}\n\n") + + b.WriteString("// What an FTW Cloud grant lets the service use the data for.\n") + b.WriteString("const (\n") + for _, p := range reg.CloudPurposes { + fmt.Fprintf(b, "\tCloudPurpose%s = %q\n", goName(p), p) + } + b.WriteString(")\n\n") + + b.WriteString("// CloudPurposes is every purpose an FTW Cloud grant may name.\n") + b.WriteString("var CloudPurposes = []string{\n") + for _, p := range reg.CloudPurposes { + fmt.Fprintf(b, "\tCloudPurpose%s,\n", goName(p)) + } + b.WriteString("}\n\n") +} + // writeOps emits the operation table as data rather than constants. The op // names the box uses are the hand-written ones in messages.go, read back by // TestRegistryOpsMatchCommandTable — generating a constant for an op no code diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index 992e09d6d..8627b3ac2 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -68,6 +68,12 @@ type Config struct { // silently hiding a feature in the app. Caps []string + // Cloud answers hello_ok's FTW Cloud session information, or nil while + // FTW Cloud is off. Asked once per hello, for every role: a viewer reads + // FTW Cloud too and checks the box's signatures the same way. Nil on a + // box without the uplink. + Cloud func() *CloudSession + // Source ids for the three frozen power fields. The dictionary points // each reading at one of these, which is how the app turns a number // into a freshness claim. @@ -373,11 +379,14 @@ func (h *Handler) onHello(env Envelope) error { caps := h.cfg.Caps hint := "" + var cloud *CloudSession if proto == ProtoFloor { // Floor mode speaks the frozen subset and nothing else. Advertising // more would invite an app that cannot use it to try. caps = []string{CapStatusCore} hint = HintAppUpdate + } else if h.cfg.Cloud != nil { + cloud = h.cfg.Cloud() } source, syncedAtMs := h.cfg.Clock.Sync() @@ -401,6 +410,7 @@ func (h *Handler) onHello(env Envelope) error { Role: h.cfg.Caller.Role, Scopes: h.cfg.Caller.Scopes.Names(), Subscribed: fastSub, + Cloud: cloud, } h.mu.Lock() diff --git a/go/internal/appproto/handshake_test.go b/go/internal/appproto/handshake_test.go index e9bfd4db7..5d843f84e 100644 --- a/go/internal/appproto/handshake_test.go +++ b/go/internal/appproto/handshake_test.go @@ -1,8 +1,10 @@ package appproto import ( + "reflect" "testing" + "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/control" ) @@ -254,3 +256,75 @@ func TestSubWhileBootingIsRefusedWithARetryableCode(t *testing.T) { t.Fatal("a booting box accepted a subscription") } } + +// Every role reads FTW Cloud, so every role learns the box's key, home id, +// boot and names. The hook answers nil while FTW Cloud is off, and hello_ok +// then says nothing about it. +func TestHelloOKCarriesTheCloudSessionForEveryRole(t *testing.T) { + session := &CloudSession{ + PK: append([]byte{4}, make([]byte, 64)...), Home: "abcdefghijklmnopqrstuvwxyz", + Boot: make([]byte, 16), + Sources: map[string]string{"AAAAAAAAAAA": "meter.p1"}, + Loadpoints: map[string]string{"BBBBBBBBBBB": "garage"}, + } + for _, c := range []struct { + name string + caller apiauth.Caller + hook func() *CloudSession + want *CloudSession + }{ + {"owner", ownerCaller(), func() *CloudSession { return session }, session}, + {"viewer", viewerCaller(), func() *CloudSession { return session }, session}, + {"FTW Cloud off", ownerCaller(), func() *CloudSession { return nil }, nil}, + {"no uplink", viewerCaller(), nil, nil}, + } { + t.Run(c.name, func(t *testing.T) { + h, _, rec, _ := newRig(t) + h.cfg.Caller, h.cfg.Cloud = c.caller, c.hook + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: 0, Max: ProtoMax}}) + ok := body[HelloOK](t, rec.only(t, MsgHelloOK)) + if !reflect.DeepEqual(ok.Cloud, c.want) { + t.Fatalf("cloud = %+v, want %+v", ok.Cloud, c.want) + } + }) + } +} + +// An app too old for full mode gets the frozen subset, and FTW Cloud is not in +// it. +func TestFloorModeLeavesTheCloudSessionOut(t *testing.T) { + h, _, rec, _ := newRig(t) + h.cfg.Cloud = func() *CloudSession { + t.Fatal("floor mode asked for FTW Cloud's session information") + return nil + } + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: 0, Max: ProtoFloor}}) + if ok := body[HelloOK](t, rec.only(t, MsgHelloOK)); ok.Cloud != nil { + t.Fatalf("floor hello_ok carries %+v", ok.Cloud) + } +} + +// The field rides under one key, so an older app skips it whole. +func TestTheCloudSessionWireNames(t *testing.T) { + raw, err := Marshal(HelloOK{Cloud: &CloudSession{PK: []byte{4}, Home: "h", Boot: []byte{1}, + Sources: map[string]string{"s": "d"}, Loadpoints: map[string]string{"l": "p"}}}) + if err != nil { + t.Fatal(err) + } + var generic map[string]any + if err := Unmarshal(raw, &generic); err != nil { + t.Fatal(err) + } + cloud, ok := generic["cloud"].(map[any]any) + if !ok { + t.Fatalf("hello_ok keys %v", generic) + } + for _, k := range []string{"pk", "home", "boot", "sources", "loadpoints"} { + if _, ok := cloud[k]; !ok { + t.Fatalf("cloud has no %q: %v", k, cloud) + } + } + if len(cloud) != 5 { + t.Fatalf("cloud has %d keys: %v", len(cloud), cloud) + } +} diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 348a75500..317ea3e14 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -106,6 +106,7 @@ type ( ErrorBody = protocol.ErrorBody Event = protocol.Event SessionTerminate = protocol.SessionTerminate + CloudSession = protocol.CloudSession ) const ( @@ -213,6 +214,7 @@ const ( CapNetWebrtc = protocol.CapNetWebrtc CapPriceSpot = protocol.CapPriceSpot CapApiPassthrough = protocol.CapApiPassthrough + CapCloudLink = protocol.CapCloudLink ScopeLiveRead = protocol.ScopeLiveRead ScopeHistoryRead = protocol.ScopeHistoryRead ScopePlanRead = protocol.ScopePlanRead diff --git a/go/internal/appproto/telemetry.go b/go/internal/appproto/telemetry.go index b84559fda..39b63eb46 100644 --- a/go/internal/appproto/telemetry.go +++ b/go/internal/appproto/telemetry.go @@ -4,6 +4,7 @@ import ( "math" "strconv" + "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/units" ) @@ -68,6 +69,13 @@ func wireSources(snap Snapshot, uptimeMs int64) map[string]SourceWire { return out } +// LiveView is the frozen fields and the graded source table exactly as a +// session renders them. The FTW Cloud uplink sends the same numbers, so it +// reads them here rather than computing a second set. +func LiveView(snap Snapshot, uptimeMs int64) (map[string]int64, map[string]SourceWire) { + return fieldValues(snap, wireModes(control.ModeCatalog())), wireSources(snap, uptimeMs) +} + // modeIndex is field 1's value: the mode's position in the catalogue that // hello_ok sent. // diff --git a/go/internal/backup/archive.go b/go/internal/backup/archive.go index 458c580f4..d71b2d463 100644 --- a/go/internal/backup/archive.go +++ b/go/internal/backup/archive.go @@ -347,6 +347,7 @@ func backupExtraBytes(sources []sourceEntry) (int64, error) { func collectSources(dataDir, statePath, outputDir string) ([]sourceEntry, error) { stateRel, _ := filepath.Rel(dataDir, statePath) cacheRel, _ := filepath.Rel(dataDir, filepath.Join(filepath.Dir(statePath), "cache.db")) + cloudKeyRel, _ := filepath.Rel(dataDir, filepath.Join(filepath.Dir(statePath), "cloud.key")) outputRel, outputInside := filepath.Rel(dataDir, outputDir) if outputInside != nil || outputRel == ".." || strings.HasPrefix(outputRel, ".."+string(filepath.Separator)) { outputRel = "" @@ -407,6 +408,12 @@ func collectSources(dataDir, statePath, outputDir string) ([]sourceEntry, error) if strings.HasPrefix(rel, stateRel+".snapshot") || rel == cacheRel || strings.HasPrefix(rel, cacheRel+"-") { return nil } + // FTW Cloud's key never goes into a backup. A box restored elsewhere + // must not speak for the old box's home: its grant reads as off until + // an owner turns FTW Cloud on again, under a new home id. + if rel == cloudKeyRel || rel == cloudKeyRel+".tmp" { + return nil + } info, err := d.Info() if err != nil { return err diff --git a/go/internal/backup/derived_files_test.go b/go/internal/backup/derived_files_test.go index 78fb759dc..d97c155bd 100644 --- a/go/internal/backup/derived_files_test.go +++ b/go/internal/backup/derived_files_test.go @@ -39,3 +39,21 @@ func TestCollectSourcesSkipsDerivedStateFiles(t *testing.T) { t.Fatalf("collected %v, want %v", got, want) } } + +// cloud.key stays on the box that made it. A backup carries the grant record +// in state.db, which then reads as off on whatever box restores it. +func TestCollectSourcesNeverTakesTheCloudKey(t *testing.T) { + dataDir := t.TempDir() + for _, name := range []string{"config.yaml", "cloud.key", "cloud.key.tmp"} { + if err := os.WriteFile(filepath.Join(dataDir, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + sources, err := collectSources(dataDir, filepath.Join(dataDir, "state.db"), filepath.Join(dataDir, "backups")) + if err != nil { + t.Fatal(err) + } + if len(sources) != 1 || sources[0].archivePath != "data/config.yaml" { + t.Fatalf("collected %+v", sources) + } +} diff --git a/go/internal/cloudlink/clock_linux.go b/go/internal/cloudlink/clock_linux.go new file mode 100644 index 000000000..b8d87cd49 --- /dev/null +++ b/go/internal/cloudlink/clock_linux.go @@ -0,0 +1,16 @@ +package cloudlink + +import "golang.org/x/sys/unix" + +// ClockSynced asks the kernel whether NTP has synchronised the clock, the +// same question timedatectl answers. A box without an RTC reads 1970 until +// it has, and a wall time from then must not anchor any age. +func ClockSynced() bool { + var tx unix.Timex + state, err := unix.Adjtimex(&tx) + if err != nil || state == unix.TIME_ERROR { + return false + } + // systemd's rule: synchronised, with an error bound under 16 s. + return tx.Status&unix.STA_UNSYNC == 0 && tx.Maxerror < 16_000_000 +} diff --git a/go/internal/cloudlink/clock_other.go b/go/internal/cloudlink/clock_other.go new file mode 100644 index 000000000..6e9ce9dd3 --- /dev/null +++ b/go/internal/cloudlink/clock_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package cloudlink + +import "time" + +// ClockSynced has no kernel to ask outside Linux, where boxes run. A +// development machine keeps its own time, so a clock past 2025 is taken as +// set. A wrong answer only makes data look older on the phone: rule N takes +// the earlier of the box's wall time and the arrival, and drops a box clock +// that runs ahead. +func ClockSynced() bool { + return time.Now().Year() >= 2025 +} diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go new file mode 100644 index 000000000..4559c59a6 --- /dev/null +++ b/go/internal/cloudlink/conn.go @@ -0,0 +1,317 @@ +package cloudlink + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "errors" + "fmt" + "math/big" + "slices" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +const ( + dialTimeout = 15 * time.Second + // writeTimeout: a write blocked this long is a dead socket. + writeTimeout = 10 * time.Second + // readTimeout is three of the service's 15 s pings. + readTimeout = 45 * time.Second + // handshakeTimeout bounds challenge and hello_ok. + handshakeTimeout = 15 * time.Second + // purgeWait is how long a revoking box waits for the receipt before it + // hangs up and tries again later. + purgeWait = 30 * time.Second + + backoffBase = 500 * time.Millisecond + backoffCap = 60 * time.Second +) + +// errService is the service breaking the protocol. The link closes and backs +// off; nothing the service says can do more than that. +var errService = errors.New("cloudlink: the service broke the protocol") + +func (l *Link) dialer() *websocket.Dialer { + return &websocket.Dialer{ + // No proxy, whatever the environment says. A proxy that ends TLS + // breaks the handshake binding by design, and one that does not + // adds nothing. + Proxy: nil, + HandshakeTimeout: dialTimeout, + TLSClientConfig: l.tlsConfig(), + EnableCompression: false, + } +} + +// once dials, serves until the connection ends and says how long to wait. +func (l *Link) once(ctx context.Context, snap grantSnapshot) (time.Duration, error) { + dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) + conn, resp, err := l.dialer().DialContext(dialCtx, l.opts.Endpoint, nil) + cancel() + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + return l.backoff(), fmt.Errorf("dialling FTW Cloud: %w", err) + } + defer conn.Close() + + connectedAt := time.Now() + err = l.serve(ctx, conn, snap) + l.mu.Lock() + l.connected = false + if time.Since(connectedAt) >= backoffCap { + l.attempt = 0 + } + l.mu.Unlock() + if errors.Is(err, errPurged) || errors.Is(err, errClosed) { + return 0, nil + } + return l.backoff(), err +} + +// backoff is full jitter from 0.5 s to 60 s. +func (l *Link) backoff() time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + ceiling := min(backoffBase< rosterSeq { + // A restore from an older backup: move past the service's number + // so revokes made since then still arrive. + l.bumpRoster(ok.RosterSeq) + } + + l.mu.Lock() + l.connected = true + l.mu.Unlock() + l.log.Info("FTW Cloud uplink connected", "home", snap.home) + + events := make(chan cloudwire.Envelope, 4) + readErr := make(chan error, 1) + go func() { + readErr <- l.read(conn, snap, events) + }() + sendErr := make(chan error, 1) + go func() { + sendErr <- w.run(ctx, events) + }() + + select { + case err := <-readErr: + cancel() + <-sendErr + return err + case err := <-sendErr: + cancel() + <-readErr + return err + } +} + +// exporterValue is the TLS 1.3 exporter value both ends bind the handshake to. +func exporterValue(conn *websocket.Conn) ([]byte, error) { + tc, ok := conn.NetConn().(*tls.Conn) + if !ok { + return nil, errors.New("cloudlink: the uplink is not TLS") + } + cs := tc.ConnectionState() + if cs.Version != tls.VersionTLS13 { + return nil, errors.New("cloudlink: the uplink is not TLS 1.3") + } + return cs.ExportKeyingMaterial(cloudwire.EKMLabel, nil, cloudwire.EKMBytes) +} + +func (l *Link) verifiedByService(home string, env cloudwire.Envelope) bool { + for _, k := range l.opts.ServiceKeys { + if cloudwire.Verify(k, home, env) == nil { + return true + } + } + return false +} + +func (l *Link) clockName() string { + if l.clockSynced() { + return cloudwire.ClockNTP + } + return cloudwire.ClockNone +} + +// readEnvelope reads one binary frame. A text message, a lane 0 frame or a +// frame over 4 KiB is the service breaking the protocol. +func readEnvelope(conn *websocket.Conn) (cloudwire.Envelope, error) { + kind, msg, err := conn.ReadMessage() + if err != nil { + return cloudwire.Envelope{}, err + } + return parseDownlink(kind, msg) +} + +func parseDownlink(kind int, msg []byte) (cloudwire.Envelope, error) { + if kind != websocket.BinaryMessage { + return cloudwire.Envelope{}, fmt.Errorf("%w: a text message", errService) + } + if len(msg) > cloudwire.MaxServiceFrame { + return cloudwire.Envelope{}, fmt.Errorf("%w: %d bytes", errService, len(msg)) + } + payload, err := cloudwire.DecodeFrame(msg) + if err != nil { + return cloudwire.Envelope{}, fmt.Errorf("%w: %v", errService, err) + } + env, err := cloudwire.DecodeEnvelope(payload) + if err != nil { + return cloudwire.Envelope{}, fmt.Errorf("%w: %v", errService, err) + } + if !slices.Contains(cloudwire.Downlink, env.T) { + return cloudwire.Envelope{}, fmt.Errorf("%w: %q is not a message the service may send", errService, env.T) + } + return env, nil +} + +// read is the downlink. After the handshake the service may send watch, +// resync and purged; challenge and hello_ok a second time are refused like +// any stranger. watch and resync go to the sender, which owns the cadence. +// A purge receipt is taken here, before the service hangs up, so the box +// never misses one it was sent. +func (l *Link) read(conn *websocket.Conn, snap grantSnapshot, events chan<- cloudwire.Envelope) error { + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + conn.SetPingHandler(func(data string) error { + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + return conn.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(writeTimeout)) + }) + for { + env, err := readEnvelope(conn) + if err != nil { + return err + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + switch env.T { + case cloudwire.MsgPurged: + if l.acceptPurged(snap, env) { + return errPurged + } + case cloudwire.MsgWatch, cloudwire.MsgResync: + events <- env + default: + return fmt.Errorf("%w: %q after the handshake", errService, env.T) + } + } +} + +// writer is the one goroutine that writes data frames on a connection. +type writer struct { + conn *websocket.Conn + link *Link + mu sync.Mutex +} + +// send seals body as type t, frames it and writes it. The byte budget is +// the caller's to check. +func (w *writer) send(snap grantSnapshot, t string, body any) error { + raw, err := cloudwire.Marshal(body) + if err != nil { + return err + } + env, err := cloudwire.SealBytes(snap.key, snap.home, t, raw) + if err != nil { + return err + } + payload, err := env.Encode() + if err != nil { + return err + } + frame, err := cloudwire.EncodeFrame(payload) + if err != nil { + return err + } + + w.mu.Lock() + defer w.mu.Unlock() + if err := w.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return err + } + if err := w.conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return err + } + l := w.link + l.mu.Lock() + l.lastSend = time.Now() + l.budget.add(l.uptimeMs(), int64(len(frame))) + l.sent[t] = sentFrame{T: t, Frame: frame, AtMs: l.uptimeMs()} + l.mu.Unlock() + return nil +} diff --git a/go/internal/cloudlink/downlink_test.go b/go/internal/cloudlink/downlink_test.go new file mode 100644 index 000000000..c99447552 --- /dev/null +++ b/go/internal/cloudlink/downlink_test.go @@ -0,0 +1,74 @@ +package cloudlink + +import ( + "bytes" + "slices" + "testing" + + "github.com/gorilla/websocket" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +func frameOf(t testing.TB, typ string, body any) []byte { + t.Helper() + env, err := cloudwire.Seal(nil, "", typ, body) + if err != nil { + t.Fatal(err) + } + raw, _ := env.Encode() + frame, _ := cloudwire.EncodeFrame(raw) + return frame +} + +func TestDownlinkIsAClosedSet(t *testing.T) { + for _, typ := range cloudwire.Downlink { + if _, err := parseDownlink(websocket.BinaryMessage, frameOf(t, typ, map[string]int{})); err != nil { + t.Errorf("%s refused: %v", typ, err) + } + } + // What a service might try instead: the box's own messages, the app + // protocol's command, and anything new. + for _, typ := range []string{"cmd", "api.req", "hello", "live", "config", "update", "time", "ping"} { + if _, err := parseDownlink(websocket.BinaryMessage, frameOf(t, typ, map[string]int{})); err == nil { + t.Errorf("%s accepted", typ) + } + } + if _, err := parseDownlink(websocket.TextMessage, frameOf(t, cloudwire.MsgWatch, cloudwire.Watch{Hz: 1})); err == nil { + t.Error("a text message was accepted") + } + big := frameOf(t, cloudwire.MsgResync, cloudwire.Resync{What: []string{string(bytes.Repeat([]byte("x"), 5000))}}) + if _, err := parseDownlink(websocket.BinaryMessage, big); err == nil { + t.Error("a frame over 4 KiB was accepted") + } + lane0 := frameOf(t, cloudwire.MsgWatch, cloudwire.Watch{Hz: 1}) + lane0[1] = 0 + if _, err := parseDownlink(websocket.BinaryMessage, lane0); err == nil { + t.Error("a lane 0 frame was accepted") + } +} + +// Whatever the service sends, the box either refuses it or reads one of the +// five types, and never panics. +func FuzzDownlink(f *testing.F) { + for _, typ := range append(slices.Clone(cloudwire.Downlink), "cmd") { + f.Add(true, frameOf(f, typ, map[string]any{"hz": 1, "what": []string{"full"}})) + } + f.Add(false, []byte("ready")) + f.Fuzz(func(t *testing.T, binary bool, msg []byte) { + kind := websocket.TextMessage + if binary { + kind = websocket.BinaryMessage + } + env, err := parseDownlink(kind, msg) + if err != nil { + return + } + if !slices.Contains(cloudwire.Downlink, env.T) || len(msg) > cloudwire.MaxServiceFrame { + t.Fatalf("accepted %q in %d bytes", env.T, len(msg)) + } + // The bodies the sender reads must not panic either. + _ = cloudwire.Unmarshal(env.B, &cloudwire.Watch{}) + _ = cloudwire.Unmarshal(env.B, &cloudwire.Resync{}) + _ = cloudwire.Unmarshal(env.B, &cloudwire.Purged{}) + }) +} diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go new file mode 100644 index 000000000..3ad8cce27 --- /dev/null +++ b/go/internal/cloudlink/events.go @@ -0,0 +1,196 @@ +package cloudlink + +import ( + "encoding/base64" + "errors" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Events and the last word. +// +// The box's notifications reach FTW Cloud as signed events, which the +// service wraps unchanged in an alert for the owners' phones. QueueEvent only +// queues; the writer signs and sends on its own goroutine, ahead of anything +// else due on that tick. An event waits at most cloudwire.AlertMaxAgeMs for +// the uplink: older, a phone would show it without its text and buttons, so +// it is dropped instead. A reconnect sends the events still waiting and +// replays none that left. + +// EventIn is one notification the box raised, before ids are cut. +type EventIn struct { + Kind string + // Priority is the rule's, 1 to 5. It becomes the event's severity. + Priority int + // Args are the catalogue's placeholders. Names are never sent: the + // link drops the name placeholders and sends the device's opaque id. + Args map[string]string + // Driver and Loadpoint are the box's own names for what the event + // concerns, empty when it concerns neither. + Driver string + Loadpoint string + At time.Time +} + +const ( + // eventRing is how many events wait for the uplink. When it is full + // the oldest goes, and the drop is counted. + eventRing = 32 + // eventIDBytes makes a 12-character event id. + eventIDBytes = 9 +) + +// nameArgs are catalogue placeholders that hold a name. The phone fills +// them from the table it holds from the box. +var nameArgs = map[string]bool{"name": true} + +// errClosed ends the uplink after its bye, as the box shuts down. +var errClosed = errors.New("cloudlink: the box is shutting down") + +// QueueEvent keeps an event for the uplink to sign and send. It never +// blocks. False means FTW Cloud will not carry it: off, being revoked, or a +// grant that leaves events out. +func (l *Link) QueueEvent(e EventIn) bool { + l.mu.Lock() + g := l.grant + if g == nil || g.Revoked || !g.has(protocol.ScopeEventsRead) || l.closing { + l.mu.Unlock() + return false + } + if e.At.IsZero() { + e.At = time.Now() + } + if len(l.events) >= eventRing { + l.events = l.events[1:] + l.eventsDropped++ + } + l.events = append(l.events, e) + l.mu.Unlock() + l.signal() + return true +} + +// sendEvents signs and sends what waits, oldest first. +func (w *writer) sendEvents(snap grantSnapshot, now time.Time) error { + l := w.link + if !snap.allowed(cloudwire.MsgEvent) { + return nil + } + l.mu.Lock() + queued := l.events + l.events = nil + l.mu.Unlock() + for i, e := range queued { + if now.Sub(e.At) > cloudwire.AlertMaxAgeMs*time.Millisecond { + l.mu.Lock() + l.eventsDropped++ + l.mu.Unlock() + continue + } + ev, err := l.projectEvent(snap, e) + if err != nil { + return err + } + if _, err := w.sendBody(snap, cloudwire.MsgEvent, &ev, true); err != nil { + // The one that failed may have left; the rest have not. + l.mu.Lock() + l.events = append(queued[i+1:len(queued):len(queued)], l.events...) + l.mu.Unlock() + return err + } + } + return nil +} + +// projectEvent cuts opaque ids and drops names. +func (l *Link) projectEvent(snap grantSnapshot, e EventIn) (cloudwire.Event, error) { + id, err := randomBytes(eventIDBytes) + if err != nil { + return cloudwire.Event{}, err + } + gk := snap.g.GrantKey + ev := cloudwire.Event{ + EventID: base64.RawURLEncoding.EncodeToString(id), Kind: e.Kind, + Severity: severity(e.Priority), Args: map[string]string{}, + } + for k, v := range e.Args { + if !nameArgs[k] { + ev.Args[k] = v + } + } + if e.Driver != "" { + ev.Src = cloudwire.SourceID(gk, e.Driver) + } + if e.Loadpoint != "" { + ev.LP = cloudwire.LoadpointID(gk, e.Loadpoint) + } + if l.clockSynced() { + ts := e.At.UnixMilli() + ev.Ts = &ts + } + return ev, nil +} + +func severity(priority int) string { + switch { + case priority >= 5: + return cloudwire.SeverityAlarm + case priority == 4: + return cloudwire.SeverityWarning + default: + return cloudwire.SeverityInfo + } +} + +// Bye tells the service why the box is going and stops the uplink for good. +// why is cloudwire.ByeUpdate for a restart the box chose, such as an update, +// which holds back box.unreachable for an hour; ByeShutdown otherwise. It +// waits at most wait for the bye to leave, and returns at once when the +// uplink is down. +func (l *Link) Bye(why string, wait time.Duration) { + if l == nil { + return + } + l.mu.Lock() + if l.closing { + l.mu.Unlock() + return + } + l.closing, l.byeWhy = true, why + done := make(chan struct{}) + l.byeDone = done + connected := l.connected + l.mu.Unlock() + l.signal() + if !connected { + return + } + select { + case <-done: + case <-time.After(wait): + } +} + +// closeIfAsked sends the bye Bye asked for and ends the connection. +func (w *writer) closeIfAsked(snap grantSnapshot) error { + l := w.link + l.mu.Lock() + closing, why, done := l.closing, l.byeWhy, l.byeDone + l.mu.Unlock() + if !closing { + return nil + } + err := w.bye(snap, why, false) + l.mu.Lock() + l.byeDone = nil + l.mu.Unlock() + if done != nil { + close(done) + } + if err != nil { + return err + } + return errClosed +} diff --git a/go/internal/cloudlink/events_test.go b/go/internal/cloudlink/events_test.go new file mode 100644 index 000000000..a91f36b45 --- /dev/null +++ b/go/internal/cloudlink/events_test.go @@ -0,0 +1,158 @@ +package cloudlink + +import ( + "bytes" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// An event leaves signed, numbered and timed, with opaque ids for what it +// concerns and no name in it anywhere. +func TestAnEventLeavesSignedWithoutNames(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + at := time.Now().Add(-3 * time.Second) + if !h.link.QueueEvent(EventIn{Kind: protocol.PushDriverOffline, Priority: 4, + Args: map[string]string{"name": chargerName}, Driver: chargerName, At: at}) { + t.Fatal("a grant with events refused an event") + } + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted, Priority: 4, Loadpoint: lpName}) { + t.Fatal("a grant with events refused an event") + } + first := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + second := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + + pub, err := cloudwire.ParsePublicKey(h.link.current().pk) + if err != nil { + t.Fatal(err) + } + var a, b cloudwire.Event + for _, x := range []struct { + env cloudwire.Envelope + out *cloudwire.Event + }{{first, &a}, {second, &b}} { + if err := cloudwire.Verify(pub, svc.home, x.env); err != nil { + t.Fatalf("event not signed by the box: %v", err) + } + if err := cloudwire.Unmarshal(x.env.B, x.out); err != nil { + t.Fatal(err) + } + } + gk := h.link.current().g.GrantKey + if a.Kind != protocol.PushDriverOffline || a.Severity != cloudwire.SeverityWarning || + a.Src != cloudwire.SourceID(gk, chargerName) || len(a.Args) != 0 || a.LP != "" { + t.Fatalf("driver.offline went as %+v", a) + } + if a.Ts == nil || *a.Ts != at.UnixMilli() || a.WallMs == nil || len(a.EventID) != 12 { + t.Fatalf("driver.offline has no time or id: %+v", a) + } + if b.Kind != protocol.PushChargingInterrupted || b.LP != cloudwire.LoadpointID(gk, lpName) || + b.Seq <= a.Seq || b.EventID == a.EventID { + t.Fatalf("charging.interrupted went as %+v after %+v", b, a) + } + for _, raw := range [][]byte{first.B, second.B} { + if bytes.Contains(raw, []byte(chargerName)) || bytes.Contains(raw, []byte(lpName)) { + t.Fatalf("a name left the house in %x", raw) + } + } +} + +// Events are their own scope: a grant without it sends none. +func TestAGrantWithoutEventsQueuesNothing(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued with FTW Cloud off") + } + h.grant(t, protocol.ScopeLiveRead) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued under a grant that leaves events out") + } +} + +// An event that waited longer than a phone would act on it is dropped and +// counted, not sent late. +func TestAnEventTooOldForThePhoneIsDropped(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected, At: time.Now().Add(-11 * time.Minute)}) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted}) + env := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + var e cloudwire.Event + if err := cloudwire.Unmarshal(env.B, &e); err != nil { + t.Fatal(err) + } + if e.Kind != protocol.PushChargingInterrupted { + t.Fatalf("sent %s, an event eleven minutes old", e.Kind) + } + if st := h.link.Status(); st.EventsDropped != 1 { + t.Fatalf("dropped %d", st.EventsDropped) + } +} + +// The queue holds the newest events and counts what it pushed out. Here the +// service fails the binding, so nothing drains the queue. +func TestTheEventQueueKeepsTheNewest(t *testing.T) { + svc := newFakeService(t) + svc.badEKM = true + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + for range eventRing + 3 { + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("refused") + } + } + h.link.mu.Lock() + n := len(h.link.events) + h.link.mu.Unlock() + if st := h.link.Status(); n != eventRing || st.EventsDropped != 3 { + t.Fatalf("queue %d, dropped %d", n, st.EventsDropped) + } +} + +// A restart the box chose says so, and the link stops dialling. +func TestByeForAnUpdateLeavesAndTheLinkStops(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + for !h.link.Status().Connected { + time.Sleep(10 * time.Millisecond) + } + + h.link.Bye(cloudwire.ByeUpdate, 2*time.Second) + env := svc.waitFor(cloudwire.MsgBye, time.Second) + var b cloudwire.Bye + if err := cloudwire.Unmarshal(env.B, &b); err != nil { + t.Fatal(err) + } + if b.Why != cloudwire.ByeUpdate || b.Purge { + t.Fatalf("bye %+v", b) + } + select { + case err := <-h.done: + if err != nil { + t.Fatalf("Run ended with %v", err) + } + h.done <- nil // for the cleanup + case <-time.After(3 * time.Second): + t.Fatal("the link kept running after its bye") + } + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued after the bye") + } + svc.mu.Lock() + conns := svc.conns + svc.mu.Unlock() + if conns != 1 { + t.Fatalf("the box dialled %d times", conns) + } +} diff --git a/go/internal/cloudlink/grant.go b/go/internal/cloudlink/grant.go new file mode 100644 index 000000000..fcff0073e --- /dev/null +++ b/go/internal/cloudlink/grant.go @@ -0,0 +1,193 @@ +package cloudlink + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "slices" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// The grant lives in the box's state store, not in the config document, so a +// whole-document config write cannot reset it. A missing record reads as off. +const ( + grantKey = "cloud/grant_v1" + rosterKey = "cloud/roster_v1" + purgedKey = "cloud/purged_v1" +) + +// KV is the part of the state store the grant needs. +type KV interface { + LoadConfig(key string) (string, bool) + SaveConfig(key, value string) error +} + +// grantRecord is what an owner turned on. +type grantRecord struct { + ID []byte `json:"id"` + Scopes []string `json:"scopes"` + Purposes []string `json:"purposes"` + // KeyFp is SHA-256 of cloud.key's public point. A record whose + // fingerprint does not match the key on disk reads as off: a grant + // restored without its key must not start a link under a new key. + KeyFp string `json:"keyFp"` + // GrantKey cuts the opaque ids. A new grant draws a new one. + GrantKey []byte `json:"grantKey"` + By string `json:"by"` + Policy string `json:"policy"` + // AtMs is the wall time of the grant, or 0 when the clock was not set. + AtMs int64 `json:"atMs"` + Controller string `json:"controller"` + // StatsID names this box's statistics rows at the service. Drawn when an + // owner first turns statistics on, kept while they stay on, and gone + // when they are turned off. + StatsID []byte `json:"statsId,omitempty"` + // StatsPolicy names the consent text the owner read for statistics. + StatsPolicy string `json:"statsPolicy,omitempty"` + // Revoked means the owner turned FTW Cloud off and the service has not + // yet confirmed the deletion. The key stays until it has. + Revoked bool `json:"revoked,omitempty"` + RevokedAtMs int64 `json:"revokedAtMs,omitempty"` +} + +func (g *grantRecord) has(scope string) bool { + return g != nil && slices.Contains(g.Scopes, scope) +} + +// receipt is the service's purge receipt, kept so the box can say when the +// copy was erased. +type receipt struct { + AtMs int64 `json:"atMs"` + GrantID []byte `json:"grantId"` +} + +// rosterState is the roster number and what it was computed from. The number +// rises when the digest changes, so changes made while the link was down +// still reach the service. +type rosterState struct { + Seq uint64 `json:"seq"` + Digest string `json:"digest"` +} + +func loadJSON[T any](kv KV, key string) (*T, error) { + raw, ok := kv.LoadConfig(key) + if !ok || raw == "" { + return nil, nil + } + var v T + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return nil, fmt.Errorf("cloudlink: reading %s: %w", key, err) + } + return &v, nil +} + +// saveJSON writes v, or clears the key when v is nil. The store has no +// delete, and an empty value reads as missing. +func saveJSON(kv KV, key string, v any) error { + if v == nil { + return kv.SaveConfig(key, "") + } + raw, err := json.Marshal(v) + if err != nil { + return err + } + return kv.SaveConfig(key, string(raw)) +} + +// ErrGrantRequest is a grant asked for with something FTW Cloud cannot have. +var ErrGrantRequest = errors.New("cloudlink: not a grant FTW Cloud may hold") + +// checkScopes refuses anything outside cloud_scopes, including every write +// scope, and an empty list. +func checkScopes(scopes []string) ([]string, error) { + if len(scopes) == 0 { + return nil, fmt.Errorf("%w: name at least one scope", ErrGrantRequest) + } + out := make([]string, 0, len(scopes)) + for _, s := range protocol.CloudScopes { + if slices.Contains(scopes, s) { + out = append(out, s) + } + } + for _, s := range scopes { + if !slices.Contains(protocol.CloudScopes, s) { + return nil, fmt.Errorf("%w: %q", ErrGrantRequest, s) + } + } + return out, nil +} + +// -------------------------------------------------------------------------- +// cloud.key +// -------------------------------------------------------------------------- + +// The key file is PEM SEC 1, 0600, beside state.db. Backups skip it: a box +// restored onto other hardware has FTW Cloud off until an owner turns it on +// again, under a new home id. + +func loadKey(path string) (*ecdsa.PrivateKey, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(raw) + if block == nil || block.Type != "EC PRIVATE KEY" { + return nil, fmt.Errorf("cloudlink: %s is not an EC private key", path) + } + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("cloudlink: %s: %w", path, err) + } + if key.Curve != elliptic.P256() { + return nil, fmt.Errorf("cloudlink: %s is not P-256", path) + } + return key, nil +} + +func createKey(path string) (*ecdsa.PrivateKey, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), 0o600); err != nil { + return nil, fmt.Errorf("cloudlink: writing %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return nil, fmt.Errorf("cloudlink: replacing %s: %w", path, err) + } + return key, nil +} + +// fingerprint is SHA-256 of the key's public point, in hex. +func fingerprint(key *ecdsa.PrivateKey) (string, []byte, error) { + pk, err := cloudwire.PublicKeyBytesOf(&key.PublicKey) + if err != nil { + return "", nil, err + } + sum := sha256.Sum256(pk) + return hex.EncodeToString(sum[:]), pk, nil +} + +func randomBytes(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil +} diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go new file mode 100644 index 000000000..9e794d05f --- /dev/null +++ b/go/internal/cloudlink/link.go @@ -0,0 +1,729 @@ +// Package cloudlink streams a signed, readable copy of the box's state to FTW +// Cloud, when an owner has turned it on. docs/protocol-cloud.md is the wire +// format and docs/adr/0008-ftw-cloud.md the decision. +// +// FTW Cloud reads and never controls. This package holds that line in its +// build: it reaches the box only through the read-only ports below and may +// import nothing from the box (imports_test.go and CI check it with go list +// -deps). What the service may send back is a closed set of five messages, +// and none of them asks the box to do anything but send sooner, send again or +// forget a key. +// +// The link is off until an owner grants it. A missing grant, or one whose key +// fingerprint does not match cloud.key, reads as off. +package cloudlink + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Compile-time settings. Production builds use the defaults; a local test +// build may set them with -ldflags -X. A box that can be pointed at another +// service at runtime is a box a support call can point at an attacker's. +var ( + // Endpoint is the service's uplink. + Endpoint = "wss://cloud.ftw.energy/v1/box" + // ServiceKeys are the service's P-256 public keys, base64url of the + // 65-byte point, comma separated. Up to two, so the key can rotate + // across a release. Empty means no service exists for this build: the + // box refuses a grant and never dials. + ServiceKeys = "" + // DevRootCA is a PEM file of extra roots, for a local service with its + // own certificate. Empty in every release build. + DevRootCA = "" +) + +// ErrNoService is a grant on a build that names no service key. +var ErrNoService = errors.New("cloudlink: this build has no FTW Cloud service") + +// ErrPurgePending is a grant asked for while the last one is still being +// deleted at the service. +var ErrPurgePending = errors.New("cloudlink: FTW Cloud is still deleting the previous copy") + +// ErrNotGranted is statistics asked for while FTW Cloud is off. +var ErrNotGranted = errors.New("cloudlink: turn FTW Cloud on first") + +// statsIDBytes is the length of a statsId, which the protocol fixes at 16. +const statsIDBytes = 16 + +// Site is the box's state, read. Every method answers from memory and changes +// nothing. Names in the answers are the box's own driver and loadpoint names; +// the link replaces each with an opaque id before anything leaves the house. +type Site interface { + // Live is the frozen fields and the source table. + Live() Live + // Loadpoints is loadpoint state and control feedback. ok is false on a + // box without loadpoints, which then sends no lp. + Loadpoints() (lp cloudwire.LP, ok bool) + // Prices is the price window from now. ok is false when the box has no + // prices. + Prices() (p cloudwire.Prices, ok bool) +} + +// Live is the live class before ids are cut. +type Live struct { + // Fields uses the app protocol's frozen ids as text keys. + Fields map[string]int64 + // FieldSources names, per field, the drivers its freshness follows. + FieldSources map[string][]string + // Sources is keyed by driver name. + Sources map[string]Source + // DispatchBlockedBy names drivers whose staleness stops dispatch. + DispatchBlockedBy []string +} + +// Source is one driver's freshness. +type Source struct { + Kind string + State protocol.SourceState + // LastOkUptimeMs is box uptime at the last good reading, negative when + // there was none. + LastOkUptimeMs int64 + StaleAfterMs int64 +} + +// RosterDevice is one paired phone. +type RosterDevice struct { + ID string + Key []byte + Role string + Epoch uint64 + // Scopes are what the role carries, expanded by the box. + Scopes []string +} + +// Roster lists the paired phones. +type Roster interface { + Devices() []RosterDevice +} + +// Options wires a link. Site, Roster, KV and KeyPath are required. +type Options struct { + Site Site + Roster Roster + KV KV + KeyPath string + Build string + // Started anchors uptime, the clock every age is measured against. + Started time.Time + // ClockSynced reports whether NTP has set the wall clock. Nil reads as + // not synced, and wallMs is then left out. + ClockSynced func() bool + // Notify tells owners the grant changed, with protocol.PushCloudGranted + // or PushCloudRevoked. Nil sends nothing. + Notify func(kind string) + Logger *slog.Logger + + // Seams for tests. Production leaves them zero. + Endpoint string + ServiceKeys []*ecdsa.PublicKey + RootCAs *x509.CertPool + Tick time.Duration +} + +// Link is the box's one uplink to FTW Cloud. +type Link struct { + opts Options + log *slog.Logger + boot []byte + + // wake is signalled when the grant or the roster changes, so the + // sender acts within one tick. + wake chan struct{} + + mu sync.Mutex + seq uint64 + key *ecdsa.PrivateKey + pk []byte + home string + grant *grantRecord + notice string + connected bool + lastSend time.Time + budget budget + sent map[string]sentFrame + srcRev uint64 + table map[string]cloudwire.Source + tableKey string + liveSeq uint64 + lpSeq uint64 + resyncAt time.Time + attempt int + purgedAt int64 + rosterDue bool + + // events wait for the writer; eventsDropped counts those that never + // left. closing is set by Bye, after which the link does not dial + // again. + events []EventIn + eventsDropped int64 + closing bool + byeWhy string + byeDone chan struct{} +} + +// New loads the grant and the key and returns a link that runs once Run is +// called. +func New(opts Options) (*Link, error) { + if opts.Site == nil || opts.Roster == nil || opts.KV == nil || opts.KeyPath == "" { + return nil, errors.New("cloudlink: site, roster, state store and key path are required") + } + if opts.Logger == nil { + opts.Logger = slog.Default() + } + if opts.Endpoint == "" { + opts.Endpoint = Endpoint + } + if opts.ServiceKeys == nil { + keys, err := parseServiceKeys(ServiceKeys) + if err != nil { + return nil, err + } + opts.ServiceKeys = keys + } + if opts.RootCAs == nil && DevRootCA != "" { + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + pemBytes, err := os.ReadFile(DevRootCA) + if err != nil { + return nil, fmt.Errorf("cloudlink: reading dev root CA: %w", err) + } + if !pool.AppendCertsFromPEM(pemBytes) { + return nil, fmt.Errorf("cloudlink: %s holds no certificate Go can use", DevRootCA) + } + opts.RootCAs = pool + } + if opts.Started.IsZero() { + opts.Started = time.Now() + } + if opts.Tick <= 0 { + opts.Tick = 250 * time.Millisecond + } + boot, err := randomBytes(cloudwire.BootBytes) + if err != nil { + return nil, err + } + l := &Link{ + opts: opts, log: opts.Logger.With("component", "cloudlink"), boot: boot, + wake: make(chan struct{}, 1), sent: map[string]sentFrame{}, + } + if err := l.load(); err != nil { + return nil, err + } + return l, nil +} + +func parseServiceKeys(list string) ([]*ecdsa.PublicKey, error) { + var keys []*ecdsa.PublicKey + for _, s := range strings.Split(list, ",") { + if s = strings.TrimSpace(s); s == "" { + continue + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("cloudlink: service key: %w", err) + } + pub, err := cloudwire.ParsePublicKey(raw) + if err != nil { + return nil, err + } + keys = append(keys, pub) + } + if len(keys) > 2 { + return nil, errors.New("cloudlink: at most two service keys") + } + return keys, nil +} + +// load reads the grant and the key. A grant without its key, or with another +// key, reads as off and leaves a notice. +func (l *Link) load() error { + l.mu.Lock() + defer l.mu.Unlock() + l.grant, l.key, l.pk, l.home, l.notice = nil, nil, nil, "", "" + if r, err := loadJSON[receipt](l.opts.KV, purgedKey); err == nil && r != nil { + l.purgedAt = r.AtMs + } + g, err := loadJSON[grantRecord](l.opts.KV, grantKey) + if err != nil { + l.notice = "grant_unreadable" + l.log.Warn("FTW Cloud grant unreadable; treating it as off", "err", err) + return nil + } + if g == nil { + return nil + } + key, err := loadKey(l.opts.KeyPath) + if err != nil { + l.notice = "key_missing" + l.log.Warn("FTW Cloud is off: the grant has no key on this box", "err", err) + return nil + } + fp, pk, err := fingerprint(key) + if err != nil || fp != g.KeyFp { + l.notice = "key_mismatch" + l.log.Warn("FTW Cloud is off: cloud.key is not the key the grant was made with") + return nil + } + l.grant, l.key, l.pk, l.home = g, key, pk, cloudwire.HomeID(pk) + return nil +} + +// signal wakes the sender. It never blocks. +func (l *Link) signal() { + select { + case l.wake <- struct{}{}: + default: + } +} + +// RosterChanged is called by enrolment after a pair, role change or revoke. +func (l *Link) RosterChanged() { + l.mu.Lock() + l.rosterDue = true + l.mu.Unlock() + l.signal() +} + +// GrantRequest is an owner's request to turn FTW Cloud on. +type GrantRequest struct { + Scopes []string `json:"scopes"` + // Policy names the consent text the owner read. + Policy string `json:"policy"` + // By is the device or door that asked, for the audit line. + By string `json:"-"` +} + +// Grant turns FTW Cloud on, or changes the scopes of a grant already on. +func (l *Link) Grant(req GrantRequest) error { + if len(l.opts.ServiceKeys) == 0 { + return ErrNoService + } + scopes, err := checkScopes(req.Scopes) + if err != nil { + return err + } + if strings.TrimSpace(req.Policy) == "" { + return fmt.Errorf("%w: name the policy the owner accepted", ErrGrantRequest) + } + + l.mu.Lock() + switch { + case l.grant != nil && l.grant.Revoked: + l.mu.Unlock() + return ErrPurgePending + case l.grant != nil: + g := *l.grant + g.Scopes, g.Policy, g.By = scopes, req.Policy, req.By + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + l.mu.Unlock() + return err + } + l.grant = &g + l.rosterDue = true + l.mu.Unlock() + l.audit("changed", &g) + l.signal() + return nil + } + l.mu.Unlock() + + key, err := createKey(l.opts.KeyPath) + if err != nil { + return err + } + fp, pk, err := fingerprint(key) + if err != nil { + return err + } + id, err := randomBytes(cloudwire.GrantIDBytes) + if err != nil { + return err + } + gk, err := randomBytes(cloudwire.GrantKeyBytes) + if err != nil { + return err + } + g := grantRecord{ + ID: id, Scopes: scopes, Purposes: []string{protocol.CloudPurposeServe}, + KeyFp: fp, GrantKey: gk, By: req.By, Policy: req.Policy, + Controller: cloudwire.ControllerSourceful, + } + if l.clockSynced() { + g.AtMs = time.Now().UnixMilli() + } + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + _ = os.Remove(l.opts.KeyPath) + return err + } + + l.mu.Lock() + l.grant, l.key, l.pk, l.home, l.notice = &g, key, pk, cloudwire.HomeID(pk), "" + l.table, l.tableKey, l.sent, l.rosterDue = nil, "", map[string]sentFrame{}, true + l.mu.Unlock() + + l.audit("granted", &g) + l.notify(protocol.PushCloudGranted) + l.signal() + return nil +} + +// Revoke turns FTW Cloud off. The box stops sending readings at once, asks +// the service to delete the home, and keeps the key until the service's +// signed receipt arrives. +func (l *Link) Revoke(by string) error { + l.mu.Lock() + if l.grant == nil || l.grant.Revoked { + l.mu.Unlock() + return nil + } + g := *l.grant + g.Revoked, g.By = true, by + if l.clockSynced() { + g.RevokedAtMs = time.Now().UnixMilli() + } + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + l.mu.Unlock() + return err + } + l.grant = &g + l.mu.Unlock() + + l.audit("revoked", &g) + l.notify(protocol.PushCloudRevoked) + l.signal() + return nil +} + +// StatsRequest is an owner's request to contribute to statistics. +type StatsRequest struct { + // Policy names the consent text the owner read. + Policy string `json:"policy"` + // By is the device or door that asked, for the audit line. + By string `json:"-"` +} + +// Stats adds the stats purpose to a grant already on. The first time, it +// draws the statsId that names this box's rows at the service; later calls +// keep it. It sends no statistics itself. +func (l *Link) Stats(req StatsRequest) error { + if len(l.opts.ServiceKeys) == 0 { + return ErrNoService + } + if strings.TrimSpace(req.Policy) == "" { + return fmt.Errorf("%w: name the policy the owner accepted", ErrGrantRequest) + } + l.mu.Lock() + defer l.mu.Unlock() + switch { + case l.grant == nil: + return ErrNotGranted + case l.grant.Revoked: + return ErrPurgePending + } + g := *l.grant + if len(g.StatsID) == 0 { + id, err := randomBytes(statsIDBytes) + if err != nil { + return err + } + g.StatsID = id + } + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, true) + g.StatsPolicy, g.By = req.Policy, req.By + return l.storeLocked(g, "statistics on") +} + +// 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() + defer l.mu.Unlock() + if l.grant == nil || l.grant.Revoked || + (len(l.grant.StatsID) == 0 && !slices.Contains(l.grant.Purposes, protocol.CloudPurposeStats)) { + return nil + } + g := *l.grant + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, false) + g.StatsID, g.StatsPolicy, g.By = nil, "", by + return l.storeLocked(g, "statistics off") +} + +// storeLocked saves a changed grant, marks the roster due and writes the +// audit line. The caller holds l.mu. +func (l *Link) storeLocked(g grantRecord, event string) error { + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + return err + } + l.grant = &g + l.rosterDue = true + l.audit(event, &g) + l.signal() + return nil +} + +// withPurpose returns purposes with p present or absent, in registry order. +// It never changes the slice it was given. +func withPurpose(purposes []string, p string, on bool) []string { + out := []string{} + for _, q := range protocol.CloudPurposes { + if (q == p && on) || (q != p && slices.Contains(purposes, q)) { + out = append(out, q) + } + } + return out +} + +// finishPurge deletes the key and the grant once the service has confirmed +// the deletion. +func (l *Link) finishPurge(atMs int64) { + l.mu.Lock() + g := l.grant + if g == nil || !g.Revoked { + l.mu.Unlock() + return + } + if err := saveJSON(l.opts.KV, purgedKey, receipt{AtMs: atMs, GrantID: g.ID}); err != nil { + l.log.Warn("could not store the FTW Cloud purge receipt", "err", err) + } + if err := saveJSON(l.opts.KV, grantKey, nil); err != nil { + l.log.Warn("could not clear the FTW Cloud grant", "err", err) + } + if err := os.Remove(l.opts.KeyPath); err != nil && !errors.Is(err, os.ErrNotExist) { + l.log.Warn("could not delete cloud.key", "err", err) + } + l.grant, l.key, l.pk, l.home, l.purgedAt = nil, nil, nil, "", atMs + l.table, l.tableKey, l.sent = nil, "", map[string]sentFrame{} + l.mu.Unlock() + l.audit("purged", g) +} + +// audit writes the line that records a grant change on the box. +func (l *Link) audit(event string, g *grantRecord) { + l.log.Info("audit: FTW Cloud grant "+event, + "grant", hex.EncodeToString(g.ID), "by", g.By, "scopes", g.Scopes, + "purposes", g.Purposes, "policy", g.Policy, "stats_policy", g.StatsPolicy) +} + +func (l *Link) notify(kind string) { + if l.opts.Notify != nil { + go l.opts.Notify(kind) + } +} + +func (l *Link) clockSynced() bool { + return l.opts.ClockSynced != nil && l.opts.ClockSynced() +} + +// Status is what GET /api/cloud shows. +type Status struct { + // State is off, on or revoking. + State string `json:"state"` + // Notice says why a grant on disk reads as off: key_missing, + // key_mismatch or grant_unreadable. + Notice string `json:"notice,omitempty"` + Available bool `json:"available"` + Home string `json:"home,omitempty"` + Scopes []string `json:"scopes"` + Purposes []string `json:"purposes"` + GrantedAtMs int64 `json:"grantedAtMs,omitempty"` + GrantedBy string `json:"grantedBy,omitempty"` + Policy string `json:"policy,omitempty"` + Connected bool `json:"connected"` + // LastSendAgeMs is how long ago the box last sent anything, -1 never. + LastSendAgeMs int64 `json:"lastSendAgeMs"` + // BytesLast24h counts what the box sent in the last 24 hours of uptime. + BytesLast24h int64 `json:"bytesLast24h"` + BudgetBytes int64 `json:"budgetBytes"` + BudgetReached bool `json:"budgetReached"` + // PurgedAtMs is the time on the service's receipt for the last deletion. + PurgedAtMs int64 `json:"purgedAtMs,omitempty"` + // EventsDropped counts notifications that never reached the service: + // the queue was full, or the uplink was down for longer than a phone + // would still act on them. + EventsDropped int64 `json:"eventsDropped"` +} + +// Status reports the grant and the link. +func (l *Link) Status() Status { + l.mu.Lock() + defer l.mu.Unlock() + s := Status{ + State: "off", Notice: l.notice, Available: len(l.opts.ServiceKeys) > 0, + Scopes: []string{}, Purposes: []string{}, Connected: l.connected, + LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, EventsDropped: l.eventsDropped, + BytesLast24h: l.budget.total(l.uptimeMs()), BudgetBytes: budgetBytes, + } + s.BudgetReached = s.BytesLast24h >= budgetBytes + if !l.lastSend.IsZero() { + s.LastSendAgeMs = time.Since(l.lastSend).Milliseconds() + } + if g := l.grant; g != nil { + s.State, s.Home, s.Scopes, s.Purposes = "on", l.home, g.Scopes, g.Purposes + s.GrantedAtMs, s.GrantedBy, s.Policy = g.AtMs, g.By, g.Policy + if g.Revoked { + s.State = "revoking" + } + } + return s +} + +// Available reports whether this build names a service, and so can run the +// uplink at all. Release builds without service keys cannot. +func (l *Link) Available() bool { return len(l.opts.ServiceKeys) > 0 } + +// SessionInfo is what a phone needs, over its Noise session with the box, to +// read this home from FTW Cloud: the key that signs it, the home id, the boot +// that anchors its times and names for the opaque ids. It is nil unless the +// grant is on. +// +// 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 { + snap := l.current() + if snap.g == nil || snap.g.Revoked || len(snap.pk) != cloudwire.PublicKeyBytes { + return nil + } + gk := snap.g.GrantKey + s := &protocol.CloudSession{ + PK: bytes.Clone(snap.pk), Home: snap.home, Boot: bytes.Clone(l.boot), + Sources: map[string]string{}, Loadpoints: map[string]string{}, + } + source := func(name string) { + if name != "" { + s.Sources[cloudwire.SourceID(gk, name)] = name + } + } + live := l.opts.Site.Live() + for name := range live.Sources { + source(name) + } + for _, names := range live.FieldSources { + for _, name := range names { + source(name) + } + } + for _, name := range live.DispatchBlockedBy { + source(name) + } + if lp, ok := l.opts.Site.Loadpoints(); ok { + for _, p := range lp.Loadpoints { + s.Loadpoints[cloudwire.LoadpointID(gk, p.ID)] = p.ID + source(p.Src) + } + for _, f := range lp.Feedback { + source(f.Src) + } + } + return s +} + +func (l *Link) uptimeMs() int64 { return time.Since(l.opts.Started).Milliseconds() } + +// header stamps the next box body. Seq rises per message, per boot. +func (l *Link) header() cloudwire.Header { + l.seq++ + h := cloudwire.Header{Seq: l.seq, Boot: l.boot, UptimeMs: l.uptimeMs()} + if l.clockSynced() { + w := time.Now().UnixMilli() + h.WallMs = &w + } + return h +} + +// grantSnapshot is the grant and key as one consistent read. +type grantSnapshot struct { + g *grantRecord + key *ecdsa.PrivateKey + pk []byte + home string +} + +func (l *Link) current() grantSnapshot { + l.mu.Lock() + defer l.mu.Unlock() + return grantSnapshot{g: l.grant, key: l.key, pk: l.pk, home: l.home} +} + +// Run keeps the uplink up while a grant is on, until ctx ends. +func (l *Link) Run(ctx context.Context) error { + for { + if l.isClosing() { + return nil + } + snap := l.current() + if snap.g == nil || len(l.opts.ServiceKeys) == 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-l.wake: + continue + } + } + delay, err := l.once(ctx, snap) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + l.log.Warn("FTW Cloud uplink dropped; retrying", "retry_in", delay, "err", err) + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func (l *Link) isClosing() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.closing +} + +// allowed reports whether the grant lets a class leave the house. +func (snap grantSnapshot) allowed(class string) bool { + scope, forwarded := cloudwire.ClassScope[class] + if !forwarded { + return false + } + return scope == "" || snap.g.has(scope) +} + +// caps is the classes this box sends under the grant. +func (snap grantSnapshot) caps() []string { + var out []string + for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB, cloudwire.MsgEvent} { + if snap.allowed(c) { + out = append(out, c) + } + } + return append(out, cloudwire.MsgRoster) +} + +// tlsConfig is TLS 1.3 only, with the system roots unless a test or a dev +// build adds its own. +func (l *Link) tlsConfig() *tls.Config { + return &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: l.opts.RootCAs} +} diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go new file mode 100644 index 000000000..51bba7d78 --- /dev/null +++ b/go/internal/cloudlink/link_test.go @@ -0,0 +1,950 @@ +package cloudlink + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// -------------------------------------------------------------------------- +// Fakes +// -------------------------------------------------------------------------- + +type memKV struct { + mu sync.Mutex + m map[string]string +} + +func (k *memKV) LoadConfig(key string) (string, bool) { + k.mu.Lock() + defer k.mu.Unlock() + v, ok := k.m[key] + return v, ok +} + +func (k *memKV) SaveConfig(key, value string) error { + k.mu.Lock() + defer k.mu.Unlock() + if k.m == nil { + k.m = map[string]string{} + } + k.m[key] = value + return nil +} + +// The fake house uses these names, so a test can search every frame for them. +const ( + meterName = "p1meter-in-the-hall" + chargerName = "easee-garage" + lpName = "garage-loadpoint" +) + +type fakeSite struct { + mu sync.Mutex + grid int64 + state protocol.SourceState + lastOk int64 + liveCalls int + lpCalls int +} + +func (s *fakeSite) setGrid(w int64) { + s.mu.Lock() + s.grid = w + s.mu.Unlock() +} + +func (s *fakeSite) Live() Live { + s.mu.Lock() + defer s.mu.Unlock() + s.liveCalls++ + return Live{ + Fields: map[string]int64{"1": 0, "2": s.grid, "6": 900, "10": 2100}, + FieldSources: map[string][]string{"2": {meterName}, "6": {meterName, chargerName}, "10": {chargerName}}, + Sources: map[string]Source{ + meterName: {Kind: "driver", State: s.state, LastOkUptimeMs: s.lastOk, StaleAfterMs: 4_000}, + chargerName: {Kind: "driver", State: protocol.SourceLive, LastOkUptimeMs: s.lastOk, StaleAfterMs: 30_000}, + }, + DispatchBlockedBy: nil, + } +} + +func (s *fakeSite) Loadpoints() (cloudwire.LP, bool) { + s.mu.Lock() + s.lpCalls++ + s.mu.Unlock() + soc := int64(430) + return cloudwire.LP{ControlRev: 9, Loadpoints: []cloudwire.Loadpoint{{ + ID: lpName, Src: chargerName, Plugged: true, SocPermille: &soc, SocSource: "inferred", PowerW: 2100, + }}, Feedback: []cloudwire.Feedback{{Kind: "ev", Src: chargerName, Status: "following"}}}, true +} + +func (s *fakeSite) Prices() (cloudwire.Prices, bool) { + p := int64(42) + return cloudwire.Prices{Zone: "SE3", Currency: "SEK", T0Ms: 1_791_820_800_000, SlotMs: 900_000, + SpotMinor: []*int64{&p}, TotalMinor: []*int64{&p}}, true +} + +type fakeRoster struct{} + +func (fakeRoster) Devices() []RosterDevice { + return []RosterDevice{{ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{5}, 32), Role: "owner", Epoch: 1, + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopeDispatchWrite, protocol.ScopeEventsRead}}} +} + +// fakeService is ftwcloudd's half of the handshake, enough to test the box. +type fakeService struct { + t *testing.T + key *ecdsa.PrivateKey + srv *httptest.Server + + // Knobs, set before the box connects. + badEKM bool + wrongKey bool + purge bool + afterHello func(conn *websocket.Conn, home string) + rosterSeqOK uint64 + + mu sync.Mutex + conns int + hello []byte + got []cloudwire.Envelope + raw [][]byte + ch chan cloudwire.Envelope + home string + pub *ecdsa.PublicKey +} + +func newFakeService(t *testing.T) *fakeService { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + s := &fakeService{t: t, key: key, ch: make(chan cloudwire.Envelope, 256)} + s.srv = httptest.NewUnstartedServer(http.HandlerFunc(s.handle)) + s.srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} + s.srv.StartTLS() + t.Cleanup(s.srv.Close) + return s +} + +func (s *fakeService) endpoint() string { + return "wss://" + s.srv.Listener.Addr().String() + "/v1/box" +} + +func (s *fakeService) roots() *x509.CertPool { + pool := x509.NewCertPool() + pool.AddCert(s.srv.Certificate()) + return pool +} + +func (s *fakeService) write(conn *websocket.Conn, key *ecdsa.PrivateKey, home, t string, body any) { + env, err := cloudwire.Seal(key, home, t, body) + if err != nil { + s.t.Error(err) + return + } + raw, _ := env.Encode() + frame, _ := cloudwire.EncodeFrame(raw) + _ = conn.WriteMessage(websocket.BinaryMessage, frame) +} + +func (s *fakeService) handle(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + s.mu.Lock() + s.conns++ + s.mu.Unlock() + ekm, _ := r.TLS.ExportKeyingMaterial(cloudwire.EKMLabel, nil, cloudwire.EKMBytes) + nonce := bytes.Repeat([]byte{7}, cloudwire.NonceBytes) + s.write(conn, nil, "", cloudwire.MsgChallenge, cloudwire.Challenge{V: 1, Nonce: nonce}) + + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + payload, _ := cloudwire.DecodeFrame(msg) + env, err := cloudwire.DecodeEnvelope(payload) + var hello cloudwire.Hello + if err != nil || env.T != cloudwire.MsgHello || cloudwire.Unmarshal(env.B, &hello) != nil { + s.t.Errorf("first box message was not a hello: %v", err) + return + } + pub, err := cloudwire.ParsePublicKey(hello.PK) + home := cloudwire.HomeID(hello.PK) + if err != nil || cloudwire.Verify(pub, home, env) != nil { + s.t.Errorf("hello does not verify: %v", err) + return + } + if !bytes.Equal(hello.Nonce, nonce) || !bytes.Equal(hello.EKM, ekm) { + s.t.Errorf("hello is not bound to this challenge and TLS session") + return + } + s.mu.Lock() + s.home, s.pub, s.hello = home, pub, msg + s.mu.Unlock() + + okEKM := ekm + if s.badEKM { + okEKM = bytes.Repeat([]byte{1}, cloudwire.EKMBytes) + } + signer := s.key + if s.wrongKey { + 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}) + if s.afterHello != nil { + go s.afterHello(conn, home) + } + + for { + kind, msg, err := conn.ReadMessage() + if err != nil { + return + } + if kind != websocket.BinaryMessage { + s.t.Errorf("the box sent a text message") + return + } + payload, err := cloudwire.DecodeFrame(msg) + if err != nil { + s.t.Errorf("box frame: %v", err) + return + } + env, err := cloudwire.DecodeEnvelope(payload) + if err != nil || cloudwire.Verify(pub, home, env) != nil { + s.t.Errorf("box envelope %q does not verify: %v", env.T, err) + return + } + s.mu.Lock() + s.got = append(s.got, env) + s.raw = append(s.raw, msg) + s.mu.Unlock() + s.ch <- env + if env.T == cloudwire.MsgBye && s.purge { + var bye cloudwire.Bye + _ = cloudwire.Unmarshal(env.B, &bye) + var grantID []byte + for _, e := range s.received() { + var r cloudwire.Roster + if e.T == cloudwire.MsgRoster && cloudwire.Unmarshal(e.B, &r) == nil { + grantID = r.Grant.ID + } + } + if bye.Purge { + s.write(conn, s.key, home, cloudwire.MsgPurged, cloudwire.Purged{ + AtMs: 1_791_821_000_000, What: cloudwire.PurgedHome, GrantID: grantID}) + } + } + } +} + +func (s *fakeService) received() []cloudwire.Envelope { + s.mu.Lock() + defer s.mu.Unlock() + return append([]cloudwire.Envelope(nil), s.got...) +} + +// waitFor returns the next envelope of type t, failing after d. +func (s *fakeService) waitFor(t string, d time.Duration) cloudwire.Envelope { + s.t.Helper() + deadline := time.After(d) + for { + select { + case env := <-s.ch: + if env.T == t { + return env + } + case <-deadline: + s.t.Fatalf("no %s within %s", t, d) + return cloudwire.Envelope{} + } + } +} + +type harness struct { + link *Link + svc *fakeService + site *fakeSite + kv *memKV + keyPath string + cancel context.CancelFunc + done chan error + pushed chan string +} + +func newHarness(t *testing.T, svc *fakeService) *harness { + t.Helper() + h := &harness{svc: svc, site: &fakeSite{grid: 1200, state: protocol.SourceLive}, kv: &memKV{}, + keyPath: filepath.Join(t.TempDir(), "cloud.key"), pushed: make(chan string, 8)} + link, err := New(Options{ + Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: h.keyPath, Build: "test", + Started: time.Now().Add(-time.Minute), ClockSynced: func() bool { return true }, + Notify: func(kind string) { h.pushed <- kind }, + Endpoint: svc.endpoint(), ServiceKeys: []*ecdsa.PublicKey{&svc.key.PublicKey}, + RootCAs: svc.roots(), Tick: 20 * time.Millisecond, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + h.link = link + ctx, cancel := context.WithCancel(context.Background()) + h.cancel, h.done = cancel, make(chan error, 1) + go func() { h.done <- link.Run(ctx) }() + t.Cleanup(func() { + cancel() + <-h.done + }) + return h +} + +func (h *harness) grant(t *testing.T, scopes ...string) { + t.Helper() + if len(scopes) == 0 { + scopes = []string{protocol.ScopeLiveRead} + } + if err := h.link.Grant(GrantRequest{Scopes: scopes, Policy: "2026-10", By: "test"}); err != nil { + t.Fatal(err) + } +} + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +func TestOffUntilAnOwnerGrants(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + time.Sleep(300 * time.Millisecond) + svc.mu.Lock() + conns := svc.conns + svc.mu.Unlock() + if conns != 0 { + t.Fatalf("the box dialled FTW Cloud %d times with no grant", conns) + } + if st := h.link.Status(); st.State != "off" || st.Home != "" { + t.Fatalf("status %+v", st) + } + if _, err := os.Stat(h.keyPath); !os.IsNotExist(err) { + t.Fatal("cloud.key exists before any grant") + } +} + +func TestGrantStreamsSignedStateWithoutNames(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead) + if kind := <-h.pushed; kind != protocol.PushCloudGranted { + t.Fatalf("owners were told %q", kind) + } + + roster := svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + live := svc.waitFor(cloudwire.MsgLive, 5*time.Second) + svc.waitFor(cloudwire.MsgLP, 5*time.Second) + svc.waitFor(cloudwire.MsgPrices, 5*time.Second) + + var r cloudwire.Roster + if err := cloudwire.Unmarshal(roster.B, &r); err != nil { + t.Fatal(err) + } + if len(r.Devices) != 1 || strings.Join(r.Devices[0].Scopes, ",") != "ftw.live.read,ftw.events.read" { + t.Fatalf("roster devices %+v: write scopes must not leave the house", r.Devices) + } + var l cloudwire.Live + if err := cloudwire.Unmarshal(live.B, &l); err != nil { + t.Fatal(err) + } + if len(l.Sources) != 2 || l.SrcRev != 1 || l.Fields["2"] != 1200 || len(l.Ages) != 2 { + t.Fatalf("first live %+v", l) + } + + // An hb follows within H (S = 4 s, so H = 2 s) when nothing changes. + svc.waitFor(cloudwire.MsgHB, 4*time.Second) + + for _, raw := range svc.raw { + for _, name := range []string{meterName, chargerName, lpName} { + if bytes.Contains(raw, []byte(name)) { + t.Fatalf("%q left the house in %x", name, raw) + } + } + } + st := h.link.Status() + if st.State != "on" || !st.Connected || st.Home != svc.home || st.BytesLast24h == 0 { + t.Fatalf("status %+v", st) + } +} + +// What the service holds is exactly what the preview shows. +func TestPreviewIsTheBytesThatLeft(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead) + svc.waitFor(cloudwire.MsgPrices, 5*time.Second) + svc.waitFor(cloudwire.MsgHB, 4*time.Second) + h.cancel() // freeze: nothing more is sent + <-h.done + h.done <- nil + + latest := map[string][]byte{} + svc.mu.Lock() + latest[cloudwire.MsgHello] = svc.hello + for i, env := range svc.got { + latest[env.T] = svc.raw[i] + } + svc.mu.Unlock() + + p := h.link.Preview() + if p.Sample || len(p.Frames) == 0 { + t.Fatalf("preview %+v", p) + } + for _, f := range p.Frames { + if !bytes.Equal(f.Frame, latest[f.T]) { + t.Fatalf("%s: preview and service differ\npreview %x\nservice %x", f.T, f.Frame, latest[f.T]) + } + if f.Body == nil { + t.Fatalf("%s: no decoded body", f.T) + } + } +} + +func TestPreviewBeforeAGrantIsAnUnsignedSample(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + p := h.link.Preview() + if !p.Sample || p.State != "off" || len(p.Frames) != 3 { + t.Fatalf("preview %+v", p) + } + for _, f := range p.Frames { + payload, _ := cloudwire.DecodeFrame(f.Frame) + env, err := cloudwire.DecodeEnvelope(payload) + if err != nil || len(env.Sig) != 0 { + t.Fatalf("%s: sample is signed or broken: %v", f.T, err) + } + if bytes.Contains(f.Frame, []byte(meterName)) { + t.Fatal("a driver name is in the sample") + } + } +} + +func TestAServiceThatFailsTheBindingGetsOnlyHello(t *testing.T) { + for name, tweak := range map[string]func(*fakeService){ + "another TLS session": func(s *fakeService) { s.badEKM = true }, + "another key": func(s *fakeService) { s.wrongKey = true }, + } { + t.Run(name, func(t *testing.T) { + svc := newFakeService(t) + tweak(svc) + h := newHarness(t, svc) + h.grant(t) + time.Sleep(time.Second) + if got := svc.received(); len(got) != 0 { + t.Fatalf("the box sent %d messages after a hello_ok that failed", len(got)) + } + if h.link.Status().Connected { + t.Fatal("the link reports connected") + } + }) + } +} + +func TestUnknownDownlinkClosesTheUplink(t *testing.T) { + svc := newFakeService(t) + svc.afterHello = func(conn *websocket.Conn, home string) { + time.Sleep(200 * time.Millisecond) + svc.write(conn, nil, home, "cmd", map[string]any{"op": "site.mode.set"}) + } + h := newHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + deadline := time.Now().Add(3 * time.Second) + for h.link.Status().Connected { + if time.Now().After(deadline) { + t.Fatal("the box kept the uplink after a message the service may not send") + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestWatchRaisesTheCadence(t *testing.T) { + svc := newFakeService(t) + svc.afterHello = func(conn *websocket.Conn, home string) { + svc.write(conn, nil, home, cloudwire.MsgWatch, cloudwire.Watch{Hz: 1}) + } + h := newHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + time.Sleep(200 * time.Millisecond) + h.site.setGrid(1500) + start := time.Now() + svc.waitFor(cloudwire.MsgLive, 3*time.Second) + if d := time.Since(start); d > 1500*time.Millisecond { + t.Fatalf("a watched change took %s", d) + } +} + +func TestATableChangeGoesAtOnceWithANewRevision(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + h.site.mu.Lock() + h.site.state = protocol.SourceLagging + h.site.mu.Unlock() + // Within the one-second look at the table, well inside the 5 s period. + env := svc.waitFor(cloudwire.MsgLive, 2*time.Second) + var l cloudwire.Live + _ = cloudwire.Unmarshal(env.B, &l) + if l.SrcRev != 2 || len(l.Sources) != 2 { + t.Fatalf("table change sent as %+v", l) + } +} + +func TestRevokeDeletesTheKeyOnlyAfterTheReceipt(t *testing.T) { + svc := newFakeService(t) + svc.purge = true + h := newHarness(t, svc) + h.grant(t) + <-h.pushed + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + if err := h.link.Revoke("test"); err != nil { + t.Fatal(err) + } + if kind := <-h.pushed; kind != protocol.PushCloudRevoked { + t.Fatalf("owners were told %q", kind) + } + bye := svc.waitFor(cloudwire.MsgBye, 3*time.Second) + var b cloudwire.Bye + if cloudwire.Unmarshal(bye.B, &b) != nil || !b.Purge || b.Why != cloudwire.ByeRevoked { + t.Fatalf("bye %+v", b) + } + deadline := time.Now().Add(3 * time.Second) + for h.link.Status().State != "off" { + if time.Now().After(deadline) { + t.Fatalf("still %+v after the receipt", h.link.Status()) + } + time.Sleep(20 * time.Millisecond) + } + if _, err := os.Stat(h.keyPath); !os.IsNotExist(err) { + t.Fatal("cloud.key survived a confirmed purge") + } + if st := h.link.Status(); st.PurgedAtMs != 1_791_821_000_000 { + t.Fatalf("receipt time %d", st.PurgedAtMs) + } + // After the bye, nothing but the receipt's ack: no readings. + got := svc.received() + for i, env := range got { + if env.T == cloudwire.MsgBye && i != len(got)-1 { + t.Fatalf("the box sent %s after bye", got[i+1].T) + } + } +} + +func TestRevokeWithoutAReceiptKeepsTheKey(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + if err := h.link.Revoke("test"); err != nil { + t.Fatal(err) + } + svc.waitFor(cloudwire.MsgBye, 3*time.Second) + time.Sleep(200 * time.Millisecond) + if _, err := os.Stat(h.keyPath); err != nil { + t.Fatal("cloud.key went before the service confirmed the purge") + } + if st := h.link.Status(); st.State != "revoking" { + t.Fatalf("state %s", st.State) + } + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: "p"}); !errors.Is(err, ErrPurgePending) { + t.Fatalf("re-grant during a pending purge: %v", err) + } +} + +func TestPastTheBudgetOnlyRosterAndByeGo(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.link.mu.Lock() + h.link.budget.add(h.link.uptimeMs(), budgetBytes) + h.link.mu.Unlock() + h.grant(t) + svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + time.Sleep(2500 * time.Millisecond) + for _, env := range svc.received() { + if env.T != cloudwire.MsgRoster { + t.Fatalf("%s went past the byte budget", env.T) + } + } + if st := h.link.Status(); !st.BudgetReached { + t.Fatalf("status %+v", st) + } +} + +func TestAGrantWithoutItsKeyReadsAsOff(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + reopen := func(keyPath string) Status { + link, err := New(Options{Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: keyPath, + ServiceKeys: h.link.opts.ServiceKeys}) + if err != nil { + t.Fatal(err) + } + return link.Status() + } + + // A restore that brought the grant back without its key. + if st := reopen(filepath.Join(t.TempDir(), "cloud.key")); st.State != "off" || st.Notice != "key_missing" { + t.Fatalf("missing key: %+v", st) + } + // Or with a key that is not the one the grant was made with. + other := filepath.Join(t.TempDir(), "cloud.key") + if _, err := createKey(other); err != nil { + t.Fatal(err) + } + if st := reopen(other); st.State != "off" || st.Notice != "key_mismatch" { + t.Fatalf("other key: %+v", st) + } + if st := reopen(h.keyPath); st.State != "on" { + t.Fatalf("its own key: %+v", st) + } +} + +func TestGrantRefusesWhatFTWCloudMayNotHold(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + for _, scopes := range [][]string{nil, {protocol.ScopeDispatchWrite}, {protocol.ScopeAssetsRead}, + {protocol.ScopeLiveRead, "ftw.everything"}} { + if err := h.link.Grant(GrantRequest{Scopes: scopes, Policy: "p"}); !errors.Is(err, ErrGrantRequest) { + t.Errorf("scopes %v: %v", scopes, err) + } + } + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}}); !errors.Is(err, ErrGrantRequest) { + t.Errorf("no policy: %v", err) + } + if _, err := os.Stat(h.keyPath); !os.IsNotExist(err) { + t.Fatal("a refused grant made a key") + } +} + +func TestABuildWithoutAServiceRefusesAGrant(t *testing.T) { + link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: &memKV{}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{}}) + if err != nil { + t.Fatal(err) + } + if err := link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: "p"}); !errors.Is(err, ErrNoService) { + t.Fatalf("grant: %v", err) + } + if link.Status().Available { + t.Fatal("status says FTW Cloud is available") + } +} + +func TestBudgetRollsOver24HoursOfUptime(t *testing.T) { + var b budget + b.add(0, 100) + b.add(60_000*60, 50) + if got := b.total(60_000 * 60); got != 150 { + t.Fatalf("total %d", got) + } + if got := b.total(60_000 * (budgetMinutes + 1)); got != 50 { + t.Fatalf("after a day: %d", got) + } +} + +func TestChangeTestsIgnoreAgesAndNoise(t *testing.T) { + age := int64(5) + a := cloudwire.LP{Loadpoints: []cloudwire.Loadpoint{{ID: "x", SocAgeS: &age, Manual: &cloudwire.Manual{SinceAgeMs: 1}}}, + Feedback: []cloudwire.Feedback{{Kind: "ev", AgeMs: &age}}} + older := int64(500) + b := cloudwire.LP{Loadpoints: []cloudwire.Loadpoint{{ID: "x", SocAgeS: &older, Manual: &cloudwire.Manual{SinceAgeMs: 900}}}, + Feedback: []cloudwire.Feedback{{Kind: "ev", AgeMs: &older}}} + if lpChanged(a, b) { + t.Fatal("lp differs only in ages and still counts as changed") + } + b.Loadpoints[0].PowerW = 90 + if lpChanged(a, b) { + t.Fatal("90 W of noise counts as a change") + } + b.Loadpoints[0].PowerW = 101 + if !lpChanged(a, b) { + t.Fatal("a power change past the band does not count") + } + b.Loadpoints[0].PowerW = 0 + b.Loadpoints[0].Plugged = true + if !lpChanged(a, b) { + t.Fatal("a plug-in does not count") + } + s1 := cloudwire.Live{Sources: map[string]cloudwire.Source{"a": {State: "live", LastOkMs: 1}}} + s2 := cloudwire.Live{Sources: map[string]cloudwire.Source{"a": {State: "live", LastOkMs: 9}}} + if tableKey(s1) != tableKey(s2) { + t.Fatal("lastOkMs moves the table") + } +} + +// Composing reads the box. The link looks at the source table once a second +// and at the loadpoints once a period, however fast it ticks. +func TestTheLinkReadsTheBoxNoFasterThanItCanSend(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) // ticks every 20 ms + h.grant(t) + svc.waitFor(cloudwire.MsgLP, 5*time.Second) + h.site.mu.Lock() + h.site.liveCalls, h.site.lpCalls = 0, 0 + h.site.mu.Unlock() + time.Sleep(3 * time.Second) + h.site.mu.Lock() + live, lp := h.site.liveCalls, h.site.lpCalls + h.site.mu.Unlock() + if live > 5 || lp > 1 { + t.Fatalf("in 3 s the link read live %d times and loadpoints %d times", live, lp) + } +} + +// -------------------------------------------------------------------------- +// Session information for phones +// -------------------------------------------------------------------------- + +// A phone learns the box's key, home id, boot and names over Noise. With them +// it verifies what the service forwards, anchors its times and names its +// sources. +func TestSessionInfoLetsAPhoneCheckAndNameWhatTheServiceForwards(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if s := h.link.SessionInfo(); s != nil { + t.Fatalf("session information with FTW Cloud off: %+v", s) + } + h.grant(t) + env := svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + s := h.link.SessionInfo() + if s == nil { + t.Fatal("no session information with FTW Cloud on") + } + if len(s.PK) != cloudwire.PublicKeyBytes || s.Home != cloudwire.HomeID(s.PK) || s.Home != svc.home { + t.Fatalf("key %x and home %q; the service saw home %q", s.PK, s.Home, svc.home) + } + pub, err := cloudwire.ParsePublicKey(s.PK) + if err != nil || cloudwire.Verify(pub, s.Home, env) != nil { + t.Fatalf("a forwarded live does not verify with the session key: %v", err) + } + var live cloudwire.Live + if err := cloudwire.Unmarshal(env.B, &live); err != nil { + t.Fatal(err) + } + if len(s.Boot) != cloudwire.BootBytes || !bytes.Equal(s.Boot, live.Boot) { + t.Fatalf("session boot %x, live boot %x", s.Boot, live.Boot) + } + + gk := h.link.current().g.GrantKey + wantSources := map[string]string{ + cloudwire.SourceID(gk, meterName): meterName, + cloudwire.SourceID(gk, chargerName): chargerName, + } + if !maps.Equal(s.Sources, wantSources) { + t.Fatalf("sources %v, want %v", s.Sources, wantSources) + } + for id := range live.Sources { + if s.Sources[id] == "" { + t.Fatalf("live names source %q, which the session cannot name", id) + } + } + if want := map[string]string{cloudwire.LoadpointID(gk, lpName): lpName}; !maps.Equal(s.Loadpoints, want) { + t.Fatalf("loadpoints %v, want %v", s.Loadpoints, want) + } +} + +// Turning FTW Cloud off withdraws the session information at once, before the +// service confirms the deletion. +func TestSessionInfoGoesWithTheGrant(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + if h.link.SessionInfo() == nil { + t.Fatal("no session information with FTW Cloud on") + } + if err := h.link.Revoke("test"); err != nil { + t.Fatal(err) + } + if s := h.link.SessionInfo(); s != nil || h.link.Status().State != "revoking" { + t.Fatalf("while revoking: %+v", s) + } +} + +func TestAvailableFollowsTheServiceKeys(t *testing.T) { + svc := newFakeService(t) + if !newHarness(t, svc).link.Available() { + t.Fatal("a build with a service key says FTW Cloud is unavailable") + } + link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: &memKV{}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{}}) + if err != nil { + t.Fatal(err) + } + if link.Available() { + t.Fatal("a build without a service key says FTW Cloud is available") + } +} + +// -------------------------------------------------------------------------- +// Statistics +// -------------------------------------------------------------------------- + +func (h *harness) record(t *testing.T) grantRecord { + t.Helper() + g, err := loadJSON[grantRecord](h.kv, grantKey) + if err != nil || g == nil { + t.Fatalf("grant record: %v", err) + } + return *g +} + +func (h *harness) nextRoster(t *testing.T) cloudwire.Roster { + t.Helper() + var r cloudwire.Roster + if err := cloudwire.Unmarshal(h.svc.waitFor(cloudwire.MsgRoster, 3*time.Second).B, &r); err != nil { + t.Fatal(err) + } + return r +} + +// Statistics are a second consent under a grant already on. The box draws a +// statsId once, keeps it while statistics stay on, tells the service through +// the roster and sends no statistics yet. +func TestStatisticsAreASecondConsentUnderTheGrant(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.Stats(StatsRequest{Policy: "stats-2026-10"}); !errors.Is(err, ErrNotGranted) { + t.Fatalf("statistics with FTW Cloud off: %v", err) + } + h.grant(t) + first := h.nextRoster(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + if err := h.link.Stats(StatsRequest{}); !errors.Is(err, ErrGrantRequest) { + t.Fatalf("statistics without a policy: %v", err) + } + + if err := h.link.Stats(StatsRequest{Policy: "stats-2026-10", By: "app:aaaa1111"}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if strings.Join(r.Grant.Purposes, ",") != "serve,stats" || r.RosterSeq <= first.RosterSeq { + t.Fatalf("roster after statistics on: purposes %v, seq %d after %d", r.Grant.Purposes, r.RosterSeq, first.RosterSeq) + } + if st := h.link.Status(); strings.Join(st.Purposes, ",") != "serve,stats" { + t.Fatalf("status purposes %v", st.Purposes) + } + g := h.record(t) + if len(g.StatsID) != statsIDBytes || g.StatsPolicy != "stats-2026-10" || g.Policy != "2026-10" { + t.Fatalf("grant record %+v", g) + } + + // Asked again, the same id. + 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" { + t.Fatalf("statsId %x became %x", g.StatsID, again.StatsID) + } + + // A restart reads it back. + reopened, err := New(Options{Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: h.keyPath, + ServiceKeys: h.link.opts.ServiceKeys}) + if err != nil { + t.Fatal(err) + } + if st := reopened.Status(); strings.Join(st.Purposes, ",") != "serve,stats" { + t.Fatalf("after a restart: %+v", st) + } + + for _, env := range svc.received() { + if env.T == "stats" { + t.Fatal("the box sent statistics") + } + } +} + +// Turning statistics off forgets the statsId; turning them on again draws a +// new one, because the service refuses an id it has deleted. +func TestStoppingStatisticsForgetsTheID(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.StopStats("lan"); err != nil { + t.Fatalf("statistics off with FTW Cloud off: %v", err) + } + h.grant(t) + h.nextRoster(t) + if err := h.link.Stats(StatsRequest{Policy: "p"}); err != nil { + t.Fatal(err) + } + h.nextRoster(t) + old := h.record(t).StatsID + + if err := h.link.StopStats("lan"); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if strings.Join(r.Grant.Purposes, ",") != "serve" { + t.Fatalf("roster after statistics off: %v", r.Grant.Purposes) + } + if g := h.record(t); len(g.StatsID) != 0 || g.StatsPolicy != "" || strings.Join(g.Purposes, ",") != "serve" { + t.Fatalf("grant record after statistics off: %+v", g) + } + if err := h.link.StopStats("lan"); err != nil { + t.Fatalf("statistics off twice: %v", err) + } + + if err := h.link.Stats(StatsRequest{Policy: "p"}); err != nil { + t.Fatal(err) + } + if renewed := h.record(t).StatsID; len(renewed) != statsIDBytes || bytes.Equal(renewed, old) { + t.Fatalf("statsId %x after %x", renewed, old) + } +} + +func TestNoStatisticsWhileTheCopyIsBeingDeleted(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + if err := h.link.Revoke("test"); err != nil { + t.Fatal(err) + } + if err := h.link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrPurgePending) { + t.Fatalf("statistics while revoking: %v", err) + } +} + +func TestABuildWithoutAServiceRefusesStatistics(t *testing.T) { + link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: &memKV{}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{}}) + if err != nil { + t.Fatal(err) + } + if err := link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrNoService) { + t.Fatalf("statistics: %v", err) + } +} diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go new file mode 100644 index 000000000..499e735ad --- /dev/null +++ b/go/internal/cloudlink/sender.go @@ -0,0 +1,650 @@ +package cloudlink + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "reflect" + "slices" + "sort" + "time" + + "github.com/fxamacker/cbor/v2" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Cadence and limits, from the protocol's "Numbers in one place". +const ( + periodIdle = 5 * time.Second + periodWatched = time.Second + // lpPeriodMin holds lp at 2 s until a Pi 4 bench shows what composing + // it costs. + lpPeriodMin = 2 * time.Second + watchFor = 60 * time.Second + fullEvery = 15 * time.Minute + resyncEvery = 5 * time.Minute + pricesEvery = time.Minute + rosterRenew = 6 * time.Hour + rosterTTLS = 86_400 + budgetBytes = 20_000_000 + budgetMinutes = 24 * 60 +) + +// sentFrame is the last frame of a class that left the house, exactly. +type sentFrame struct { + T string + Frame []byte + AtMs int64 +} + +// budget counts bytes sent over a rolling 24 hours of uptime, per minute. +// Past budgetBytes the box sends only bye and roster, and no hb, so data on +// phones ages to stale rather than looking current. +type budget struct { + bytes [budgetMinutes]int64 + minute [budgetMinutes]int64 +} + +func (b *budget) add(uptimeMs, n int64) { + m := uptimeMs / 60_000 + i := m % budgetMinutes + if b.minute[i] != m { + b.minute[i], b.bytes[i] = m, 0 + } + b.bytes[i] += n +} + +func (b *budget) total(uptimeMs int64) int64 { + m := uptimeMs / 60_000 + var sum int64 + for i := range b.bytes { + if age := m - b.minute[i]; age >= 0 && age < budgetMinutes { + sum += b.bytes[i] + } + } + return sum +} + +// 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 + pricesKey string + pricesAt time.Time + anyAt time.Time // last live or hb + rosterAt time.Time + watchUntil time.Time + + // When each class was last composed, and whether a change it found is + // still waiting for its period. Composing reads the box, and on a Pi + // the loadpoint view is not free, so it happens no faster than a + // message could go. + liveCheckedAt, lpCheckedAt time.Time + liveWaiting, lpWaiting bool +} + +// liveCheckEvery is how often the source table is looked at, so a table +// change goes within a second whatever the period. +const liveCheckEvery = time.Second + +// run sends until the connection or the grant ends. +func (w *writer) run(ctx context.Context, events <-chan cloudwire.Envelope) error { + l := w.link + tick := time.NewTicker(l.opts.Tick) + defer tick.Stop() + connHome := l.current().home + // A roster goes at every connect, which also renews its ttlS: the + // service counts that from when it received the last one. + st := &cadence{full: true} + var purgeSince time.Time + + for { + snap := l.current() + if snap.g == nil || snap.home != connHome { + return errors.New("cloudlink: the grant changed under the connection") + } + now := time.Now() + if snap.g.Revoked { + if purgeSince.IsZero() { + if err := w.bye(snap, cloudwire.ByeRevoked, true); err != nil { + return err + } + purgeSince = now + } else if now.Sub(purgeSince) > purgeWait { + return errors.New("cloudlink: no purge receipt yet") + } + } else { + if err := w.closeIfAsked(snap); err != nil { + return err + } + if err := w.sendEvents(snap, now); err != nil { + return err + } + if err := w.tick(snap, st, now); err != nil { + return err + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-tick.C: + case <-l.wake: + case env := <-events: + if err := w.downlink(snap, st, env); err != nil { + return err + } + } + } +} + +// downlink acts on watch, resync and purged, and on nothing else. +func (w *writer) downlink(snap grantSnapshot, st *cadence, env cloudwire.Envelope) error { + l := w.link + switch env.T { + case cloudwire.MsgWatch: + var b cloudwire.Watch + if cloudwire.Unmarshal(env.B, &b) == nil && b.Hz == 1 { + st.watchUntil = time.Now().Add(watchFor) + } + case cloudwire.MsgResync: + var b cloudwire.Resync + if cloudwire.Unmarshal(env.B, &b) != nil { + return nil + } + l.mu.Lock() + honoured := l.resyncAt.IsZero() || time.Since(l.resyncAt) >= resyncEvery + if honoured { + l.resyncAt = time.Now() + } + l.mu.Unlock() + if !honoured { + return nil + } + if slices.Contains(b.What, cloudwire.ResyncFull) { + st.full = true + } + if slices.Contains(b.What, cloudwire.ResyncRoster) { + st.rosterAt = time.Time{} + } + } + return nil +} + +// acceptPurged finishes a revoke on a valid receipt: signed by the service, +// for this home and grant, while the grant is being revoked. +func (l *Link) acceptPurged(snap grantSnapshot, env cloudwire.Envelope) bool { + var b cloudwire.Purged + cur := l.current() + if cur.g == nil || !cur.g.Revoked || cur.home != snap.home || !l.verifiedByService(snap.home, env) || + cloudwire.Unmarshal(env.B, &b) != nil || b.What != cloudwire.PurgedHome || !bytes.Equal(b.GrantID, cur.g.ID) { + return false + } + l.finishPurge(b.AtMs) + return true +} + +// tick sends whatever is due. +func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { + l := w.link + + l.mu.Lock() + due := l.rosterDue + l.rosterDue = false + h := time.Duration(cloudwire.Heartbeat(l.table)) * time.Millisecond + l.mu.Unlock() + if due || st.rosterAt.IsZero() || now.Sub(st.rosterAt) >= rosterRenew { + _, roster := l.rosterNumber(snap) + if _, err := w.sendBody(snap, cloudwire.MsgRoster, &roster, true); err != nil { + return err + } + st.rosterAt = now + } + + period := periodIdle + if now.Before(st.watchUntil) { + period = periodWatched + } + lpPeriod := max(period, lpPeriodMin) + full := st.full || now.Sub(st.lastFull) >= fullEvery + hbDue := now.Sub(st.anyAt) >= h + + var ages map[string]int64 + if snap.allowed(cloudwire.MsgLive) && (full || hbDue || now.Sub(st.liveCheckedAt) >= liveCheckEvery) { + st.liveCheckedAt = now + live, tableChanged := l.projectLive(snap, l.opts.Site.Live()) + ages = live.Ages + key := liveKey(live) + changed := tableChanged || key != st.liveKey + st.liveWaiting = false + switch { + case full || tableChanged || (changed && now.Sub(st.liveAt) >= period): + if !full && !tableChanged { + live.Sources, live.FieldSources = nil, nil + } + sent, err := w.sendBody(snap, cloudwire.MsgLive, &live, false) + if err != nil { + return err + } + if sent { + st.liveKey, st.liveAt, st.anyAt = key, now, now + } + case changed: + st.liveWaiting = true + } + } + + if snap.allowed(cloudwire.MsgLP) && (full || now.Sub(st.lpCheckedAt) >= lpPeriod) { + st.lpCheckedAt = now + st.lpWaiting = false + if lp, ok := l.opts.Site.Loadpoints(); ok { + lp = l.projectLP(snap, lp) + changed := st.lpLast == nil || lpChanged(*st.lpLast, lp) + switch { + case full || (changed && now.Sub(st.lpAt) >= lpPeriod): + sent, err := w.sendBody(snap, cloudwire.MsgLP, &lp, false) + if err != nil { + return err + } + if sent { + st.lpLast, st.lpAt = &lp, now + } + case changed: + st.lpWaiting = true + } + } + } + if full { + st.full, st.lastFull = false, now + } + + if snap.allowed(cloudwire.MsgPrices) && (st.pricesKey == "" || now.Sub(st.pricesAt) >= pricesEvery) { + st.pricesAt = now + if p, ok := l.opts.Site.Prices(); ok { + if key := pricesKey(p); key != st.pricesKey { + sent, err := w.sendBody(snap, cloudwire.MsgPrices, &p, false) + if err != nil { + return err + } + if sent { + st.pricesKey = key + } + } + } + } + + // hb never vouches for a live or lp that waits for its period, and its + // ages were read on this tick. + if now.Sub(st.anyAt) >= h && !st.liveWaiting && !st.lpWaiting { + hb := l.heartbeat(ages) + sent, err := w.sendBody(snap, cloudwire.MsgHB, &hb, false) + if err != nil { + return err + } + if sent { + st.anyAt = now + } + } + return nil +} + +// sendBody stamps the header, sends, and records the seq of a live or lp so +// hb can name it. body is a pointer to a cloudwire body with a Header. +func (w *writer) sendBody(snap grantSnapshot, t string, body any, force bool) (bool, error) { + l := w.link + l.mu.Lock() + if !force && l.budget.total(l.uptimeMs()) >= budgetBytes { + l.mu.Unlock() + return false, nil + } + h := l.header() + l.mu.Unlock() + reflect.ValueOf(body).Elem().FieldByName("Header").Set(reflect.ValueOf(h)) + + if err := w.send(snap, t, reflect.ValueOf(body).Elem().Interface()); err != nil { + return false, err + } + l.mu.Lock() + switch t { + case cloudwire.MsgLive: + l.liveSeq = h.Seq + case cloudwire.MsgLP: + l.lpSeq = h.Seq + } + l.mu.Unlock() + return true, nil +} + +func (w *writer) bye(snap grantSnapshot, why string, purge bool) error { + _, err := w.sendBody(snap, cloudwire.MsgBye, &cloudwire.Bye{Why: why, Purge: purge}, true) + return err +} + +// heartbeat names the newest live and lp sent and how old each reading is. +func (l *Link) heartbeat(ages map[string]int64) cloudwire.HB { + l.mu.Lock() + defer l.mu.Unlock() + return cloudwire.HB{Clock: l.clockName(), SrcRev: l.srcRev, LiveSeq: l.liveSeq, LpSeq: l.lpSeq, Ages: ages} +} + +// project cuts opaque ids and ages from the box's names. +func (l *Link) project(gk []byte, in Live) cloudwire.Live { + up := l.uptimeMs() + out := cloudwire.Live{ + Fields: in.Fields, Sources: map[string]cloudwire.Source{}, + FieldSources: map[string][]string{}, Ages: map[string]int64{}, + } + for name, s := range in.Sources { + id := cloudwire.SourceID(gk, name) + out.Sources[id] = cloudwire.Source{Kind: s.Kind, State: s.State, + LastOkMs: max(0, s.LastOkUptimeMs), StaleAfterMs: s.StaleAfterMs} + if (s.State == protocol.SourceLive || s.State == protocol.SourceLagging) && s.LastOkUptimeMs >= 0 { + out.Ages[id] = max(0, up-s.LastOkUptimeMs) + } + } + for f, names := range in.FieldSources { + out.FieldSources[f] = ids(gk, names) + } + out.DispatchBlockedBy = ids(gk, in.DispatchBlockedBy) + return out +} + +// projectLive is project plus the source revision, which moves when the +// table changed. The comparison ignores lastOkMs, which moves every reading. +func (l *Link) projectLive(snap grantSnapshot, in Live) (cloudwire.Live, bool) { + out := l.project(snap.g.GrantKey, in) + table := tableKey(out) + l.mu.Lock() + defer l.mu.Unlock() + changed := table != l.tableKey + if changed { + l.srcRev++ + l.tableKey = table + l.table = out.Sources + } + out.SrcRev = l.srcRev + return out, changed +} + +func ids(grantKey []byte, names []string) []string { + out := make([]string, 0, len(names)) + for _, n := range names { + out = append(out, cloudwire.SourceID(grantKey, n)) + } + sort.Strings(out) + return out +} + +// projectLP replaces loadpoint and driver names with opaque ids. +func (l *Link) projectLP(snap grantSnapshot, in cloudwire.LP) cloudwire.LP { + gk := snap.g.GrantKey + out := cloudwire.LP{ControlRev: in.ControlRev} + for _, lp := range in.Loadpoints { + lp.ID = cloudwire.LoadpointID(gk, lp.ID) + if lp.Src != "" { + lp.Src = cloudwire.SourceID(gk, lp.Src) + } + out.Loadpoints = append(out.Loadpoints, lp) + } + for _, f := range in.Feedback { + if f.Src != "" { + f.Src = cloudwire.SourceID(gk, f.Src) + } + out.Feedback = append(out.Feedback, f) + } + return out +} + +// The change tests: encodings of what a phone must hear about, without the +// fields that move every reading. + +func digest(v any) string { + raw, err := cloudwire.Marshal(v) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func liveKey(l cloudwire.Live) string { + return digest([]any{l.Fields, l.DispatchBlockedBy, l.SrcRev}) +} + +func tableKey(l cloudwire.Live) string { + table := map[string]cloudwire.Source{} + for id, s := range l.Sources { + s.LastOkMs = 0 + table[id] = s + } + return digest([]any{table, l.FieldSources}) +} + +// lpKey is lp without its ages and its measured values, which lpChanged +// compares with a deadband instead. +func lpKey(lp cloudwire.LP) string { + c := cloudwire.LP{ControlRev: lp.ControlRev} + for _, p := range lp.Loadpoints { + p.SocAgeS, p.SocPermille, p.PowerW, p.SessionWh = nil, nil, 0, 0 + if p.Manual != nil { + m := *p.Manual + m.SinceAgeMs = 0 + p.Manual = &m + } + c.Loadpoints = append(c.Loadpoints, p) + } + for _, f := range lp.Feedback { + f.AgeMs, f.ActualW = nil, nil + c.Feedback = append(c.Feedback, f) + } + return digest(c) +} + +// Deadbands for lp's measured values. A charging car moves its power every +// reading; sending lp for each would cost about 12 MB a day. A change of +// state goes at once, a reading only when it moved by more than this, and +// the full lp every 15 minutes carries exact values. Live field 10 carries +// the site's charging power every period anyway. +const ( + lpPowerBandW = 100 + lpPowerBandRatio = 0.05 + lpEnergyBandWh = 100 + lpSocBandPermille = 10 +) + +// lpChanged reports whether cur says something prev did not. +func lpChanged(prev, cur cloudwire.LP) bool { + if lpKey(prev) != lpKey(cur) { + return true + } + moved := func(a, b int64) bool { + band := max(lpPowerBandW, int64(lpPowerBandRatio*float64(max(abs(a), abs(b))))) + return abs(a-b) > band + } + for i, p := range cur.Loadpoints { + q := prev.Loadpoints[i] + if moved(q.PowerW, p.PowerW) || abs(q.SessionWh-p.SessionWh) >= lpEnergyBandWh || + (q.SocPermille == nil) != (p.SocPermille == nil) || + (p.SocPermille != nil && abs(*q.SocPermille-*p.SocPermille) >= lpSocBandPermille) { + return true + } + } + for i, f := range cur.Feedback { + g := prev.Feedback[i] + if (g.ActualW == nil) != (f.ActualW == nil) || (f.ActualW != nil && moved(*g.ActualW, *f.ActualW)) { + return true + } + } + return false +} + +func abs(v int64) int64 { + if v < 0 { + return -v + } + return v +} + +func pricesKey(p cloudwire.Prices) string { + return digest([]any{p.T0Ms, p.SlotMs, p.SpotMinor, p.TotalMinor}) +} + +// rosterNumber builds the roster and its number. The number rises when the +// devices or the grant differ from what it was last computed from. +func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { + r := cloudwire.Roster{TTLS: rosterTTLS, Grant: cloudwire.Grant{ + ID: snap.g.ID, Scopes: snap.g.Scopes, Purposes: snap.g.Purposes, Controller: snap.g.Controller, + }} + 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}) + } + sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) + sum := digest([]any{r.Devices, r.Grant}) + + l.mu.Lock() + defer l.mu.Unlock() + state, err := loadJSON[rosterState](l.opts.KV, rosterKey) + if err != nil || state == nil { + state = &rosterState{} + } + if state.Digest != sum { + state.Seq++ + state.Digest = sum + if err := saveJSON(l.opts.KV, rosterKey, state); err != nil { + l.log.Warn("could not store the FTW Cloud roster number", "err", err) + } + } + r.RosterSeq = state.Seq + return state.Seq, r +} + +// bumpRoster moves the roster number past the service's. +func (l *Link) bumpRoster(past uint64) { + l.mu.Lock() + defer l.mu.Unlock() + state, err := loadJSON[rosterState](l.opts.KV, rosterKey) + if err != nil || state == nil { + state = &rosterState{} + } + state.Seq = past + 1 + if err := saveJSON(l.opts.KV, rosterKey, state); err != nil { + l.log.Warn("could not store the FTW Cloud roster number", "err", err) + } + l.rosterDue = true +} + +// -------------------------------------------------------------------------- +// Preview +// -------------------------------------------------------------------------- + +// Preview is what GET /api/cloud/preview shows: the exact bytes that leave +// the house, and the same bytes decoded for reading. +type Preview struct { + State string `json:"state"` + Home string `json:"home,omitempty"` + // Sample is true when nothing has been sent yet: the frames are what the + // box would send now, unsigned, with ids cut by the grant's key or, with + // FTW Cloud off, by a key of zeros. + Sample bool `json:"sample"` + Frames []PreviewFrame `json:"frames"` +} + +// PreviewFrame is one class's last frame. +type PreviewFrame struct { + T string `json:"t"` + // Frame is the whole WebSocket message, base64 in JSON. + Frame []byte `json:"frame"` + // AgeMs is how long ago it was sent, by box uptime. + AgeMs int64 `json:"ageMs"` + // Body is the signed body decoded, for people to read. The frame is the + // record; this is a rendering of it. + Body any `json:"body"` +} + +var previewDec, _ = cbor.DecOptions{DefaultMapType: reflect.TypeOf(map[string]any(nil))}.DecMode() + +// Preview returns the last frame of each class, or a sample. +func (l *Link) Preview() Preview { + status := l.Status() + l.mu.Lock() + sent := make([]sentFrame, 0, len(l.sent)) + for _, f := range l.sent { + sent = append(sent, f) + } + up := l.uptimeMs() + l.mu.Unlock() + sort.Slice(sent, func(a, b int) bool { return sent[a].T < sent[b].T }) + + p := Preview{State: status.State, Home: status.Home, Frames: []PreviewFrame{}} + if len(sent) == 0 { + p.Sample = true + sent = l.sample() + } + for _, f := range sent { + pf := PreviewFrame{T: f.T, Frame: f.Frame, AgeMs: up - f.AtMs} + if payload, err := cloudwire.DecodeFrame(f.Frame); err == nil { + if env, err := cloudwire.DecodeEnvelope(payload); err == nil { + var body any + if previewDec.Unmarshal(env.B, &body) == nil { + pf.Body = body + } + } + } + p.Frames = append(p.Frames, pf) + } + return p +} + +// sample composes, unsigned and unsent, what the box would send now. +func (l *Link) sample() []sentFrame { + snap := l.current() + if snap.g == nil { + snap.g = &grantRecord{GrantKey: make([]byte, cloudwire.GrantKeyBytes), Scopes: protocol.CloudScopes} + } + up := l.uptimeMs() + var out []sentFrame + add := func(t string, body any) { + env, err := cloudwire.Seal(nil, snap.home, t, body) + if err != nil { + return + } + raw, err := env.Encode() + if err != nil { + return + } + frame, err := cloudwire.EncodeFrame(raw) + if err == nil { + out = append(out, sentFrame{T: t, Frame: frame, AtMs: up}) + } + } + h := cloudwire.Header{Boot: l.boot, UptimeMs: up} + gk := snap.g.GrantKey + if snap.allowed(cloudwire.MsgLive) { + live := l.project(gk, l.opts.Site.Live()) + live.Header = h + add(cloudwire.MsgLive, live) + } + if lp, ok := l.opts.Site.Loadpoints(); ok && snap.allowed(cloudwire.MsgLP) { + lp = l.projectLP(snap, lp) + lp.Header = h + add(cloudwire.MsgLP, lp) + } + if p, ok := l.opts.Site.Prices(); ok && snap.allowed(cloudwire.MsgPrices) { + p.Header = h + add(cloudwire.MsgPrices, p) + } + return out +} diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index 27072c483..fceaad484 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -71,6 +71,7 @@ const ( KindChargingConnected = "charging.connected" KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" + KindChargingGoalAtRisk = "charging.goal_at_risk" ) // HealthTick is fired every control-loop tick with the current telemetry @@ -156,9 +157,11 @@ type UpdateInstalled struct { func (UpdateInstalled) Kind() string { return KindUpdateInstalled } -// ChargingSessionComplete is emitted once per session when a fresh, -// matched vehicle BMS reading confirms that the active target was reached. -// A charger refusing current does not establish battery state. +// ChargingSessionComplete is emitted once per session when the active target +// was reached: by a fresh, matched vehicle BMS reading, or, for a car that +// reports nothing itself, by the box's estimate from a level someone set. +// The estimate is energy the box measured, so subscribers say it in kWh. A +// charger refusing current does not establish battery state. type ChargingSessionComplete struct { LoadpointID string KWh float64 // what the session meter delivered @@ -185,3 +188,18 @@ type ChargingConnected struct { } func (ChargingConnected) Kind() string { return KindChargingConnected } + +// ChargingGoalAtRisk is emitted once per plug-in when the plan cannot bring +// the car to its target by the ready time, and the box knows the car's +// level rather than assuming it. +type ChargingGoalAtRisk struct { + LoadpointID string + TargetSoC float64 // fraction + By time.Time + // ByClock is By as a household reads it, "07:00" in the box's zone. + ByClock string + ShortfallWh float64 + At time.Time +} + +func (ChargingGoalAtRisk) Kind() string { return KindChargingGoalAtRisk } diff --git a/go/internal/loadpoint/goal_risk.go b/go/internal/loadpoint/goal_risk.go new file mode 100644 index 000000000..af76420cc --- /dev/null +++ b/go/internal/loadpoint/goal_risk.go @@ -0,0 +1,59 @@ +package loadpoint + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +const ( + // GoalRiskMinWh is the smallest shortfall worth telling anyone about. + // Below it the plan is close enough that a rounding of the estimate or + // a slightly cheaper slot changes the answer. + GoalRiskMinWh = 1000 + // minCompletionWh keeps "car charged" for sessions that charged. + minCompletionWh = 100 +) + +// ObservePlanShortfall takes a fresh plan's shortfall per loadpoint, in +// battery watt-hours, and raises charging.goal_at_risk once per plug-in for +// a car that will not reach its target by its ready time. Only when the box +// knows the car's level, from the car or from someone who set it: on an +// assumed level the shortfall is a guess, and the plug-in notification +// already asks for the level. The caller passes only plans that are neither +// being remade nor out of date. +func (m *Manager) ObservePlanShortfall(shortfallWh map[string]float64, now time.Time) { + if m == nil || len(shortfallWh) == 0 { + return + } + var fired []events.Event + m.mu.Lock() + bus := m.bus + for _, id := range m.order { + lp, ok := m.byID[id] + if !ok || !lp.pluggedIn || lp.goalRiskNotified || !lp.socConfirmed || lp.completionNotified || + lp.targetSoC <= 0 || lp.targetTime.IsZero() || !lp.targetTime.After(now) { + continue + } + wh := shortfallWh[id] + if wh < GoalRiskMinWh { + continue + } + lp.goalRiskNotified = true + by := lp.targetTime.Local() + if m.loc != nil { + by = lp.targetTime.In(m.loc) + } + fired = append(fired, events.ChargingGoalAtRisk{ + LoadpointID: id, TargetSoC: lp.targetSoC, By: lp.targetTime, ByClock: by.Format("15:04"), + ShortfallWh: wh, At: now, + }) + } + m.mu.Unlock() + if bus == nil { + return + } + for _, e := range fired { + bus.Publish(e) + } +} diff --git a/go/internal/loadpoint/goal_risk_test.go b/go/internal/loadpoint/goal_risk_test.go new file mode 100644 index 000000000..6a99541f0 --- /dev/null +++ b/go/internal/loadpoint/goal_risk_test.go @@ -0,0 +1,136 @@ +package loadpoint + +import ( + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +func goalRiskLog(bus *events.Bus) func() []events.ChargingGoalAtRisk { + var mu sync.Mutex + var got []events.ChargingGoalAtRisk + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + mu.Lock() + got = append(got, ev) + mu.Unlock() + } + }) + return func() []events.ChargingGoalAtRisk { + mu.Lock() + defer mu.Unlock() + return append([]events.ChargingGoalAtRisk(nil), got...) + } +} + +// A car that reports nothing is done when the box's estimate, from a level +// someone set, reaches the target. Once, in kWh. +func TestACarWithoutItsOwnDataIsDoneAtTheBoxsEstimate(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.SetCurrentSoC("garage", 0.5) + r.tick(time.Hour, true, 11_000, 10_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatal("done before the estimate reached the target") + } + need := 0.3 * 60_000 / DefaultChargeEfficiency + r.tick(time.Hour, true, 11_000, need+50, true) + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d, want 1", c) + } + if got := r.log.complete[0]; got.KWh < need/1000 || got.LoadpointID != "garage" { + t.Fatalf("event %+v", got) + } + for range 5 { + r.tick(10*time.Minute, true, 0, need+50, false) + } + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d after hours, want 1", c) + } +} + +// On an assumed level the estimate is a guess, so nothing claims the car is +// done. +func TestAnAssumedLevelNeverSaysDone(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.tick(5*time.Hour, true, 11_000, 60_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d on an assumed level", c) + } +} + +// A car that reports its own level is done by its own reading, not by the +// box's estimate. +func TestACarWithItsOwnDataIsDoneOnlyByItsReading(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.AnchorVehicleSoC("garage", 0.5) + r.tick(5*time.Hour, true, 11_000, 30_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d from an estimate for a car that reports", c) + } +} + +// After a restart beside a car that is already done, nothing is said: it +// has not drawn power since the box started. +func TestARestartBesideAFinishedCarSaysNothing(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 0, 25_000, false) + r.mgr.SetCurrentSoC("garage", 0.85) + for range 5 { + r.tick(time.Minute, true, 0, 25_000, false) + } + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d after a restart", c) + } +} + +// The plan's shortfall tells the household once per plug-in, and only when +// the level is known, the goal has a time ahead, and the gap matters. +func TestGoalAtRiskOncePerPlugInOnAKnownLevel(t *testing.T) { + r := newSessionRig(t) + got := goalRiskLog(r.mgr.bus) + by := r.now.Add(8 * time.Hour) + r.mgr.SetTarget("garage", 0.8, by) + r.tick(0, true, 0, 0, true) + + short := map[string]float64{"garage": 9_000} + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk on an assumed level") + } + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(map[string]float64{"garage": GoalRiskMinWh - 1}, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk for a gap under the threshold") + } + r.mgr.ObservePlanShortfall(short, r.now) + r.mgr.ObservePlanShortfall(short, r.now.Add(time.Minute)) + evs := got() + if len(evs) != 1 { + t.Fatalf("goal at risk %d times, want once", len(evs)) + } + if e := evs[0]; e.LoadpointID != "garage" || e.TargetSoC != 0.8 || !e.By.Equal(by) || e.ShortfallWh != 9_000 { + t.Fatalf("event %+v", e) + } + + // A new plug-in may speak again, but not about a ready time that passed. + r.tick(time.Minute, false, 0, 0, false) + r.tick(time.Minute, true, 0, 0, true) + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(short, by.Add(time.Minute)) + if len(got()) != 1 { + t.Fatal("goal at risk for a ready time already gone") + } + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 2 { + t.Fatal("the next plug-in was not told") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index ce4dcfa46..530348366 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -363,6 +363,13 @@ type loadpointRuntime struct { sessionID string socRetention string completionNotified bool + // goalRiskNotified latches charging.goal_at_risk once per plug-in. + // vehicleAnchored is true once the car itself reported a level this + // session; chargedSinceStart once it drew power in this process, so a + // restart beside a car that is already done says nothing. + goalRiskNotified bool + vehicleAnchored bool + chargedSinceStart bool Config pluggedIn bool @@ -771,6 +778,9 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.sessionPluginSoC = anchor lp.socConfirmed = false lp.completionNotified = false + lp.goalRiskNotified = false + lp.vehicleAnchored = false + lp.chargedSinceStart = false lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} lp.chargingDeclined = false @@ -878,6 +888,17 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 if pluggedIn { lp.currentSoC = estimateSoC(lp.sessionPluginSoC, deliveredWh, lp.VehicleCapacityWh) + if powerW >= DeliveringW { + lp.chargedSinceStart = true + } + // A car that reports nothing itself is done when the box's own + // estimate, from a level someone set, reaches the target. Said in + // kWh, which the box measured; the level is an estimate. + if !lp.completionNotified && !lp.vehicleAnchored && lp.socConfirmed && lp.chargedSinceStart && + lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && deliveredWh >= minCompletionWh { + lp.completionNotified = true + fired = append(fired, events.ChargingSessionComplete{LoadpointID: id, KWh: deliveredWh / 1000, At: now}) + } } else { lp.currentSoC = 0 } @@ -1140,6 +1161,7 @@ func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time. } } reanchorSoCAtLocked(lp, socPct, deliveredAt, m.now()) + lp.vehicleAnchored = true if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified { lp.completionNotified = true completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 48c5cb74a..868ca2753 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -14,10 +14,13 @@ const ( PushChargingConnected = protocol.PushChargingConnected PushChargingSessionComplete = protocol.PushChargingSessionComplete PushChargingInterrupted = protocol.PushChargingInterrupted + PushChargingGoalAtRisk = protocol.PushChargingGoalAtRisk PushUpdateInstalled = protocol.PushUpdateInstalled PushDriverOffline = protocol.PushDriverOffline PushFuseOverLimit = protocol.PushFuseOverLimit PushBoxUnreachable = protocol.PushBoxUnreachable + PushCloudGranted = protocol.PushCloudGranted + PushCloudRevoked = protocol.PushCloudRevoked ) // PushSentence is one lock-screen sentence, exactly as the app wrote it. diff --git a/go/internal/notifications/charging.go b/go/internal/notifications/charging.go new file mode 100644 index 000000000..4ca51b792 --- /dev/null +++ b/go/internal/notifications/charging.go @@ -0,0 +1,51 @@ +package notifications + +import "time" + +// ConnectedSettle is how long after a plug-in the box decides whether to +// tell anyone. A car that reports its own level usually does so within it, +// and a car whose level and goal are both known needs no question. +const ConnectedSettle = 90 * time.Second + +// ChargingFacts is what the plug-in rule needs to know about a loadpoint. +type ChargingFacts struct { + Plugged bool + // LevelKnown is true when the car's level came from the car or from + // someone who set it, not from the plug-in assumption. + LevelKnown bool + // HasGoal is true when a target level is set. + HasGoal bool +} + +// SetChargingFacts wires what the plug-in rule reads. Nil tells every +// plug-in, as before. +func (s *Service) SetChargingFacts(f func(loadpointID string) (ChargingFacts, bool)) { + if s == nil { + return + } + s.mu.Lock() + s.charging = f + s.mu.Unlock() +} + +// handleConnected tells a plug-in only when the box needs the household: +// the car's level is an assumption, or no goal is set. It decides after +// ConnectedSettle, and says nothing if the cable came out meanwhile. +func (s *Service) handleConnected(loadpointID string) { + if s == nil { + return + } + s.mu.Lock() + facts, settle := s.charging, s.connectedSettle + s.mu.Unlock() + if facts != nil { + if settle > 0 { + time.Sleep(settle) + } + f, ok := facts(loadpointID) + if !ok || !f.Plugged || (f.LevelKnown && f.HasGoal) { + return + } + } + s.handleCatalogued(PushChargingConnected, loadpointID, nil) +} diff --git a/go/internal/notifications/cloud.go b/go/internal/notifications/cloud.go new file mode 100644 index 000000000..e5a4e0141 --- /dev/null +++ b/go/internal/notifications/cloud.go @@ -0,0 +1,58 @@ +package notifications + +import "context" + +// NotifyCloudGrant tells every owner that FTW Cloud was turned on or off, +// with the catalogue's sentence for kind. The rule gates it like any other, +// but a box whose stored rules predate these kinds still sends it: a missing +// rule reads as the default, which is on. +func (s *Service) NotifyCloudGrant(kind string) { + if s == nil || (kind != PushCloudGranted && kind != PushCloudRevoked) { + return + } + s.mu.Lock() + if s.cfg == nil || !s.cfg.Enabled { + s.mu.Unlock() + return + } + rule, ok := findRule(s.cfg.Events, kind) + if !ok { + rule, _ = findRule(DefaultRules(), kind) + } + prio := rule.Priority + if prio == 0 { + prio = s.cfg.DefaultPriority + } + s.mu.Unlock() + if !rule.Enabled { + return + } + + title, body, err := RenderPush(kind, nil) + if err != nil { + s.bumpFailed() + s.emitDispatched(kind, "", Message{Priority: prio}, "failed", err.Error()) + return + } + s.deliver(kind, "", Message{Kind: kind, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags)}) +} + +// CloudPublisher hands each catalogue-worded message to FTW Cloud's uplink, +// which signs it and sends it as an event for the owners' phones. It only +// queues: the uplink sends on its own goroutine, so a slow or absent service +// never holds up another transport. Template rules carry no kind and stay on +// the box's own transports. +type CloudPublisher struct { + // Queue takes one message and reports whether FTW Cloud will carry it. + // False means FTW Cloud is off or its grant leaves events out. + Queue func(Message) bool +} + +// Publish queues m, or answers ErrNothingToSend when FTW Cloud will not +// carry it. +func (p CloudPublisher) Publish(_ context.Context, m Message) error { + if m.Kind == "" || p.Queue == nil || !p.Queue(m) { + return ErrNothingToSend + } + return nil +} diff --git a/go/internal/notifications/cloud_events_test.go b/go/internal/notifications/cloud_events_test.go new file mode 100644 index 000000000..2bf9a1edc --- /dev/null +++ b/go/internal/notifications/cloud_events_test.go @@ -0,0 +1,150 @@ +package notifications + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/events" +) + +// K5: a catalogue sentence carries its kind and its arguments, so a +// transport whose phones write their own words, as FTW Cloud's do, gets the +// facts and not only the English. +func TestACatalogueMessageCarriesItsKindAndArgs(t *testing.T) { + pub := &fakePub{} + svc, clk := newSvc(pushCfg(config.NotificationRule{ + Type: PushDriverOffline, Enabled: true, ThresholdS: 600, Priority: 4, CooldownS: 3600, + }), pub) + last := clk.now() + clk.advance(800 * time.Second) + svc.Observe(healthStale(last)) + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("got %d msgs", len(msgs)) + } + m := msgs[0] + if m.Kind != PushDriverOffline || m.Args["name"] != "ferroamp" || m.Device != "ferroamp" || m.At.IsZero() { + t.Fatalf("message %+v", m) + } +} + +// hangingPub never answers until its context ends. +type hangingPub struct{ entered chan struct{} } + +func (h hangingPub) Publish(ctx context.Context, _ Message) error { + close(h.entered) + <-ctx.Done() + return ctx.Err() +} + +// Each transport has its own time: one that hangs does not hold up another. +func TestAHangingTransportDoesNotDelayAnother(t *testing.T) { + quick := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{ + Type: PushChargingInterrupted, Enabled: true, Priority: 4, + }), hangingPub{entered: make(chan struct{})}) + svc.AddPublisher(quick) + bus := events.NewBus() + svc.Subscribe(bus) + + start := time.Now() + bus.Publish(events.ChargingInterrupted{LoadpointID: "garage", At: start}) + select { + case <-quick.published: + case <-time.After(2 * time.Second): + t.Fatal("a hanging transport held up the other") + } + if waited := time.Since(start); waited > time.Second { + t.Fatalf("the quick transport waited %s", waited) + } +} + +// The FTW Cloud transport only queues, and only messages with a kind. +func TestCloudPublisherQueuesOnlyCatalogueKinds(t *testing.T) { + var mu sync.Mutex + var queued []Message + on := true + p := CloudPublisher{Queue: func(m Message) bool { + mu.Lock() + defer mu.Unlock() + queued = append(queued, m) + return on + }} + if err := p.Publish(context.Background(), Message{Title: "FTW: test"}); err != ErrNothingToSend { + t.Fatalf("a template message went to FTW Cloud: %v", err) + } + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != nil { + t.Fatal(err) + } + on = false + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("FTW Cloud off answered %v", err) + } + if len(queued) != 2 { + t.Fatalf("queued %d", len(queued)) + } + if err := (CloudPublisher{}).Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("no queue answered %v", err) + } +} + +// The plug-in notification asks only when the box needs the household: the +// level is assumed or no goal is set. It decides after a pause, and says +// nothing about a cable already out. +func TestPlugInAsksOnlyWhenTheBoxNeedsTheHousehold(t *testing.T) { + cases := []struct { + name string + facts ChargingFacts + ok bool + want int + }{ + {"assumed level, goal set", ChargingFacts{Plugged: true, HasGoal: true}, true, 1}, + {"known level, no goal", ChargingFacts{Plugged: true, LevelKnown: true}, true, 1}, + {"known level and goal", ChargingFacts{Plugged: true, LevelKnown: true, HasGoal: true}, true, 0}, + {"unplugged meanwhile", ChargingFacts{}, true, 0}, + {"unknown loadpoint", ChargingFacts{}, false, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: true}), pub) + svc.connectedSettle = 0 + svc.SetChargingFacts(func(id string) (ChargingFacts, bool) { + if id != "garage" { + t.Errorf("asked about %q", id) + } + return c.facts, c.ok + }) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: time.Now()}) + if c.want == 1 { + waitForMsgs(t, pub, 1) + return + } + settle(svc) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("sent %d", n) + } + }) + } +} + +// Goal at risk says the target and the ready time. +func TestGoalAtRiskSaysTargetAndTime(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingGoalAtRisk, Enabled: true, Priority: 4}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingGoalAtRisk{LoadpointID: "garage", TargetSoC: 0.8, ByClock: "07:00", At: time.Now()}) + m := waitForMsgs(t, pub, 1)[0] + if m.Kind != PushChargingGoalAtRisk || m.LoadpointID != "garage" || m.Args["target"] != "80" || m.Args["by"] != "07:00" { + t.Fatalf("message %+v", m) + } + if m.Title != "The car may not be ready" || m.Body != "It will not reach 80 % by 07:00 at this pace." { + t.Fatalf("words %q / %q", m.Title, m.Body) + } +} diff --git a/go/internal/notifications/cloud_test.go b/go/internal/notifications/cloud_test.go new file mode 100644 index 000000000..b2081c388 --- /dev/null +++ b/go/internal/notifications/cloud_test.go @@ -0,0 +1,29 @@ +package notifications + +import ( + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +// FTW Cloud on or off reaches the owners even on a box whose stored rules +// predate the two kinds, and stays silent when an owner turned it off. +func TestCloudGrantNoticesAreOnUnlessTurnedOff(t *testing.T) { + pub := &fakePub{} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingSessionComplete, Enabled: true}), pub) + svc.NotifyCloudGrant(PushCloudGranted) + settle(svc) + msgs := pub.Messages() + if len(msgs) != 1 || msgs[0].Kind != PushCloudGranted || msgs[0].Title != "FTW Cloud is on" { + t.Fatalf("messages %+v", msgs) + } + + pub = &fakePub{} + svc, _ = newSvc(pushCfg(config.NotificationRule{Type: PushCloudRevoked, Enabled: false}), pub) + svc.NotifyCloudGrant(PushCloudRevoked) + svc.NotifyCloudGrant("charging.connected") + settle(svc) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("an owner's off switch was ignored, or another kind went through: %d", n) + } +} diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index 2e6ae2eda..bc63b5908 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "log/slog" + "math" "sort" "strconv" "strings" @@ -97,12 +98,21 @@ func DefaultRules() []config.NotificationRule { {Type: PushChargingConnected, Enabled: false, Priority: 3, CooldownS: 60}, {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, + // Once per plug-in, only when the box knows the car's level and the + // plan cannot make the goal: rare, and the moment to act. + {Type: PushChargingGoalAtRisk, Enabled: false, Priority: 4}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, // Phone lock-screen counterparts of the operator rules above. // Same thresholds so a blip or a kettle is not a notification; // the sentences come from the catalogue, never from templates. {Type: PushDriverOffline, Enabled: false, ThresholdS: DefaultThresholdS, Priority: 4, CooldownS: DefaultCooldownS}, {Type: PushFuseOverLimit, Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, + // FTW Cloud on or off. The only rules on by default: each says the + // house has started or stopped sending a readable copy of itself to + // a Sourceful service, and every owner should hear that without + // having opted in first. An owner may still turn them off. + {Type: PushCloudGranted, Enabled: true, Priority: 3}, + {Type: PushCloudRevoked, Enabled: true, Priority: 3}, } } @@ -116,7 +126,8 @@ func KnownRuleTypes() []string { EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, - PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, + PushChargingGoalAtRisk, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, + PushCloudGranted, PushCloudRevoked, } } @@ -138,6 +149,14 @@ type Message struct { Body string Priority int Tags []string + // Args are the catalogue's placeholders, for a transport whose phones + // write their own words, as FTW Cloud's do. Nil for template rules. + Args map[string]string + // Device is the driver the event concerns, for a transport that names + // it some other way than by its name. + Device string + // At is when the event happened. + At time.Time } // Publisher dispatches a rendered Message to its transport. @@ -254,6 +273,10 @@ type Service struct { sent uint64 failed uint64 now func() time.Time + // charging answers what the plug-in rule needs to know about a + // loadpoint; connectedSettle is how long it waits before asking. + charging func(loadpointID string) (ChargingFacts, bool) + connectedSettle time.Duration } // New constructs a Service. cfg may be nil (no-op until Reload is called). @@ -267,6 +290,7 @@ func New(cfg *config.Notifications, pub Publisher, lookup DeviceLookup) *Service activeAlert: map[string]bool{}, fuseFirstOverAt: map[string]time.Time{}, now: time.Now, + connectedSettle: ConnectedSettle, } } @@ -391,7 +415,15 @@ func (s *Service) Subscribe(bus *events.Bus) { // cooldown. Words come from the catalogue and nowhere else. bus.Subscribe(events.KindChargingConnected, func(e events.Event) { if ev, ok := e.(events.ChargingConnected); ok { - go s.handleCatalogued(PushChargingConnected, ev.LoadpointID, nil) + go s.handleConnected(ev.LoadpointID) + } + }) + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + go s.handleCatalogued(PushChargingGoalAtRisk, ev.LoadpointID, map[string]string{ + "target": strconv.Itoa(int(math.Round(ev.TargetSoC * 100))), + "by": ev.ByClock, + }) } }) bus.Subscribe(events.KindChargingSessionComplete, func(e events.Event) { @@ -470,6 +502,8 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + At: now, }) } @@ -867,19 +901,7 @@ func (s *Service) SendTest() error { Body: fmt.Sprintf("Test notification sent at %s.", time.Now().UTC().Format(time.RFC3339)), Priority: prio, } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(pubs, msg) var err error if sent == 0 { if len(errs) > 0 { @@ -946,7 +968,8 @@ func (s *Service) buildData(driver, eventType string, since time.Duration, now t } func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.NotificationRule, data templateData) { - title, body, err := RenderPush(rule.Type, catalogueArgs(rule.Type, data)) + args := catalogueArgs(rule.Type, data) + title, body, err := RenderPush(rule.Type, args) if err != nil { slog.Warn("notifications: catalogue render failed", "event", rule.Type, "err", err) s.bumpFailed() @@ -958,10 +981,14 @@ func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.Notif prio = cfg.DefaultPriority } s.deliver(rule.Type, data.Device, Message{ + Kind: rule.Type, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + Device: data.Device, + At: s.now(), }) } @@ -1033,22 +1060,7 @@ func (s *Service) dispatch(cfg *config.Notifications, rule config.NotificationRu // enabled with no provider installed — and stays a truthful failure in the // history rather than a panic or a silence. func (s *Service) deliver(eventType, device string, msg Message) { - pubs := s.publishers() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - // Absence, not failure. - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(s.publishers(), msg) switch { case sent > 0: if len(errs) > 0 { @@ -1069,6 +1081,35 @@ func (s *Service) deliver(eventType, device string, msg Message) { } } +// PublishTimeout bounds one transport's Publish. Each transport has its own, +// and they run side by side, so a gateway that hangs costs only itself. +const PublishTimeout = 10 * time.Second + +// publishAll hands msg to every transport at once, each under its own +// timeout, and counts the outcome. ErrNothingToSend is absence, not failure. +func publishAll(pubs []Publisher, msg Message) (sent int, errs []error) { + results := make([]error, len(pubs)) + var wg sync.WaitGroup + for i, pub := range pubs { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), PublishTimeout) + defer cancel() + results[i] = pub.Publish(ctx, msg) + }) + } + wg.Wait() + for _, err := range results { + switch { + case err == nil: + sent++ + case errors.Is(err, ErrNothingToSend): + default: + errs = append(errs, err) + } + } + return sent, errs +} + // emitDispatched publishes a NotificationDispatched event for history // loggers + future audit subscribers. Safe when the bus is nil. func (s *Service) emitDispatched(eventType, driver string, m Message, status, errStr string) { diff --git a/go/protocol/appwire/initiator.go b/go/protocol/appwire/initiator.go new file mode 100644 index 000000000..5da59e49b --- /dev/null +++ b/go/protocol/appwire/initiator.go @@ -0,0 +1,167 @@ +package appwire + +import ( + "fmt" + "sync" +) + +// Noise_IK_25519_ChaChaPoly_SHA256, initiator half. +// +// The box is always a responder, so Core never runs this. It exists for the +// other side of FTW Cloud's app session, where the service is the responder +// and a phone the initiator: the service's tests and any Go client speak to +// it with this. Verified against the same Cacophony vector as the responder. + +// InitiatorOptions configures the initiator. +type InitiatorOptions struct { + // StaticKey is the initiator's long-term key. + StaticKey KeyPair + // RemoteStatic is the responder's static key, known before the first + // byte: IK's pre-message. + RemoteStatic []byte + // Prologue is mixed in before anything else. Both ends must agree on it + // byte for byte. + Prologue []byte + // Ephemeral fixes the ephemeral key. Test vectors only. + Ephemeral *KeyPair +} + +type initStep int + +const ( + initWrite1 initStep = iota + initRead2 + initDone + initSplit +) + +// InitiatorState runs the initiator side of IK. Drive it with WriteMessage, +// then ReadMessage, then Split. +type InitiatorState struct { + mu sync.Mutex + sym *symmetricState + s KeyPair + e KeyPair + rs []byte + step initStep +} + +// NewInitiator starts a handshake toward a responder whose static key is +// already known. +func NewInitiator(opts InitiatorOptions) (*InitiatorState, error) { + if opts.StaticKey.priv == nil { + return nil, fmt.Errorf("%w: initiator needs a static key", ErrNoiseKey) + } + if len(opts.RemoteStatic) != DHBytes { + return nil, fmt.Errorf("%w: responder key is %d bytes, need %d", ErrNoiseKey, len(opts.RemoteStatic), DHBytes) + } + h := &InitiatorState{sym: newSymmetricState(), s: opts.StaticKey, rs: append([]byte{}, opts.RemoteStatic...)} + if opts.Ephemeral != nil { + h.e = *opts.Ephemeral + } + h.sym.mixHash(opts.Prologue) + h.sym.mixHash(h.rs) + return h, nil +} + +// WriteMessage produces `-> e, es, s, ss` carrying payload. +func (h *InitiatorState) WriteMessage(payload []byte) ([]byte, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.step != initWrite1 { + return nil, fmt.Errorf("%w: cannot write now", ErrNoiseState) + } + if h.e.priv == nil { + e, err := GenerateKeyPair() + if err != nil { + return nil, err + } + h.e = e + } + out := h.e.Public() + h.sym.mixHash(out) + + es, err := h.e.dh(h.rs) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(es); err != nil { + return nil, err + } + encStatic, err := h.sym.encryptAndHash(h.s.Public()) + if err != nil { + return nil, err + } + ss, err := h.s.dh(h.rs) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(ss); err != nil { + return nil, err + } + encPayload, err := h.sym.encryptAndHash(payload) + if err != nil { + return nil, err + } + h.step = initRead2 + out = append(out, encStatic...) + return append(out, encPayload...), nil +} + +// ReadMessage consumes `<- e, ee, se` and returns its payload. +func (h *InitiatorState) ReadMessage(message []byte) ([]byte, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.step != initRead2 { + return nil, fmt.Errorf("%w: cannot read now", ErrNoiseState) + } + if len(message) < DHBytes+TagBytes { + return nil, fmt.Errorf("%w: handshake message 2 is %d bytes", ErrNoiseMessage, len(message)) + } + re := append([]byte{}, message[:DHBytes]...) + h.sym.mixHash(re) + ee, err := h.e.dh(re) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(ee); err != nil { + return nil, err + } + se, err := h.s.dh(re) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(se); err != nil { + return nil, err + } + payload, err := h.sym.decryptAndHash(message[DHBytes:]) + if err != nil { + return nil, err + } + h.step = initDone + return payload, nil +} + +// Split ends the handshake and hands over the transport keys. Like the +// responder's, it works once. +func (h *InitiatorState) Split() (*Session, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.step == initSplit { + return nil, fmt.Errorf("%w: handshake already split", ErrNoiseState) + } + if h.step != initDone { + return nil, fmt.Errorf("%w: handshake is not finished", ErrNoiseState) + } + keys, err := h.sym.split() + if err != nil { + return nil, err + } + h.step = initSplit + return &Session{ + Send: keys[0], + Recv: keys[1], + HandshakeHash: append([]byte{}, h.sym.h...), + RemoteStatic: append([]byte{}, h.rs...), + }, nil +} diff --git a/go/protocol/appwire/initiator_test.go b/go/protocol/appwire/initiator_test.go new file mode 100644 index 000000000..158f72779 --- /dev/null +++ b/go/protocol/appwire/initiator_test.go @@ -0,0 +1,120 @@ +package appwire + +import ( + "bytes" + "encoding/hex" + "errors" + "testing" +) + +// The vector's initiator ephemeral, from the same Cacophony entry. The +// responder test does not need it; the initiator cannot run without it. +const cacophonyInitEphemeral = "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a" + +func TestCacophonyVectorAsInitiator(t *testing.T) { + eph := keyFromHex(t, cacophonyInitEphemeral) + initiator, err := NewInitiator(InitiatorOptions{ + StaticKey: keyFromHex(t, cacophony.initStatic), + RemoteStatic: unhex(t, cacophony.initRemoteStatc), + Prologue: unhex(t, cacophony.prologue), + Ephemeral: &eph, + }) + if err != nil { + t.Fatalf("NewInitiator: %v", err) + } + message1, err := initiator.WriteMessage(unhex(t, cacophony.messages[0].payload)) + if err != nil { + t.Fatalf("WriteMessage: %v", err) + } + if got := hex.EncodeToString(message1); got != cacophony.messages[0].ciphertext { + t.Fatalf("message 1 = %s, want %s", got, cacophony.messages[0].ciphertext) + } + payload, err := initiator.ReadMessage(unhex(t, cacophony.messages[1].ciphertext)) + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + if got := hex.EncodeToString(payload); got != cacophony.messages[1].payload { + t.Fatalf("message 2 payload = %s, want %s", got, cacophony.messages[1].payload) + } + session, err := initiator.Split() + if err != nil { + t.Fatalf("Split: %v", err) + } + if got := hex.EncodeToString(session.HandshakeHash); got != cacophony.handshakeHash { + t.Fatalf("handshake hash = %s, want %s", got, cacophony.handshakeHash) + } + for i, msg := range cacophony.messages[2:] { + if i%2 == 0 { + ciphertext, err := session.Send.EncryptWithAD(nil, unhex(t, msg.payload)) + if err != nil || hex.EncodeToString(ciphertext) != msg.ciphertext { + t.Fatalf("transport %d: %x %v", i, ciphertext, err) + } + continue + } + plaintext, err := session.Recv.DecryptWithAD(nil, unhex(t, msg.ciphertext)) + if err != nil || hex.EncodeToString(plaintext) != msg.payload { + t.Fatalf("transport %d: %x %v", i, plaintext, err) + } + } + if _, err := initiator.Split(); !errors.Is(err, ErrNoiseState) { + t.Fatalf("second Split: %v", err) + } +} + +// An initiator and the responder agree with each other, and a transport +// built on each side carries frames both ways. +func TestInitiatorAndResponderMeet(t *testing.T) { + phone, _ := GenerateKeyPair() + service, _ := GenerateKeyPair() + prologue := []byte("ftw.cloud.v1:test") + init, err := NewInitiator(InitiatorOptions{StaticKey: phone, RemoteStatic: service.Public(), Prologue: prologue}) + if err != nil { + t.Fatal(err) + } + resp, err := NewResponder(HandshakeOptions{StaticKey: service, Prologue: prologue}) + if err != nil { + t.Fatal(err) + } + if resp.RemoteStatic() != nil { + t.Fatal("the responder named a key before message 1") + } + m1, _ := init.WriteMessage([]byte("now")) + p1, err := resp.ReadMessage(m1) + if err != nil || string(p1) != "now" { + t.Fatalf("message 1: %q %v", p1, err) + } + if !bytes.Equal(resp.RemoteStatic(), phone.Public()) { + t.Fatal("after message 1 the responder does not know the initiator's key") + } + m2, _ := resp.WriteMessage([]byte("snapshot")) + p2, err := init.ReadMessage(m2) + if err != nil || string(p2) != "snapshot" { + t.Fatalf("message 2: %q %v", p2, err) + } + is, _ := init.Split() + rs, _ := resp.Split() + if !bytes.Equal(rs.RemoteStatic, phone.Public()) || !bytes.Equal(is.HandshakeHash, rs.HandshakeHash) { + t.Fatal("the two ends disagree about the session") + } + a, b := NewTransport(is), NewTransport(rs) + msg, _ := b.Encrypt([]byte("frame")) + got, err := a.Decrypt(msg) + if err != nil || string(got) != "frame" { + t.Fatalf("transport: %q %v", got, err) + } +} + +func TestInitiatorRefusesAnotherResponder(t *testing.T) { + phone, _ := GenerateKeyPair() + service, _ := GenerateKeyPair() + impostor, _ := GenerateKeyPair() + init, _ := NewInitiator(InitiatorOptions{StaticKey: phone, RemoteStatic: service.Public()}) + resp, _ := NewResponder(HandshakeOptions{StaticKey: impostor}) + m1, _ := init.WriteMessage(nil) + if _, err := resp.ReadMessage(m1); !errors.Is(err, ErrNoiseAuth) { + t.Fatalf("an impostor read message 1: %v", err) + } + if _, err := NewInitiator(InitiatorOptions{StaticKey: phone, RemoteStatic: []byte{1, 2}}); !errors.Is(err, ErrNoiseKey) { + t.Fatalf("short responder key: %v", err) + } +} diff --git a/go/protocol/appwire/noise.go b/go/protocol/appwire/noise.go index 8df400bdf..f9303a381 100644 --- a/go/protocol/appwire/noise.go +++ b/go/protocol/appwire/noise.go @@ -496,6 +496,19 @@ func (h *HandshakeState) WriteMessage(payload []byte) ([]byte, error) { return append(epub, encrypted...), nil } +// RemoteStatic is the initiator's static key once ReadMessage has +// authenticated it, and nil before. A responder that admits initiators by key, +// as FTW Cloud admits phones by the box's roster, decides here, before +// message 2 carries anything. +func (h *HandshakeState) RemoteStatic() []byte { + h.mu.Lock() + defer h.mu.Unlock() + if h.step == stepRead1 { + return nil + } + return append([]byte{}, h.rs...) +} + // Complete reports whether the pattern has run to its end. func (h *HandshakeState) Complete() bool { h.mu.Lock() diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go new file mode 100644 index 000000000..22fb4f80f --- /dev/null +++ b/go/protocol/cloudwire/allowlist.go @@ -0,0 +1,55 @@ +package cloudwire + +// Fields is the allowlist of everything the box may send FTW Cloud: one table +// per class, and one per nested shape, as docs/protocol-cloud.md lists them. +// Header holds the four fields every box body carries. +// +// A field reaches a phone only under its class's scope (ClassScope); fields +// of a nested table belong to the class that carries it. Driver names, device +// capabilities and inventory have no row, so the box has no field to put +// them in. TestEveryFieldHasARow fails when a body type grows a field this +// table does not list, or the table lists one the type does not have. +var Fields = map[string][]string{ + "header": {"seq", "boot", "uptimeMs", "wallMs"}, + + MsgHello: {"v", "pk", "nonce", "ekm", "boxNonce", "build", "clock", "caps", "rosterSeq", "grantId"}, + + MsgLive: {"fields", "fieldSources", "sources", "srcRev", "ages", "dispatchBlockedBy"}, + "Source": {"kind", "state", "lastOkMs", "staleAfterMs"}, + + MsgLP: {"controlRev", "loadpoints", "feedback"}, + "Loadpoint": { + "id", "src", "plugged", "socPermille", "socSource", "socAgeS", + "targetPermille", "readyByMs", "recurring", "powerW", "sessionWh", + "session", "manual", "commandedW", "commandedReason", "surplusOnly", + "gridDeferred", "planPending", "boost", "nextStartMs", "nextEndMs", "nextWh", + }, + "Manual": {"state", "sinceAgeMs", "requestedW", "commandedW", "reason"}, + "Feedback": {"kind", "src", "status", "reason", "severity", "evidence", "fresh", "actualW", "ageMs"}, + + MsgPrices: {"zone", "currency", "t0Ms", "slotMs", "spotMinor", "totalMinor"}, + + MsgHB: {"clock", "srcRev", "liveSeq", "lpSeq", "ages"}, + + MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp"}, + + MsgRoster: {"rosterSeq", "ttlS", "devices", "grant"}, + "Device": {"id", "key", "role", "scopes", "epoch"}, + "Grant": {"id", "scopes", "purposes", "controller"}, + + MsgBye: {"why", "purge"}, +} + +// Bodies maps each class the box sends to its body type. Classes the box does +// not send yet (plan, health, tiles) have no entry; the service treats their +// bytes as opaque either way. +var Bodies = map[string]any{ + MsgHello: Hello{}, + MsgLive: Live{}, + MsgLP: LP{}, + MsgPrices: Prices{}, + MsgHB: HB{}, + MsgEvent: Event{}, + MsgRoster: Roster{}, + MsgBye: Bye{}, +} diff --git a/go/protocol/cloudwire/allowlist_test.go b/go/protocol/cloudwire/allowlist_test.go new file mode 100644 index 000000000..9e07d4fe8 --- /dev/null +++ b/go/protocol/cloudwire/allowlist_test.go @@ -0,0 +1,166 @@ +package cloudwire + +import ( + "bufio" + "os" + "reflect" + "regexp" + "slices" + "sort" + "strings" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// cborFields lists a struct's wire names, with the header's four apart. +func cborFields(t *testing.T, typ reflect.Type) (fields []string, nested map[string]reflect.Type, header bool) { + t.Helper() + nested = map[string]reflect.Type{} + for i := range typ.NumField() { + f := typ.Field(i) + if f.Anonymous { + if f.Type != reflect.TypeOf(Header{}) { + t.Fatalf("%s embeds %s; only Header may be embedded", typ.Name(), f.Type) + } + header = true + continue + } + name := strings.Split(f.Tag.Get("cbor"), ",")[0] + if name == "" || name == "-" { + t.Fatalf("%s.%s has no wire name", typ.Name(), f.Name) + } + fields = append(fields, name) + inner := f.Type + for inner.Kind() == reflect.Pointer || inner.Kind() == reflect.Slice || inner.Kind() == reflect.Map { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct { + nested[inner.Name()] = inner + } + } + return fields, nested, header +} + +func sorted(s []string) []string { + out := slices.Clone(s) + sort.Strings(out) + return out +} + +// Every field the box can encode has a row in Fields, and every row is a +// field. A new field without a row fails here, before it can leave a house. +func TestEveryFieldHasARow(t *testing.T) { + seen := map[string]bool{} + var walk func(table string, typ reflect.Type, wantHeader bool) + walk = func(table string, typ reflect.Type, wantHeader bool) { + fields, nested, header := cborFields(t, typ) + if header != wantHeader { + t.Errorf("%s: header embedded = %v, want %v", table, header, wantHeader) + } + rows, ok := Fields[table] + if !ok { + t.Errorf("%s has no table in Fields", table) + return + } + seen[table] = true + if got, want := sorted(fields), sorted(rows); !slices.Equal(got, want) { + t.Errorf("%s: fields %v, rows %v", table, got, want) + } + for name, inner := range nested { + walk(name, inner, false) + } + } + for class, body := range Bodies { + walk(class, reflect.TypeOf(body), true) + } + header, _, _ := cborFields(t, reflect.TypeOf(Header{})) + if !slices.Equal(sorted(header), sorted(Fields["header"])) { + t.Errorf("header: fields %v, rows %v", header, Fields["header"]) + } + seen["header"] = true + for table := range Fields { + if !seen[table] { + t.Errorf("Fields has a %s table no body uses", table) + } + } +} + +// Each class a phone may read belongs to exactly one scope, and the scope is +// one an owner can grant FTW Cloud. +func TestEveryForwardedClassHasAGrantableScope(t *testing.T) { + grantable := map[string]bool{"": true} + for _, s := range protocol.CloudScopes { + grantable[s] = true + } + for class, scope := range ClassScope { + if !grantable[scope] { + t.Errorf("%s needs %q, which a grant cannot name", class, scope) + } + if slices.Contains(ServiceOnly, class) { + t.Errorf("%s is both forwarded and service-only", class) + } + } + for class := range Bodies { + _, forwarded := ClassScope[class] + if forwarded == slices.Contains(ServiceOnly, class) { + t.Errorf("%s must be either forwarded or service-only", class) + } + } +} + +// The tables in docs/protocol-cloud.md and Fields say the same thing. The +// document is normative; this keeps the code from drifting from it. +func TestFieldsMatchTheProtocolDocument(t *testing.T) { + const path = "../../../docs/protocol-cloud.md" + f, err := os.Open(path) + if os.IsNotExist(err) { + t.Skipf("%s is not in this checkout yet", path) + } + if err != nil { + t.Fatal(err) + } + defer f.Close() + + // A table belongs to the nearest heading above it: "### `live`", a bold + // "**Source**" or "**`roster`**", a numbered "**Box → service: `hello`**", + // or "## Common body fields" for the header. Any other heading ends it. + classHeading := regexp.MustCompile("^### `([a-z.]+)`") + boldHeading := regexp.MustCompile("^\\*\\*`?([A-Za-z]+)`?\\*\\*") + stepHeading := regexp.MustCompile("^\\d+\\. \\*\\*[^`*]*`([a-z_]+)`\\*\\*") + row := regexp.MustCompile("^\\s*\\| `([A-Za-z][A-Za-z0-9]*)` \\|") + doc := map[string][]string{} + current := "" + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + switch { + case classHeading.MatchString(line): + current = classHeading.FindStringSubmatch(line)[1] + case stepHeading.MatchString(line): + current = stepHeading.FindStringSubmatch(line)[1] + case strings.HasPrefix(line, "## Common body fields"): + current = "header" + case strings.HasPrefix(line, "#"), strings.HasPrefix(line, "**"): + current = "" + if m := boldHeading.FindStringSubmatch(line); m != nil { + current = m[1] + } + case current != "" && row.MatchString(line): + doc[current] = append(doc[current], row.FindStringSubmatch(line)[1]) + } + } + for table, rows := range Fields { + if table == MsgBye { + continue // stated in prose, not in a table + } + got, ok := doc[table] + if !ok { + t.Errorf("the document has no %s table", table) + continue + } + if !slices.Equal(sorted(got), sorted(rows)) { + t.Errorf("%s: document %v, code %v", table, sorted(got), sorted(rows)) + } + } +} diff --git a/go/protocol/cloudwire/app.go b/go/protocol/cloudwire/app.go new file mode 100644 index 000000000..d7005687c --- /dev/null +++ b/go/protocol/cloudwire/app.go @@ -0,0 +1,153 @@ +package cloudwire + +import ( + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +// The app session: a phone reads its home from FTW Cloud. +// +// The phone opens wss://cloud.ftw.energy/v1/app?home= and runs +// Noise_IK_25519_ChaChaPoly_SHA256 as initiator, with the service's Noise key +// as the responder's static key and AppPrologue(home) as the prologue. The +// query only says which home to look in: a wrong one fails the handshake, +// because the prologue binds it. Message 1 carries a now envelope, message 2 +// the snapshot, and after that each box envelope the device may read rides, +// unchanged, in an unpadded lane 1 frame inside a Noise transport message, +// exactly as appwire.Transport writes one. +// +// Every envelope between phone and service has an empty sig. Only box +// envelopes carry signatures, and the phone checks them with the box key it +// learned from the box over its own Noise session, never from the service. + +// AppPrologueTag starts the prologue; the home id follows it. +const AppPrologueTag = "ftw.cloud.v1:" + +// AppHomeParam is the query parameter that names the home. +const AppHomeParam = "home" + +// Phone to service. The service acts on these and ignores every other type. +const ( + // MsgNow is message 1's payload. Watch semantics follow. + MsgNow = "now" + // MsgSuspend and MsgUnsuspend are an owner's; they only narrow access. + MsgSuspend = "suspend" + MsgUnsuspend = "unsuspend" + // MsgWatch (shared with the downlink) changes whether this phone is + // looking: hz 1 while it can see the home, 0 when it cannot. +) + +// Service to phone. +const ( + // MsgSnapshot is message 2's payload. + MsgSnapshot = "snapshot" +) + +// Close codes on /v1/app. A phone reads them to decide whether to retry. +const ( + // CloseProtocol: the phone sent something this protocol does not allow. + CloseProtocol = 4400 + // CloseDenied is every refusal: no such home, a device not in the roster, + // a suspended device, an expired roster or a grant that is off. It does + // not say which. + CloseDenied = 4403 + // CloseReplaced: a newer session from the same device took over. + CloseReplaced = 4408 + // CloseChanged: the roster changed what this device may read. A phone + // reconnects at once to learn its new access. + CloseChanged = 4409 + // CloseBusy: the home has as many sessions open as the service allows. + // The relay uses the same number for the same thing. + CloseBusy = 4429 +) + +// Hz values for now and watch. +const ( + HzLooking = 1 + HzAway = 0 +) + +// Now is message 1: whether the phone is looking. +type Now struct { + Hz int `cbor:"hz"` +} + +// Suspend names the roster device an owner suspends or lifts a suspension +// from. +type Suspend struct { + DeviceID string `cbor:"deviceId"` +} + +// Snapshot is message 2: for each class the device may read, the newest +// envelope the service holds, the newest live that carried sources and the +// newest hb, in rising seq, as the box sent them. LastSeenMs and Online are +// the service's own word, unsigned, and never shown as a reading. +// +// Envelopes that do not fit in one Noise message follow message 2 as +// ordinary frames, so More counts them. +type Snapshot struct { + Envelopes []cbor.RawMessage `cbor:"envelopes"` + LastSeenMs int64 `cbor:"lastSeenMs"` + Online bool `cbor:"online"` + More int `cbor:"more"` +} + +// AppPrologue is the Noise prologue for one home's app session. +func AppPrologue(home string) []byte { + return append([]byte(AppPrologueTag), home...) +} + +// ValidHome reports whether s has a home id's shape: 26 characters of +// lowercase base32. +func ValidHome(s string) bool { + if len(s) != 26 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { + return false + } + } + return true +} + +// Unsigned wraps a body the phone or service says itself: an envelope with an +// empty sig. +func Unsigned(t string, body any) ([]byte, error) { + b, err := Marshal(body) + if err != nil { + return nil, err + } + return Envelope{T: t, B: b}.Encode() +} + +// SnapshotPayload is message 2's payload around envelopes already encoded. +// They are carried as they are: the service never re-encodes a box message. +func SnapshotPayload(envelopes [][]byte, lastSeenMs int64, online bool, more int) ([]byte, error) { + raw := make([]cbor.RawMessage, len(envelopes)) + for i, e := range envelopes { + if _, err := DecodeEnvelope(e); err != nil { + return nil, fmt.Errorf("cloudwire: snapshot entry %d: %w", i, err) + } + raw[i] = e + } + return Unsigned(MsgSnapshot, Snapshot{Envelopes: raw, LastSeenMs: lastSeenMs, Online: online, More: more}) +} + +// ParseSnapshot reads message 2. Each entry comes back as its exact bytes. +func ParseSnapshot(payload []byte) (Snapshot, error) { + env, err := DecodeEnvelope(payload) + if err != nil { + return Snapshot{}, err + } + if env.T != MsgSnapshot { + return Snapshot{}, fmt.Errorf("%w: message 2 is %q, not a snapshot", ErrEnvelope, env.T) + } + var s Snapshot + if err := Unmarshal(env.B, &s); err != nil { + return Snapshot{}, err + } + return s, nil +} diff --git a/go/protocol/cloudwire/app_vectors_test.go b/go/protocol/cloudwire/app_vectors_test.go new file mode 100644 index 000000000..d5749cfc8 --- /dev/null +++ b/go/protocol/cloudwire/app_vectors_test.go @@ -0,0 +1,254 @@ +package cloudwire + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// testdata/cloud-app-vectors.json freezes one app session byte for byte: +// fixed static and ephemeral keys on both ends, message 1 with now, message 2 +// with a snapshot of the signed vectors in cloud-vectors.json, and transport +// messages both ways. srcfl/ftw-cloud answers it as the service; the phone +// replays it as the initiator. + +const appVectorsPath = "testdata/cloud-app-vectors.json" + +type appVectorFile struct { + ServiceStatic string `json:"serviceStatic"` + ServicePublic string `json:"servicePublic"` + ServiceEphemeral string `json:"serviceEphemeral"` + PhoneStatic string `json:"phoneStatic"` + PhonePublic string `json:"phonePublic"` + PhoneEphemeral string `json:"phoneEphemeral"` + Home string `json:"home"` + Prologue string `json:"prologue"` + Msg1Payload string `json:"msg1Payload"` + Msg1 string `json:"msg1"` + Msg2Payload string `json:"msg2Payload"` + Msg2 string `json:"msg2"` + HandshakeHash string `json:"handshakeHash"` + Snapshot []string `json:"snapshot"` + Transport []appVectorFrame `json:"transport"` + Notes map[string]string `json:"notes"` +} + +type appVectorFrame struct { + From string `json:"from"` + Name string `json:"name"` + Frame string `json:"frame"` + Message string `json:"message"` +} + +func fixedX25519(t *testing.T, label string) appwire.KeyPair { + t.Helper() + sum := sha256.Sum256([]byte(label)) + k, err := appwire.KeyPairFromSecret(sum[:]) + if err != nil { + t.Fatal(err) + } + return k +} + +func secretHex(label string) string { + sum := sha256.Sum256([]byte(label)) + return hex.EncodeToString(sum[:]) +} + +func TestAppSessionVectors(t *testing.T) { + raw, err := os.ReadFile(vectorsPath) + if err != nil { + t.Fatal(err) + } + var signed vectorFile + if err := json.Unmarshal(raw, &signed); err != nil { + t.Fatal(err) + } + envelope := func(name string) []byte { + for _, m := range signed.Messages { + if m.Name == name { + b, _ := hex.DecodeString(m.Envelope) + return b + } + } + t.Fatalf("no signed vector %q", name) + return nil + } + frameOf := func(env []byte) []byte { + f, err := EncodeFrame(env) + if err != nil { + t.Fatal(err) + } + return f + } + + labels := map[string]string{ + "serviceStatic": "ftw.cloud app vector service static", + "serviceEphemeral": "ftw.cloud app vector service ephemeral", + "phoneStatic": "ftw.cloud app vector phone static", + "phoneEphemeral": "ftw.cloud app vector phone ephemeral", + } + serviceStatic := fixedX25519(t, labels["serviceStatic"]) + serviceEph := fixedX25519(t, labels["serviceEphemeral"]) + phoneStatic := fixedX25519(t, labels["phoneStatic"]) + phoneEph := fixedX25519(t, labels["phoneEphemeral"]) + home := signed.BoxKey.Home + prologue := AppPrologue(home) + + msg1Payload, err := Unsigned(MsgNow, Now{Hz: HzLooking}) + if err != nil { + t.Fatal(err) + } + // The snapshot: newest live with sources, newest live, lp, prices and hb, + // in rising seq. + names := []string{"live full", "live delta", "lp", "prices", "hb"} + var envs [][]byte + for _, n := range names { + envs = append(envs, envelope(n)) + } + msg2Payload, err := SnapshotPayload(envs, 1_791_820_869_000, true, 0) + if err != nil { + t.Fatal(err) + } + + init, err := appwire.NewInitiator(appwire.InitiatorOptions{ + StaticKey: phoneStatic, RemoteStatic: serviceStatic.Public(), Prologue: prologue, Ephemeral: &phoneEph, + }) + if err != nil { + t.Fatal(err) + } + resp, err := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: serviceStatic, Prologue: prologue, Ephemeral: &serviceEph}) + if err != nil { + t.Fatal(err) + } + msg1, err := init.WriteMessage(msg1Payload) + if err != nil { + t.Fatal(err) + } + got1, err := resp.ReadMessage(msg1) + if err != nil || !bytes.Equal(got1, msg1Payload) { + t.Fatalf("message 1: %v", err) + } + msg2, err := resp.WriteMessage(msg2Payload) + if err != nil { + t.Fatal(err) + } + got2, err := init.ReadMessage(msg2) + if err != nil || !bytes.Equal(got2, msg2Payload) { + t.Fatalf("message 2: %v", err) + } + snap, err := ParseSnapshot(got2) + if err != nil || len(snap.Envelopes) != len(envs) || !snap.Online { + t.Fatalf("snapshot: %+v %v", snap, err) + } + for i := range envs { + if !bytes.Equal(snap.Envelopes[i], envs[i]) { + t.Fatalf("snapshot entry %d is not the box's bytes", i) + } + } + is, _ := init.Split() + rs, _ := resp.Split() + phone, service := appwire.NewTransport(is), appwire.NewTransport(rs) + + out := appVectorFile{ + ServiceStatic: secretHex(labels["serviceStatic"]), ServicePublic: hex.EncodeToString(serviceStatic.Public()), + ServiceEphemeral: secretHex(labels["serviceEphemeral"]), + PhoneStatic: secretHex(labels["phoneStatic"]), PhonePublic: hex.EncodeToString(phoneStatic.Public()), + PhoneEphemeral: secretHex(labels["phoneEphemeral"]), + Home: home, Prologue: hex.EncodeToString(prologue), + Msg1Payload: hex.EncodeToString(msg1Payload), Msg1: hex.EncodeToString(msg1), + Msg2Payload: hex.EncodeToString(msg2Payload), Msg2: hex.EncodeToString(msg2), + HandshakeHash: hex.EncodeToString(rs.HandshakeHash), + Notes: map[string]string{ + "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.", + "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.", + "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0.", + }, + } + out.Snapshot = names + + addFrame := func(from, name string, frame []byte) { + var msg []byte + var err error + if from == "service" { + msg, err = service.Encrypt(frame) + if err == nil { + var back []byte + if back, err = phone.Decrypt(msg); err == nil && !bytes.Equal(back, frame) { + t.Fatalf("%s did not round-trip", name) + } + } + } else { + msg, err = phone.Encrypt(frame) + if err == nil { + var back []byte + if back, err = service.Decrypt(msg); err == nil && !bytes.Equal(back, frame) { + t.Fatalf("%s did not round-trip", name) + } + } + } + if err != nil { + t.Fatalf("%s: %v", name, err) + } + out.Transport = append(out.Transport, appVectorFrame{From: from, Name: name, Frame: hex.EncodeToString(frame), Message: hex.EncodeToString(msg)}) + } + addFrame("service", "hb", frameOf(envelope("hb"))) + addFrame("service", "lp", frameOf(envelope("lp"))) + away, _ := Unsigned(MsgWatch, Watch{Hz: HzAway}) + addFrame("phone", "watch away", frameOf(away)) + suspend, _ := Unsigned(MsgSuspend, Suspend{DeviceID: "Zf09xQ2a"}) + addFrame("phone", "suspend", frameOf(suspend)) + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(appVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(appVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the wire change is intended, "+ + "regenerate with -update and pair the change with srcfl/ftw-cloud and the phone", appVectorsPath) + } +} + +func TestAppPrologueAndHome(t *testing.T) { + if got := string(AppPrologue("fjzyr67buzpyncqmjliwgezvde")); got != "ftw.cloud.v1:fjzyr67buzpyncqmjliwgezvde" { + t.Fatalf("prologue %q", got) + } + for s, ok := range map[string]bool{ + "fjzyr67buzpyncqmjliwgezvde": true, + "fjzyr67buzpyncqmjliwgezvd": false, + "fjzyr67buzpyncqmjliwgezvdee": false, + "Fjzyr67buzpyncqmjliwgezvde": false, + "fjzyr67buzpyncqmjliwgezvd1": false, + "fjzyr67buzpyncqmjliwgezvd/": false, + } { + if ValidHome(s) != ok { + t.Fatalf("ValidHome(%q) = %v", s, !ok) + } + } +} + +func TestSnapshotRefusesWhatIsNotAnEnvelope(t *testing.T) { + if _, err := SnapshotPayload([][]byte{{0xa0}}, 0, false, 0); err == nil { + t.Fatal("a snapshot took an empty map as an envelope") + } + now, _ := Unsigned(MsgNow, Now{Hz: 1}) + if _, err := ParseSnapshot(now); err == nil { + t.Fatal("a now envelope read as a snapshot") + } +} diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go new file mode 100644 index 000000000..100786b20 --- /dev/null +++ b/go/protocol/cloudwire/messages.go @@ -0,0 +1,295 @@ +package cloudwire + +import "github.com/srcfl/ftw/go/protocol" + +// Message types. The service sends exactly the first five; anything else from +// it closes the uplink. +const ( + MsgChallenge = "challenge" + MsgHelloOK = "hello_ok" + MsgWatch = "watch" + MsgResync = "resync" + MsgPurged = "purged" +) + +// Message types the box sends. +const ( + MsgHello = "hello" + MsgLive = "live" + MsgLP = "lp" + MsgPrices = "prices" + MsgPlan = "plan" + MsgHealth = "health" + MsgEvent = "event" + MsgTiles = "tiles" + MsgHB = "hb" + MsgRoster = "roster" + MsgBye = "bye" +) + +// Downlink is the closed set of types the service may send the box. +var Downlink = []string{MsgChallenge, MsgHelloOK, MsgWatch, MsgResync, MsgPurged} + +// ClassScope is the scope a device needs to read each class the service +// forwards. hb maps to "" because every device in the roster gets it: it +// carries no readings, only how old they are. +var ClassScope = map[string]string{ + MsgLive: protocol.ScopeLiveRead, + MsgLP: protocol.ScopeLiveRead, + MsgPrices: protocol.ScopeLiveRead, + MsgPlan: protocol.ScopePlanRead, + MsgHealth: protocol.ScopeHealthRead, + MsgEvent: protocol.ScopeEventsRead, + MsgTiles: protocol.ScopeHistoryRead, + MsgHB: "", +} + +// ServiceOnly are box messages the service acts on and never forwards. +var ServiceOnly = []string{MsgHello, MsgRoster, MsgBye} + +// Clock values for hello.clock and hb.clock. +const ( + ClockNTP = "ntp" + ClockNone = "none" +) + +// Bye reasons. +const ( + ByeRevoked = "revoked" + ByeShutdown = "shutdown" + ByeUpdate = "update" +) + +// What a purge receipt or a resync names. +const ( + PurgedHome = "home" + PurgedStats = "stats" + + ResyncFull = "full" + ResyncTiles = "tiles" + ResyncRoster = "roster" +) + +// ControllerSourceful is the data controller unless a partner flow names +// another. No such flow exists yet. +const ControllerSourceful = "sourceful" + +// -------------------------------------------------------------------------- +// Service to box +// -------------------------------------------------------------------------- + +// Challenge is the service's first message, unsigned. +type Challenge struct { + V int `cbor:"v"` + Nonce []byte `cbor:"nonce"` +} + +// 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"` + // 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"` + // Have maps tile id to etag for the tiles the service holds. + Have map[string]string `cbor:"have"` + RosterSeq uint64 `cbor:"rosterSeq"` +} + +// Watch raises the period to one second for 60 seconds. +type Watch struct { + Hz int `cbor:"hz"` +} + +// Resync asks for full state again. The box honours one per five minutes. +type Resync struct { + What []string `cbor:"what"` + Tiles []string `cbor:"tiles,omitempty"` +} + +// Purged is the service's signed receipt that a deletion is done. +type Purged struct { + AtMs int64 `cbor:"atMs"` + What string `cbor:"what"` + GrantID []byte `cbor:"grantId"` + StatsID []byte `cbor:"statsId,omitempty"` +} + +// -------------------------------------------------------------------------- +// Box to service +// -------------------------------------------------------------------------- + +// Header is in every body the box sends. +type Header struct { + // Seq starts at 1 and rises by 1 per message, per boot, across + // reconnects. + Seq uint64 `cbor:"seq"` + // Boot is drawn at random at each Core start. + Boot []byte `cbor:"boot"` + // UptimeMs is the box's monotonic time since start, at send. + UptimeMs int64 `cbor:"uptimeMs"` + // WallMs is the box's wall time at send, present only while NTP has + // synced the box clock. + WallMs *int64 `cbor:"wallMs,omitempty"` +} + +// Hello proves the box holds its key and binds this TLS session to it. +type Hello struct { + Header + V int `cbor:"v"` + // PK is the box's public key, 65 bytes. The home id is a hash of it. + PK []byte `cbor:"pk"` + Nonce []byte `cbor:"nonce"` + EKM []byte `cbor:"ekm"` + BoxNonce []byte `cbor:"boxNonce"` + Build string `cbor:"build"` + Clock string `cbor:"clock"` + // Caps lists the classes this box sends. + Caps []string `cbor:"caps"` + RosterSeq uint64 `cbor:"rosterSeq"` + GrantID []byte `cbor:"grantId"` +} + +// Live is the site's flows and the freshness of every source behind them. +type Live struct { + Header + // Fields uses the app protocol's frozen ids 1–6 and 10, keyed and + // encoded as its delta does. + Fields map[string]int64 `cbor:"fields"` + // FieldSources and Sources travel only when the source table changed and + // in every full message. + FieldSources map[string][]string `cbor:"fieldSources,omitempty"` + Sources map[string]Source `cbor:"sources,omitempty"` + // SrcRev rises by 1, per boot, each time the source table changes. + SrcRev uint64 `cbor:"srcRev"` + // Ages is, for each live or lagging source, the age of its newest + // reading at send, in ms of box uptime. + Ages map[string]int64 `cbor:"ages"` + DispatchBlockedBy []string `cbor:"dispatchBlockedBy"` +} + +// Source is one source's freshness as the box grades it. +type Source struct { + Kind string `cbor:"kind"` + State protocol.SourceState `cbor:"state"` + LastOkMs int64 `cbor:"lastOkMs"` + StaleAfterMs int64 `cbor:"staleAfterMs"` +} + +// LP is loadpoint and control feedback state. +type LP struct { + Header + ControlRev uint64 `cbor:"controlRev"` + Loadpoints []Loadpoint `cbor:"loadpoints"` + Feedback []Feedback `cbor:"feedback"` +} + +// Loadpoint is one charger as the household sees it. Capabilities such as +// maximum power or phases are not sent. +type Loadpoint struct { + ID string `cbor:"id"` + Src string `cbor:"src"` + Plugged bool `cbor:"plugged"` + SocPermille *int64 `cbor:"socPermille"` + SocSource string `cbor:"socSource"` + SocAgeS *int64 `cbor:"socAgeS"` + TargetPermille *int64 `cbor:"targetPermille"` + ReadyByMs *int64 `cbor:"readyByMs"` + Recurring bool `cbor:"recurring"` + PowerW int64 `cbor:"powerW"` + SessionWh int64 `cbor:"sessionWh"` + Session string `cbor:"session"` + Manual *Manual `cbor:"manual"` + CommandedW *int64 `cbor:"commandedW"` + CommandedReason string `cbor:"commandedReason"` + SurplusOnly bool `cbor:"surplusOnly"` + GridDeferred bool `cbor:"gridDeferred"` + PlanPending bool `cbor:"planPending"` + Boost bool `cbor:"boost"` + NextStartMs *int64 `cbor:"nextStartMs"` + NextEndMs *int64 `cbor:"nextEndMs"` + NextWh *int64 `cbor:"nextWh"` +} + +// Manual is a manual charging hold. +type Manual struct { + State string `cbor:"state"` + SinceAgeMs int64 `cbor:"sinceAgeMs"` + RequestedW *int64 `cbor:"requestedW"` + CommandedW *int64 `cbor:"commandedW"` + Reason string `cbor:"reason"` +} + +// Feedback is Core's verdict on whether one device function follows. +type Feedback struct { + Kind string `cbor:"kind"` + Src string `cbor:"src"` + Status string `cbor:"status"` + Reason string `cbor:"reason"` + Severity string `cbor:"severity"` + Evidence string `cbor:"evidence"` + Fresh bool `cbor:"fresh"` + ActualW *int64 `cbor:"actualW"` + AgeMs *int64 `cbor:"ageMs"` +} + +// Prices is the price window, in columns. +type Prices struct { + Header + Zone string `cbor:"zone"` + Currency string `cbor:"currency"` + T0Ms int64 `cbor:"t0Ms"` + SlotMs int64 `cbor:"slotMs"` + // SpotMinor and TotalMinor are minor units per kWh, null where a slot + // has no price. TotalMinor includes tariff and VAT. + SpotMinor []*int64 `cbor:"spotMinor"` + TotalMinor []*int64 `cbor:"totalMinor"` +} + +// HB says the box is there and how old its readings are. It never vouches +// for a live or lp the box has not sent: it names the newest of each. +type HB struct { + Header + Clock string `cbor:"clock"` + SrcRev uint64 `cbor:"srcRev"` + LiveSeq uint64 `cbor:"liveSeq"` + LpSeq uint64 `cbor:"lpSeq"` + Ages map[string]int64 `cbor:"ages"` +} + +// Roster is every device paired with the box and what the owner granted. +// The service learns devices from nothing else. +type Roster struct { + Header + RosterSeq uint64 `cbor:"rosterSeq"` + TTLS int64 `cbor:"ttlS"` + Devices []Device `cbor:"devices"` + Grant Grant `cbor:"grant"` +} + +// Device is one paired phone. +type Device struct { + ID string `cbor:"id"` + // Key is the device's X25519 Noise static key. + Key []byte `cbor:"key"` + Role string `cbor:"role"` + // Scopes are what the box derives from the role. The service holds no + // role table. + Scopes []string `cbor:"scopes"` + Epoch uint64 `cbor:"epoch"` +} + +// Grant is what the owner turned on. +type Grant struct { + ID []byte `cbor:"id"` + Scopes []string `cbor:"scopes"` + Purposes []string `cbor:"purposes"` + Controller string `cbor:"controller"` +} + +// Bye ends the uplink. Purge asks the service to delete the home. +type Bye struct { + Header + Why string `cbor:"why"` + Purge bool `cbor:"purge"` +} diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go new file mode 100644 index 000000000..33137865f --- /dev/null +++ b/go/protocol/cloudwire/push.go @@ -0,0 +1,251 @@ +package cloudwire + +import ( + "bytes" + "encoding/json" + + "github.com/srcfl/ftw/go/protocol" +) + +// Events and push: what the box tells phones that are not looking, how a +// phone tells the service where Apple can reach it, and what the service +// hands Apple. docs/protocol-cloud.md, "Push", is the normative text. +// +// The box signs every event. The service wraps the envelope, unchanged, in an +// APNs alert; the phone's notification extension checks the signature with +// the box key it learned over Noise, writes its own text from the kind, and +// picks the buttons from the verified kind alone. The service picks nothing: +// not the words, not the buttons, not the priority. box.unreachable is the +// one alert the service writes itself, because a box that cannot reach the +// service cannot sign one; it carries no arguments and no buttons. + +// Message types a phone sends the service over its app session. +const ( + MsgPushRegister = "push.register" + MsgPushUnregister = "push.unregister" +) + +// APNs environments. A development build of the app gets sandbox tokens. +const ( + PushSandbox = "sandbox" + PushProduction = "production" +) + +// PushKindAlert is the only kind of registration until widgets and Live +// Activities get their own pushes. +const PushKindAlert = "alert" + +// APNsTokenMaxBytes bounds a device token. Apple's are 32 bytes today and +// documented as variable length. +const APNsTokenMaxBytes = 100 + +// Event severities, from the box's notification priority. +const ( + SeverityInfo = "info" + SeverityWarning = "warning" + SeverityAlarm = "alarm" +) + +// Event is one notification the box raised, for devices with +// ftw.events.read. Args are the catalogue's placeholders except names: an +// event about a device names it by Src, and the phone looks the name up in +// the table it holds from the box. +type Event struct { + Header + // EventID is unique per event. + EventID string `cbor:"eventId"` + // Ts is the box's wall time when the event happened, present only + // while NTP has synced the box clock. It can be earlier than the + // header's wallMs when the event waited for the uplink. + Ts *int64 `cbor:"ts,omitempty"` + Kind string `cbor:"kind"` + Severity string `cbor:"severity"` + Args map[string]string `cbor:"args"` + // Src is the opaque source id of the device the event concerns. + Src string `cbor:"src,omitempty"` + // LP is the opaque loadpoint id of the loadpoint it concerns. + LP string `cbor:"lp,omitempty"` +} + +// PushRegister tells the service a device's APNs token for one kind of push. +// The service binds it to the device's roster entry and forgets it when the +// roster drops the device, its epoch changes, or Apple says the token is +// gone. +type PushRegister struct { + APNsToken []byte `cbor:"apnsToken"` + Environment string `cbor:"environment"` + Kind string `cbor:"kind"` + // ActivityID names a Live Activity. Unused until its push exists. + ActivityID string `cbor:"activityId,omitempty"` +} + +// PushUnregister forgets one token. +type PushUnregister struct { + APNsToken []byte `cbor:"apnsToken"` +} + +// AlertMaxAgeMs is how old an event may be and still get its text and +// buttons on the phone. An older one, or one the phone cannot anchor in +// time, shows the generic line with no buttons. +const AlertMaxAgeMs = 10 * 60 * 1000 + +// UnreachableAfterMs is how long the service waits without the box's socket +// before it sends box.unreachable. A bye for an update holds it back until +// the box returns or UpdateGraceMs has passed. +const ( + UnreachableAfterMs = 600 * 1000 + UpdateGraceMs = 60 * 60 * 1000 +) + +// Alert is the "ftw" member of an APNs alert payload. Event is the box's +// envelope exactly as it arrived; Kind is set only on the alert the service +// writes itself, box.unreachable, and a phone accepts no other kind there. +type Alert struct { + V int `json:"v"` + Home string `json:"home"` + Event []byte `json:"event,omitempty"` + Kind string `json:"kind,omitempty"` +} + +// AlertKey is the payload key Alert sits under, beside Apple's "aps". +const AlertKey = "ftw" + +// Localisation keys of the line Apple shows if the extension does not run. +// The app's own strings hold the words, so the service writes none. +const ( + AlertTitleLocKey = "push.title" + AlertBodyLocKey = "push.generic" +) + +// AlertPayload is the whole APNs payload for one alert: Apple's "aps" with +// the generic line and mutable-content, so the extension runs, and Alert +// under AlertKey. Every alert gets the same aps, whatever its kind. +func AlertPayload(a Alert) ([]byte, error) { + type apsAlert struct { + TitleLocKey string `json:"title-loc-key"` + LocKey string `json:"loc-key"` + } + type aps struct { + Alert apsAlert `json:"alert"` + MutableContent int `json:"mutable-content"` + Sound string `json:"sound"` + } + a.V = Version + return json.Marshal(struct { + APS aps `json:"aps"` + FTW Alert `json:"ftw"` + }{aps{apsAlert{AlertTitleLocKey, AlertBodyLocKey}, 1, "default"}, a}) +} + +// What a phone makes of an alert. +const ( + // AlertEvent: a box-signed event, anchored and fresh, of a kind the + // catalogue has. The phone writes its text and offers its buttons. + AlertEvent = "event" + // AlertUnreachable: the service's box.unreachable. Its own text, no + // buttons. + AlertUnreachable = "unreachable" + // AlertGeneric: anything else. The generic line, no buttons. + AlertGeneric = "generic" +) + +// AlertView is what an alert says once checked. +type AlertView struct { + Verdict string + Home string + // The rest is set only for AlertEvent. + Kind string + Args map[string]string + Src string + LP string + Boot []byte + Seq uint64 + // AtMs is when the event happened by the phone's reading of the box + // clock, less rule U's drift margin when rule U anchors it: never later + // than it arrived. + AtMs int64 +} + +// ClockLeadMs is how far ahead of the phone a box's wall clock may run +// before the phone stops trusting it (rule N). +const ClockLeadMs = 2000 + +// Offset is rule U's anchor for one home, as the phone keeps it for its +// notification extension: phone time minus box uptime, the smallest learned +// over Noise for Boot, and when it was last learned. +type Offset struct { + Boot []byte + OffsetMs int64 + LearnedAtMs int64 +} + +// ReadAlert is the reference for what a phone's notification extension does +// with an alert payload. boxKey returns the key the phone learned over Noise +// for a home, nil when it has none; offset returns rule U's anchor for a +// home, nil when it has none; nowMs is the phone's clock at arrival. Whether +// a (boot, seq) was seen before is the caller's state, not this function's. +func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home string) *Offset, nowMs int64) AlertView { + var p struct { + FTW *Alert `json:"ftw"` + } + if json.Unmarshal(payload, &p) != nil || p.FTW == nil || p.FTW.V != Version { + return AlertView{Verdict: AlertGeneric} + } + a := *p.FTW + view := AlertView{Verdict: AlertGeneric, Home: a.Home} + if len(a.Event) == 0 { + if a.Kind == protocol.PushBoxUnreachable { + view.Verdict = AlertUnreachable + } + return view + } + if a.Kind != "" { + return view + } + env, err := DecodeEnvelope(a.Event) + if err != nil || env.T != MsgEvent { + return view + } + pk := boxKey(a.Home) + if pk == nil { + return view + } + pub, err := ParsePublicKey(pk) + if err != nil || Verify(pub, a.Home, env) != nil { + return view + } + var ev Event + if Unmarshal(env.B, &ev) != nil { + return view + } + at, ok := alertSendTime(ev, offset(a.Home), nowMs) + if !ok { + return view + } + if ev.Ts != nil { + at = min(at, *ev.Ts) + } + _, known := protocol.PushSentences[ev.Kind] + if !known || ev.Kind == protocol.PushBoxUnreachable || nowMs-at > AlertMaxAgeMs { + return view + } + return AlertView{Verdict: AlertEvent, Home: a.Home, Kind: ev.Kind, Args: ev.Args, Src: ev.Src, LP: ev.LP, + Boot: ev.Boot, Seq: ev.Seq, AtMs: at} +} + +// alertSendTime anchors an event as View anchors a message: the earlier of +// rule N and rule U, and when rule U gives it, earlier by the drift margin so +// the margin counts against the age. A box clock more than ClockLeadMs ahead +// gives no rule N. ok is false when neither rule applies. +func alertSendTime(ev Event, off *Offset, nowMs int64) (at int64, ok bool) { + if ev.WallMs != nil && *ev.WallMs <= nowMs+ClockLeadMs { + at, ok = min(*ev.WallMs, nowMs), true + } + if off == nil || !bytes.Equal(off.Boot, ev.Boot) || nowMs-off.LearnedAtMs > RuleULifetimeMs { + return at, ok + } + if u := min(off.OffsetMs+ev.UptimeMs, nowMs); !ok || u <= at { + at, ok = u-max(0, nowMs-off.LearnedAtMs)/ruleUDriftDivisor, true + } + return at, ok +} diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go new file mode 100644 index 000000000..0fd94d6b3 --- /dev/null +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -0,0 +1,332 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "slices" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// Push vectors: APNs payloads as the service makes them, the phone's clock +// when each arrives, and what the phone must make of it. ReadAlert is the +// reference; the phone's notification extension replays the same file. +// +// go test ./cloudwire -run TestPushVectors -update +// +// Signatures are stored, as in cloud-vectors.json, so an update that only +// adds a case leaves the others' bytes alone. + +const pushVectorsPath = "testdata/push-vectors.json" + +type pushVectorFile struct { + // Keys are the box keys the phone holds, by home. + Keys []pushVectorKey `json:"keys"` + Cases []pushVectorCase `json:"cases"` +} + +type pushVectorKey struct { + Home string `json:"home"` + PK string `json:"pk"` +} + +type pushVectorCase struct { + Name string `json:"name"` + // Payload is the APNs payload, exactly as the service sends it. + Payload string `json:"payload"` + // Sig is the event's stored signature, hex, empty when there is no + // event or it is unsigned. + Sig string `json:"sig"` + // Offset is rule U's anchor the phone holds, absent when it holds none. + Offset *pushVectorOffset `json:"offset,omitempty"` + NowMs int64 `json:"nowMs"` + Want pushVectorOut `json:"want"` +} + +type pushVectorOffset struct { + Home string `json:"home"` + Boot string `json:"boot"` + OffsetMs int64 `json:"offsetMs"` + LearnedAtMs int64 `json:"learnedAtMs"` +} + +type pushVectorOut struct { + Verdict string `json:"verdict"` + Home string `json:"home"` + Kind string `json:"kind,omitempty"` + Args map[string]string `json:"args,omitempty"` + Src string `json:"src,omitempty"` + LP string `json:"lp,omitempty"` + Boot string `json:"boot,omitempty"` + Seq uint64 `json:"seq,omitempty"` + AtMs int64 `json:"atMs,omitempty"` +} + +func TestPushVectors(t *testing.T) { + box := testKey(t, "ftw.cloud vector box") + other := testKey(t, "ftw.cloud vector box two") + home, boxPK := homeOf(t, box) + otherHome, otherPK := homeOf(t, other) + grantKey := bytes.Repeat([]byte{0x77}, GrantKeyBytes) + charger := SourceID(grantKey, "easee") + garage := LoadpointID(grantKey, "garage") + boot := bytes.Repeat([]byte{0xb0}, BootBytes) + wall := int64(1_791_820_800_000) + at := func(uptime int64) *int64 { return i64(wall + uptime - 60_000) } + + var stored pushVectorFile + if raw, err := os.ReadFile(pushVectorsPath); err == nil { + if err := json.Unmarshal(raw, &stored); err != nil { + t.Fatal(err) + } + } else if !*update { + t.Fatalf("%v (run with -update to create it)", err) + } + storedSig := func(i int, name string) []byte { + if i >= len(stored.Cases) || stored.Cases[i].Name != name { + if *update { + return nil + } + t.Fatalf("push vector %d is not %q in %s; regenerate with -update", i, name, pushVectorsPath) + } + sig, _ := hex.DecodeString(stored.Cases[i].Sig) + return sig + } + + connected := Event{ + Header: Header{Seq: 9, Boot: boot, UptimeMs: 71_000, WallMs: at(71_000)}, + EventID: "Q2ZrMjR0dzVh", Ts: i64(*at(71_000) - 400), Kind: protocol.PushChargingConnected, + Severity: SeverityInfo, Args: map[string]string{}, Src: charger, LP: garage, + } + interrupted := Event{ + Header: Header{Seq: 10, Boot: boot, UptimeMs: 72_000, WallMs: at(72_000)}, + EventID: "aW50cnB0ZDAx", Ts: i64(*at(72_000) - 300), Kind: protocol.PushChargingInterrupted, + Severity: SeverityWarning, Args: map[string]string{}, Src: charger, LP: garage, + } + noClock := connected + noClock.WallMs, noClock.Ts, noClock.Seq = nil, nil, 12 + ahead := connected + ahead.WallMs, ahead.Ts, ahead.Seq = at(80_000), nil, 13 + unknown := connected + unknown.Kind, unknown.Seq = "charging.someday", 14 + goal := Event{ + Header: Header{Seq: 15, Boot: boot, UptimeMs: 74_000, WallMs: at(74_000)}, + EventID: "Z29hbHJpc2sx", Ts: i64(*at(74_000) - 100), Kind: protocol.PushChargingGoalAtRisk, + Severity: SeverityWarning, Args: map[string]string{"target": "80", "by": "07:00"}, LP: garage, + } + signedAsOther := connected + signedAsOther.Seq = 16 + + // Rule U: the phone learned phone time minus box uptime an hour before + // the event, so the anchor carries 0.01 % of that hour as margin. + learned := *at(71_000) - 60*60*1000 + ruleU := &Offset{Boot: boot, OffsetMs: wall - 60_000, LearnedAtMs: learned} + otherBoot := &Offset{Boot: bytes.Repeat([]byte{0xb1}, BootBytes), OffsetMs: ruleU.OffsetMs, LearnedAtMs: learned} + dayOld := &Offset{Boot: boot, OffsetMs: ruleU.OffsetMs, LearnedAtMs: fresh0(connected) - RuleULifetimeMs - 1} + byRuleU := func(e Event, nowMs int64) pushVectorOut { + return pushVectorOut{Verdict: AlertEvent, Home: home, Kind: e.Kind, Args: e.Args, Src: e.Src, LP: e.LP, + Boot: hex.EncodeToString(e.Boot), Seq: e.Seq, + AtMs: ruleU.OffsetMs + e.UptimeMs - (nowMs-learned)/ruleUDriftDivisor} + } + + type spec struct { + name string + key *ecdsa.PrivateKey // signer; nil for no event + signFor string // home the box signs for + event *Event + alert Alert // Home and Kind; Event is filled in + mutate func(env []byte) []byte + offset *Offset // rule U's anchor the phone holds for home + nowMs int64 + want pushVectorOut + } + fresh := fresh0 + event := func(e Event) pushVectorOut { + at := min(*e.WallMs, fresh(e)) + if e.Ts != nil { + at = min(at, *e.Ts) + } + return pushVectorOut{Verdict: AlertEvent, Home: home, Kind: e.Kind, Args: e.Args, Src: e.Src, LP: e.LP, + Boot: hex.EncodeToString(e.Boot), Seq: e.Seq, AtMs: at} + } + generic := pushVectorOut{Verdict: AlertGeneric, Home: home} + specs := []spec{ + {name: "connected, fresh", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: fresh(connected), want: event(connected)}, + {name: "interrupted, fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: fresh(interrupted), want: event(interrupted)}, + {name: "goal at risk, fresh", key: box, signFor: home, event: &goal, alert: Alert{Home: home}, + nowMs: fresh(goal), want: event(goal)}, + {name: "one byte of the body changed", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + mutate: func(env []byte) []byte { + out := bytes.Clone(env) + i := bytes.Index(out, []byte("charging.connected")) + out[i+len("charging.")] ^= 0x01 + return out + }, + nowMs: fresh(connected), want: generic}, + {name: "named for another home the phone holds", key: box, signFor: home, event: &connected, + alert: Alert{Home: otherHome}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: otherHome}}, + {name: "signed by another box for this home", key: other, signFor: home, event: &signedAsOther, + alert: Alert{Home: home}, nowMs: fresh(signedAsOther), want: generic}, + {name: "two hours old", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: *connected.WallMs + 2*60*60*1000, want: generic}, + {name: "ten minutes old is still fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: *interrupted.Ts + AlertMaxAgeMs, + want: func() pushVectorOut { + o := event(interrupted) + o.AtMs = *interrupted.Ts + return o + }()}, + {name: "box clock not synced", key: box, signFor: home, event: &noClock, alert: Alert{Home: home}, + nowMs: fresh(connected), want: generic}, + {name: "box clock ahead of the phone", key: box, signFor: home, event: &ahead, alert: Alert{Home: home}, + nowMs: *ahead.WallMs - ClockLeadMs - 1, want: generic}, + {name: "a kind the phone does not know", key: box, signFor: home, event: &unknown, alert: Alert{Home: home}, + nowMs: fresh(unknown), want: generic}, + {name: "unreachable, from the service", alert: Alert{Home: home, Kind: protocol.PushBoxUnreachable}, + nowMs: wall, want: pushVectorOut{Verdict: AlertUnreachable, Home: home}}, + {name: "another kind from the service", alert: Alert{Home: home, Kind: protocol.PushChargingConnected}, + nowMs: wall, want: generic}, + {name: "a home the phone does not hold", key: box, signFor: home, event: &connected, + alert: Alert{Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + {name: "box clock not synced, anchored by rule U", key: box, signFor: home, event: &noClock, + alert: Alert{Home: home}, offset: ruleU, nowMs: fresh(connected), want: byRuleU(noClock, fresh(connected))}, + {name: "box clock ahead, anchored by rule U", key: box, signFor: home, event: &ahead, + alert: Alert{Home: home}, offset: ruleU, nowMs: fresh(connected), want: byRuleU(ahead, fresh(connected))}, + {name: "rule U from another boot", key: box, signFor: home, event: &noClock, + alert: Alert{Home: home}, offset: otherBoot, nowMs: fresh(connected), want: generic}, + {name: "rule U learned over a day ago", key: box, signFor: home, event: &noClock, + alert: Alert{Home: home}, offset: dayOld, nowMs: fresh(connected), want: generic}, + } + + keys := map[string][]byte{home: boxPK, otherHome: otherPK} + out := pushVectorFile{Keys: []pushVectorKey{ + {Home: home, PK: hex.EncodeToString(boxPK)}, + {Home: otherHome, PK: hex.EncodeToString(otherPK)}, + }} + for i, s := range specs { + a := s.alert + var sig []byte + if s.event != nil { + body, err := Marshal(*s.event) + if err != nil { + t.Fatal(err) + } + sig = storedSig(i, s.name) + if *update && Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}) != nil { + if sig, err = Sign(rand.Reader, s.key, s.signFor, MsgEvent, body); err != nil { + t.Fatal(err) + } + } + if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}); err != nil { + t.Fatalf("%s: stored signature does not verify: %v", s.name, err) + } + env, err := Envelope{T: MsgEvent, B: body, Sig: sig}.Encode() + if err != nil { + t.Fatal(err) + } + if s.mutate != nil { + env = s.mutate(env) + } + a.Event = env + } + payload, err := AlertPayload(a) + if err != nil { + t.Fatal(err) + } + if len(payload) > 4096 { + t.Fatalf("%s: %d bytes is over APNs' 4 KB", s.name, len(payload)) + } + + offset := func(h string) *Offset { + if h != home { + return nil + } + return s.offset + } + view := ReadAlert(payload, func(h string) []byte { return keys[h] }, offset, s.nowMs) + got := pushVectorOut{Verdict: view.Verdict, Home: view.Home, Kind: view.Kind, Args: view.Args, + Src: view.Src, LP: view.LP, Seq: view.Seq, AtMs: view.AtMs} + if view.Boot != nil { + got.Boot = hex.EncodeToString(view.Boot) + } + if !equalOut(got, s.want) { + t.Errorf("%s: got %+v, want %+v", s.name, got, s.want) + } + c := pushVectorCase{Name: s.name, Payload: string(payload), Sig: hex.EncodeToString(sig), NowMs: s.nowMs, Want: s.want} + if s.offset != nil { + c.Offset = &pushVectorOffset{Home: home, Boot: hex.EncodeToString(s.offset.Boot), + OffsetMs: s.offset.OffsetMs, LearnedAtMs: s.offset.LearnedAtMs} + } + out.Cases = append(out.Cases, c) + } + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(pushVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, _ := os.ReadFile(pushVectorsPath) + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the change is intended, regenerate with -update "+ + "and pair it with srcfl/ftw-cloud and the app", pushVectorsPath) + } +} + +// fresh0 is a phone clock 1.2 s after the event's wallMs. +func fresh0(e Event) int64 { return *e.WallMs + 1_200 } + +func equalOut(a, b pushVectorOut) bool { + if len(a.Args) == 0 && len(b.Args) == 0 { + a.Args, b.Args = nil, nil + } + ka, kb := make([]string, 0, len(a.Args)), make([]string, 0, len(b.Args)) + for k, v := range a.Args { + ka = append(ka, k+"="+v) + } + for k, v := range b.Args { + kb = append(kb, k+"="+v) + } + slices.Sort(ka) + slices.Sort(kb) + a.Args, b.Args = nil, nil + return a.Verdict == b.Verdict && a.Home == b.Home && a.Kind == b.Kind && a.Src == b.Src && a.LP == b.LP && + a.Boot == b.Boot && a.Seq == b.Seq && a.AtMs == b.AtMs && slices.Equal(ka, kb) +} + +// The service's alert has the same aps whatever it carries, so Apple learns +// a kind only from the signed event, never from priority or wording. +func TestEveryAlertHasTheSameAps(t *testing.T) { + a, err := AlertPayload(Alert{Home: "h", Event: []byte{1, 2, 3}}) + if err != nil { + t.Fatal(err) + } + b, err := AlertPayload(Alert{Home: "h", Kind: protocol.PushBoxUnreachable}) + if err != nil { + t.Fatal(err) + } + var pa, pb map[string]json.RawMessage + if json.Unmarshal(a, &pa) != nil || json.Unmarshal(b, &pb) != nil { + t.Fatal("not JSON") + } + if !bytes.Equal(pa["aps"], pb["aps"]) { + t.Fatalf("aps differs: %s vs %s", pa["aps"], pb["aps"]) + } + if !bytes.Contains(pa["aps"], []byte(`"mutable-content":1`)) { + t.Fatalf("no mutable-content: %s", pa["aps"]) + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-app-vectors.json b/go/protocol/cloudwire/testdata/cloud-app-vectors.json new file mode 100644 index 000000000..b49c1b364 --- /dev/null +++ b/go/protocol/cloudwire/testdata/cloud-app-vectors.json @@ -0,0 +1,53 @@ +{ + "serviceStatic": "02d86370739892ce589fb0dac0416128cb716a667f6b3baf2979ed03a39be145", + "servicePublic": "4bcf8ca8663797d826934ad341de6e223114105a1f513b78da30ef3f32cd8557", + "serviceEphemeral": "f4e90c05a93a8b3e2e3741a3c3df44681e9829b95332b4a406e3325d3cce6bd3", + "phoneStatic": "ea39659c38ef7cf936ca64fc18edccb9889785bf85e202593bd192680b425f37", + "phonePublic": "f7d081a5e8884ade9cf55aeb6cf767afabf70d3c0b195a013651055908128f08", + "phoneEphemeral": "df36c6700bc874c62773997c6e95704050bac93a3b649e0883a7f5791a358ebc", + "home": "fjzyr67buzpyncqmjliwgezvde", + "prologue": "6674772e636c6f75642e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465", + "msg1Payload": "a3616245a162687a016174636e6f776373696740", + "msg1": "38869cb4f47eb17ca6a251b657a79943bca4b3a22813caac1578296e837a0273dd35e67fe141738f375ec80128d8e3c18dae31dcc5fe42a0273b9cbe300164e9e903d823a7c1bab23d562d1bf2879379e0a974150000f0b93b6e0c0fe2d0f58b1d814d5427606b9ab58d548696d231eab9369a86", + "msg2Payload": "a36162590771a4646d6f726500666f6e6c696e65f569656e76656c6f70657385a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd86a6c6173745365656e4d731b000001a130d8ad88617468736e617073686f746373696740", + "msg2": "a0719d669e99f8ba82522bf6f2e6614d99bccb5616ae3e2af7e4635267531c148d96093e3a658c9843d6ba1321e2fc10ea6f9dc9a4ff98256ab2edf4dd8f9d8d29fc50ed8ce63ab3d409e5740f425a84b8331523b6aebb7bc37080cf41080144beee9311a8089e341d3fb4886247e9114f2990333e5e29e7f3d91f338d0fdb89bb0ff5424c533b8a6cb800dd263eb08c5995540efdad9b729ff8d63e1c15df4cfe7079a8354fee4141d7ac43a0d44cc6b820444920c760983413caefbd1a31037003228381c291226f4cead6db97fe6023e8c0c1a503e8467c6726d1a045b456971a2ba19df457439481c218b95a5a94628ca3a813ee741501e475b6039f5614ab8a5e71ffe8377d3e06fc253f106a4133709fbfdccb6c6ca754ba375e12c924cb5c52351cf78ee6645f007a439ef55774f69b8e458d93cec2a1b369759ba67772b8c4f45737a82aef39f434c956b34a8a1978e261999904b80d39cd619cda27cbc983b45385b8c2569677f76d888439e7dae4df77a4e1d05ee11526f1a6276de5a53266d13fcc620e77594115f90d1b9e802ee942c5705aecb8bcd7ca2d410474dcd89d368f5411645a69bf364e67abc04e7638028117f577c016d008ba9eea84215a921b67dab3e16ec3c11b716574c332a5047e824b0232b97a1bdd210056b4a936cf70221aef0e40fd7ae1341abf7ab9b09a4424203c2f821ca2e9360b2f2130b28fa7d28cf7ac89d7013dd086f4f7aa88f3667cd25d279b0697224504f05bb5dd15098759a86cf046521fddf4010323fa2bfd88bd58b3e6babaf32d77a607b220aa613b4a0dacc1141556d8aae189d218f9f402e57b3ededb3f8b46d5d18267566fa0c309ef988333c416218579247abd47634d6631a4c6e81ecb0c5946370b1686b08509ff35aee5a6b4aed4ff56bb1c4fd7742377f8da760867a8c8e482abd062326085f62b2343957c6826e15d8a5025a584ca3b1eb31d2f48c5c0234ec9f63b68d589d4b7ad4727bb5141e2e2d3a3a7cfbe15eae129a6cdaaab7df28eb05d8ce4cf5d65761c4f5132dc717cae2c7045860224743780aca762c3e77fec18db5c7bb73bfd66f90df934afaf0b6f4f7d6102bae17b083e47a4aa63db614aa0441c0fd45193d7bc02194d9d93241f6ce83feda0f6f16d79cd13a3aadfb1fad0e8db8a40a1434f85634e923759363a71b37579f28c3795fba377a59c719937b56d44aed8bad1fd3a7be959b80ae0e12fcf1d8f2a016a64663a9f197eb35c1d1948dea05b88ff7cf25aa794a98e01383ab4dd5e4de1aceaa0dae29b90c776b4b8c97ef95201f1b466b5a19841a427a4b74123341f763770661c4fb8b01837a785c86f065d258801fac607d886d0775b32c8856e47f05be7c0f672c886c218371104c237c95c9e149bf2a5ab68882737919351429a0982da7304bb3d2643a11f12105ac393a5167fdf30a163b175c3bfe2aea7cb89cf13a60fac27fc447e98c530726d5ed4ec0d2355488a5c0ee8b265c95a3ec9381a2e1f156740bfe1346143e227d4ed6a78adbd1447f9e2690fb878f3c7bdbaefa366edbdb4c2b99c4735847beaaeada651140497d4dc82133797dd1f3333579b93aeef3a485dc3a658a20d22584b54bd5efac1d1542e37212465b51c0ccebb55d81f68729e77db8ea5eb001abd46fd4ea13d2e67114be82976a748368948bd9f3bb44784830eb8723d37e1bb12785fdcd2b02ce60d7ad655000d5abbdbbae9db3f215308e56f378b736cd101236221dbfb5c00a15c97161b53c144de4fdfac0f1e349536a37065259b86630b2f7351568878832c2a8c8c5669f70d8430b6a22c80c898da82ccd6e0b0cb902eda4b5294981a3d037cdf4da8ea31e511fdd142546e4c01e1c0154d1c098f5b36dee0d4732ad19032e9416eed93addea024bc422e5dad3783af218534b255d5dbde9202d0931230b7c6678c66165e05f0e98099b9ebe45ae7d83a1e0f4bc15845cbe4012a807632819d8d5762286c2868056b0670c26b7faa765047b42f89d7fe6e012a262c354a485419b0fb236a4332130707728e25b323f76dfcd6d89fcff4ffa274f988bcd09207f0e24f12f69a83f448b814c5eb0d7d87f993593f27ea3189a61f111d89c9d9e6668abd495253340741b31b34234e3d89106cda2a4dd06e3bc96ad3ed9dede19f1d387b1c7e094733b9e85571d9393fa23efdef0e64af8a53d4ab314b0c0f2a0f15d16bb2db9f8e57816b10d0e8a3d3406a08a68d24d0fdb1589ef951c7e98f77f9ad558da0a9a927ad9d85779bec3376f51188d12accf7fe20a019861a3c8bf0cf5135a093a7ab2cb7b2db0ca548b5e677a3bf8e0add303e3567d71826c4fd38a65a95aeeb8c166846090ee9a173fe0a753041f9df779f693ae686b75fe9854f76d0405bd2e4e5d98f3d0ee74747229c73f2964e72861a4b8df60fe638e8c450b80de929b1d612afc9c8fe537ffad8eed8f176c4d53cd1149e9566da2ff321905ea640938df4f972cb887c66c1a7828da36a014b09f111c1464ffe85f74d6e50a8428c4384579d0180ecd00209012545755e9b1c584817446d5a930e775460bb6edb1f19f6185e63685debd622b293657d3a560ef47c53aff422488f8cb09013290f08433e0f8284fa0e5105f84a2aa2882e75b7b846f19bf6dc89894a3fd773f1804f8cfa5e8a134ba15003472bacb2e5ccae391e8f08ae52a05ee1d4c2f850f94f096087bb4d26c5a6dacde4189ce9b76aeb738021092857b36c84037356d7f3431ac041c41072c57377ea", + "handshakeHash": "572f4062a19964d6417d27e070137f6e012ac705c073841669685158a36a0de7", + "snapshot": [ + "live full", + "live delta", + "lp", + "prices", + "hb" + ], + "transport": [ + { + "from": "service", + "name": "hb", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "message": "00000000000000009bc932e703b7e50790aabe40832bc82ce30862451e6dbe237412298bdaec184f5bce9309f5e4823f6dc191ab5ae312c7308284bda5b4c01cebc376c65fde035767f581515047a7d463eda5e9849ee2278db80301aa88813981e80f9f3f4988de7cf8869719a5160e41e2cca4c4b0deedcff9c5706ca531452abcee947966643389f57790b7c899cf6fa4945d70b455d3270a6484d978440b91d3e23ce3055f1ef260a148ba4570bb51cb58ef3190066f2b1443d6ecd11a015b96966e0caf32236a4df15140e6ae6e131ede59ff51403400bcd325f075f7d2a5000f847a6a7996480bbbc63d08" + }, + { + "from": "service", + "name": "lp", + "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "message": "000000000000000148cf8f560097201c885cbfb4654f3007e1146c3b6c39ce8a4b98af3c76347a1def28cbf0bf20db92705fcc9bff3ee7bd78220f315ebbfebc2b4371edd2b9823910351899a8b3ef8e2a26c61eaa56fd4d71fff10af0aef235390740ad8357756cbe3ccbbf667c02d3c36d056d8f2ea29dcf4dd3be678e9d19abc308d6f940c45053b6633a336e0e86ba1accd5ac2ac60a7b7471115b9c539ee973acc104a71b71c42724ed43335f9eb3eff4d447b52d989605ab2a0e3250fbf19ec53367f11f1fbaa633b1bbd7422bc0740f7d44ffc6e7fc1b1e6594fae2a3027eb58b5a63089a328878c377ef69d5b1e194606836b5db97e9da4cd5e6bac5214f17b7ef161d0a04605251de29bfab442788332d5dcfc716bc04f604544368a87cfb77d7d57b89dae49c8378b2cf2372b6fe1108076edd6adbec3fa25df23d210bb22281f91564367ec7bfb17d5b6c218eda569d65bf5a99a9a242ff72190419e2ebaa34919457c9163458d6eec3e42b67e395b6d96b771a82ca8e4cf5faf3db7493c02d1695e2b2a6e40d3156cb570f5d4880ca294fd8ac70d8deddd605cdc9fbf5cb4c0b3de38a66ff3e1ef0d35c01dfb576eda170a545682498c035afac02f29ce0e3b7c0c3d0ad1536d04481cbc90e18376c57348ff1f38b1b83000e343966b52cf82f0d243c0d225360c6c1034f0dfbc2ed08f09ff2844c578ac10f27e5fa85e77fe84b98e6fff0609e82409f9b32b5809f50355007f7284bc8d60d612d60b46627668de3d4e7966b6c1f40435bd4d0fbba103a13c29db8fb74aa1c6ca06ea20979e3093d3a88f8fbef1bba2e15cfd81822a573820f3ca87e9f9519fbb8c536f23831f69e7be75b869835" + }, + { + "from": "phone", + "name": "watch away", + "frame": "010100000016a3616245a162687a0061746577617463686373696740", + "message": "000000000000000018d9d2f74604f3ec3b405b61c46ab22f3f16d31530953cc94eec8cade0d64f32ba349e2f7835559709c008f3" + }, + { + "from": "phone", + "name": "suspend", + "frame": "010100000026a3616253a1686465766963654964685a6630397851326161746773757370656e646373696740", + "message": "00000000000000018ef98c7df1fa903675ab1bf0e8aff5cd342be0cdc21b8ead3d04a153cf0fe6ce0d28f5fdf88e409e68eadc1827bfc61039294263085fc27f2b9b980b" + } + ], + "notes": { + "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.", + "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.", + "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0." + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json new file mode 100644 index 000000000..9ad224461 --- /dev/null +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -0,0 +1,196 @@ +{ + "boxKey": { + "d": "6c50811aa47e688ecbb01b2c1e91aca4a2ad7c879526d1c9679ef3fd25560074", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8", + "home": "fjzyr67buzpyncqmjliwgezvde" + }, + "serviceKey": { + "d": "f8d258ed839f4602dcfc2c139237d740e960310da68140e23bf88bf4c3c6ffa5", + "pk": "043cb883f267f64e085082b9025d318d83a1b36bc9fa474091074498c6417100da767b737fc59344ddb1b3ae5ddd4b6446f330315d79a0fb7dc88e96a5ec827c9e" + }, + "grantKey": "7777777777777777777777777777777777777777777777777777777777777777", + "ids": [ + { + "kind": "src", + "name": "p1meter", + "id": "ACfHt4DWL7g" + }, + { + "kind": "src", + "name": "sungrow", + "id": "f-oHMDqV-mA" + }, + { + "kind": "src", + "name": "easee", + "id": "DUYWWQmn-Xg" + }, + { + "kind": "lp", + "name": "garage", + "id": "j3HZORNCWHs" + } + ], + "messages": [ + { + "name": "challenge", + "signer": "", + "t": "challenge", + "body": "a2617601656e6f6e636558201111111111111111111111111111111111111111111111111111111111111111", + "signedInputSha256": "b8baed8e00b3e02409d01f18a83294c626a1ee5d43a3e38efd071fb2a89e92f0", + "sig": "", + "envelope": "a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740", + "frame": "010100000042a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740" + }, + { + "name": "hello", + "signer": "box", + "t": "hello", + "body": "ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f7374657253657104", + "signedInputSha256": "85ba027484b1e3ca0d10cca2602d07413d2dac2850e410e468274ee17ac43068", + "sig": "32b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "envelope": "a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "frame": "0101000001a9a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d" + }, + { + "name": "hello_ok", + "signer": "service", + "t": "hello_ok", + "body": "a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f7374657253657103", + "signedInputSha256": "499bf81555f32a94ce6e39a3e1eb43880773b33005ae5efe459a8227aee4d855", + "sig": "230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "envelope": "a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "frame": "0101000000b9a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d" + }, + { + "name": "roster", + "signer": "box", + "t": "roster", + "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104", + "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf", + "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", + "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", + "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124" + }, + { + "name": "live full", + "signer": "box", + "t": "live", + "body": "aa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b6564427980", + "signedInputSha256": "e45fdca79363137b374011804fb5905c1452b533cb275c89de0aa61562886393", + "sig": "02a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "envelope": "a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "frame": "010100000250a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95" + }, + { + "name": "live delta", + "signer": "box", + "t": "live", + "body": "a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b6564427980", + "signedInputSha256": "6881cdc040c9d5e58e85e7bfb9e3b15ae13e6a9c235a06070c43d870df0976fd", + "sig": "0dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "envelope": "a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "frame": "0101000000f3a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6" + }, + { + "name": "lp", + "signer": "box", + "t": "lp", + "body": "a7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e", + "signedInputSha256": "4774dda3175a23d752f2b1aea4e1959d50edec99f4882caf007779daa829eb8f", + "sig": "b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "envelope": "a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79" + }, + { + "name": "prices", + "signer": "box", + "t": "prices", + "body": "aa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847", + "signedInputSha256": "022b3d2ae277cd3caee80775e0a2fe1c8ecebcce3263f586c0ee5e4af96a00e7", + "sig": "e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "envelope": "a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "frame": "0101000000e0a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3" + }, + { + "name": "hb", + "signer": "box", + "t": "hb", + "body": "a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c", + "signedInputSha256": "6961171385681257aeb2a889a6c33b5854b95e85cef4d9a8fd3b6dfb7d709541", + "sig": "e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "envelope": "a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8" + }, + { + "name": "watch", + "signer": "", + "t": "watch", + "body": "a162687a01", + "signedInputSha256": "64808965667acfc5db8cd769b2c18654ff94b593dd47363d30b0835f438bde81", + "sig": "", + "envelope": "a3616245a162687a0161746577617463686373696740", + "frame": "010100000016a3616245a162687a0161746577617463686373696740" + }, + { + "name": "resync", + "signer": "", + "t": "resync", + "body": "a16477686174816466756c6c", + "signedInputSha256": "778a30a84492300eb2b2b14555a35e0ac73d2f145d5ef5c0b9aa45e0be3f093f", + "sig": "", + "envelope": "a361624ca16477686174816466756c6c617466726573796e636373696740", + "frame": "01010000001ea361624ca16477686174816466756c6c617466726573796e636373696740" + }, + { + "name": "bye", + "signer": "box", + "t": "bye", + "body": "a6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a00011170", + "signedInputSha256": "0499c5251783c9826fdc39a6a09e8f0f35f20244dd100b07e9978dced9475389", + "sig": "19b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "envelope": "a36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "frame": "01010000009ea36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04" + }, + { + "name": "purged", + "signer": "service", + "t": "purged", + "body": "a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444", + "signedInputSha256": "8789b7655a3f732582f55de4e6e1dbb9d9a60dadc501b535560566e8c879c9b6", + "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" + } + ] +} diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json new file mode 100644 index 000000000..e6b4ac67c --- /dev/null +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -0,0 +1,257 @@ +{ + "keys": [ + { + "home": "fjzyr67buzpyncqmjliwgezvde", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8" + }, + { + "home": "t3w5bfotxeygcjxjdhjaeo6pvq", + "pk": "04003ed3e19bde3ad2abbe687ce92a9d63cfacd49cb4bace9903c3ee95093700804081439d960c07e29171ab20fdaf94c603d87e4669f961d7a227e8d8616111f9" + } + ], + "cases": [ + { + "name": "connected, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEABMIFtaHgkzfeMrtApu30UgdXtawe2kzcX4lBlhZ9yVOdjZkXlCSoSUrd2nHXDYI4pClpW4ReFKP/H9mqucV2H\"}}", + "sig": "0130816d687824cdf78caed029bb7d1481d5ed6b07b6933717e25065859f7254e7636645e5092a1252b7769c75c3608e290a5a56e1178528ffc7f66aae715d87", + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 9, + "atMs": 1791820810600 + } + }, + { + "name": "interrupted, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQKJwjC0k3f6XPDpapuR7NTwgus1FBgURGLD5DSpoTIsFWtfQ2iow7T1MWUHBPcWxPna0WQUCzpdkh5EcsZQULTs=\"}}", + "sig": "a2708c2d24ddfe973c3a5aa6e47b353c20bacd4506051118b0f90d2a684c8b055ad7d0da2a30ed3d4c5941c13dc5b13e76b4590502ce976487911cb194142d3b", + "nowMs": 1791820813200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "goal at risk, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWK+qYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw19ZMY3NlcQ9kYXJnc6JiYnllMDc6MDBmdGFyZ2V0YjgwZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdWNoYXJnaW5nLmdvYWxfYXRfcmlza2Z3YWxsTXMbAAABoTDX1rBnZXZlbnRJZGxaMjloYkhKcGMyc3hoc2V2ZXJpdHlnd2FybmluZ2h1cHRpbWVNcxoAASEQYXRlZXZlbnRjc2lnWEDAGlZS76Snuq3dzP6oBe1buyz2kzW/N79ukTRxpHgali+4Bqh7Um61zOsv4QTfS6Ss6vENXdDC6biX122t3pJg\"}}", + "sig": "c01a5652efa4a7baadddccfea805ed5bbb2cf69335bf37bf6e913471a4781a962fb806a87b526eb5cceb2fe104df4ba4aceaf10d5dd0c2e9b897d76dadde9260", + "nowMs": 1791820815200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.goal_at_risk", + "args": { + "by": "07:00", + "target": "80" + }, + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 15, + "atMs": 1791820813900 + } + }, + { + "name": "one byte of the body changed", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmJvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECNIbMUmau1F9d1HqNW3rNdV0jlDVXEjK/Q3/b8bBjF7yCpDjUzd9n2FfuL0zIv0aJUJkc3BbScu+pvpr8piwGb\"}}", + "sig": "8d21b31499abb517d7751ea356deb35d5748e50d55c48cafd0dff6fc6c18c5ef20a90e353377d9f615fb8bd3322fd1a25426473705b49cbbea6fa6bf298b019b", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "named for another home the phone holds", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"t3w5bfotxeygcjxjdhjaeo6pvq\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBH6wZnxbr20B6MN8GTyLZcR25rSVN1bugYCsIleoMmLRG5mELw/q7JcRUOiucK7LLM3ka46TK2T7FnZh3mxuIl\"}}", + "sig": "47eb0667c5baf6d01e8c37c193c8b65c476e6b4953756ee8180ac2257a83262d11b99842f0feaec971150e8ae70aecb2ccde46b8e932b64fb167661de6c6e225", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "t3w5bfotxeygcjxjdhjaeo6pvq" + } + }, + { + "name": "signed by another box for this home", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcRBjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBJ68gFtSwOcqFpSXiK6MHVsHr2RiTTMxIm9h1lNwn/IroE4rs65mYQCkTY+xLXrenrOasV/suwp6HzhwgoHk8z\"}}", + "sig": "49ebc805b52c0e72a16949788ae8c1d5b07af64624d3331226f61d653709ff22ba04e2bb3ae666100a44d8fb12d7ade9eb39ab15fecbb0a7a1f38708281e4f33", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "two hours old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAg1t+OvFYf9y9gpMPg21Q7dzGvrJ5V8CrBNIr2EWpgLZl1QZgbSA8ELs8nrATH2p+tUjDAmJSyG1m8YlsgYEap\"}}", + "sig": "20d6df8ebc561ff72f60a4c3e0db543b7731afac9e55f02ac1348af6116a602d997541981b480f042ecf27ac04c7da9fad5230c09894b21b59bc625b206046a9", + "nowMs": 1791828011000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "ten minutes old is still fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQB7Mfxasg5x9bJLORP3yYOgcto/sS+Y/Z2SgGKkgJucoOlYH2mg5ex6sShXDjDHRYZ4RHhRgC3ENSJ+FxFIryMI=\"}}", + "sig": "1ecc7f16ac839c7d6c92ce44fdf260e81cb68fec4be63f6764a018a92026e7283a5607da68397b1eac4a15c38c31d1619e111e14600b710d489f85c4522bc8c2", + "nowMs": 1791821411700, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "box clock not synced", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQA4D7BC9hbsDGWWnl69+YzZWkvCG2DyNVfPtNFNW4O27uftmRBxY0ewGRUt+ilCSn+mRKmIIqpbBh0wAgJxCVaI=\"}}", + "sig": "0e03ec10bd85bb031965a797af7e63365692f086d83c8d55f3ed345356e0edbbb9fb66441c58d1ec06454b7e8a50929fe9912a6208aa96c1874c00809c4255a2", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "box clock ahead of the phone", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEDNcUjPNmeqdosZjUen9w+4n17dD+hThNnqvSLF0A89nUqTWpscn7BRZybd7FyfIHQ8We5wyuZLHQYmf4s2Ggd5\"}}", + "sig": "cd7148cf3667aa768b198d47a7f70fb89f5edd0fe85384d9eabd22c5d00f3d9d4a935a9b1c9fb0516726ddec5c9f20743c59ee70cae64b1d06267f8b361a0779", + "nowMs": 1791820817999, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a kind the phone does not know", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKSrYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQ5jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGNoYXJnaW5nLnNvbWVkYXlmd2FsbE1zGwAAAaEw18r4Z2V2ZW50SWRsUTJack1qUjBkelZoaHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAEVWGF0ZWV2ZW50Y3NpZ1hAuiASso5HdVyi4+dXlmi5PKDjO5DBTXiaChzNJqz5l/nXU7o++4oPDzfzx3k0Ra0tM3vXtZ2GoHJafIP+dypV6g==\"}}", + "sig": "ba2012b28e47755ca2e3e7579668b93ca0e33b90c14d789a0a1ccd26acf997f9d753ba3efb8a0f0f37f3c7793445ad2d337bd7b59d86a0725a7c83fe772a55ea", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "unreachable, from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"box.unreachable\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "unreachable", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "another kind from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"charging.connected\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a home the phone does not hold", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECFrKOjjhGZn6O0MCWuzT1paANIFK44ZLUd7mRqIzBcRgdME/ZjLp6jHx96zZY87aqltd41D9CG4Fn1IrGtzuNz\"}}", + "sig": "85aca3a38e11999fa3b43025aecd3d6968034814ae3864b51dee646a23305c46074c13f6632e9ea31f1f7acd963cedaaa5b5de350fd086e059f522b1adcee373", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "name": "box clock not synced, anchored by rule U", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQFNotNXPkQUfb07DO7RwspScyA+HtdtuE/R6EAOdZFzmMBTAeJfa+x5KFmmfsdQNg7Q3ffV+WLE0KNonhgL6OXY=\"}}", + "sig": "5368b4d5cf91051f6f4ec33bb470b2949cc80f87b5db6e13f47a10039d645ce63014c07897dafb1e4a16699fb1d40d83b4377df57e58b13428da278602fa3976", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 12, + "atMs": 1791820810640 + } + }, + { + "name": "box clock ahead, anchored by rule U", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAT0WQabV0OknG7SmU38U/KFBl7E6LdxOZZMNh9lOK/hi2WqRrVsSfQKUrKe8k65zfTUYHCQjuGSyi03B3vwg4W\"}}", + "sig": "13d1641a6d5d0e9271bb4a6537f14fca14197b13a2ddc4e65930d87d94e2bf862d96a91ad5b127d0294aca7bc93ae737d35181c2423b864b28b4dc1defc20e16", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 13, + "atMs": 1791820810640 + } + }, + { + "name": "rule U from another boot", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQGk/DH/IlyDThxxoaZe5v0fNthUnu8QnNIlvDjReA36nwi4S1d0vPiDfNV2JwE4iyT57heDhaU27bERXLSNGRnc=\"}}", + "sig": "693f0c7fc89720d3871c686997b9bf47cdb61527bbc42734896f0e345e037ea7c22e12d5dd2f3e20df355d89c04e22c93e7b85e0e1694dbb6c44572d23464677", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "rule U learned over a day ago", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQJYY1B25q3N9kJEsHpndMcA9zIZD70klDsFbQdtklHF3eIWxYd1qbRN1eQaszxJWNQc8zatRazb3KwxDyiFMj/0=\"}}", + "sig": "9618d41db9ab737d90912c1e99dd31c03dcc8643ef49250ec15b41db649471777885b161dd6a6d13757906accf125635073ccdab516b36f72b0c43ca214c8ffd", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791734412199 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + } + ] +} diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go new file mode 100644 index 000000000..172253841 --- /dev/null +++ b/go/protocol/cloudwire/vectors_test.go @@ -0,0 +1,294 @@ +package cloudwire + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "os" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// Golden vectors: fixed keys and bodies, and the exact bytes they make. +// +// testdata/cloud-vectors.json is what srcfl/ftw-cloud and the phone replay. A +// change to any body, key order, id or frame shows up here as a diff to that +// file, which has to be regenerated on purpose: +// +// go test ./cloudwire -run TestGoldenVectors -update +// +// ECDSA signatures are randomised, so the stored signature is checked by +// verifying it, not by signing again. + +var update = flag.Bool("update", false, "rewrite testdata/cloud-vectors.json") + +const vectorsPath = "testdata/cloud-vectors.json" + +type vectorFile struct { + BoxKey vectorKey `json:"boxKey"` + ServiceKey vectorKey `json:"serviceKey"` + GrantKey string `json:"grantKey"` + IDs []vectorID `json:"ids"` + Messages []vectorSigned `json:"messages"` +} + +type vectorKey struct { + D string `json:"d"` + PK string `json:"pk"` + // Home is the box key's home id. The service signs over the box's home + // too, so its own key has none. + Home string `json:"home,omitempty"` +} + +type vectorID struct { + Kind string `json:"kind"` + Name string `json:"name"` + ID string `json:"id"` +} + +type vectorSigned struct { + Name string `json:"name"` + // Signer is "box", "service" or "" for unsigned. + Signer string `json:"signer"` + T string `json:"t"` + Body string `json:"body"` + // SignedInputSHA256 is SHA-256 of SignTag ‖ CBOR([home, t, b]). + SignedInputSHA256 string `json:"signedInputSha256"` + Sig string `json:"sig"` + Envelope string `json:"envelope"` + Frame string `json:"frame"` +} + +func i64(v int64) *int64 { return &v } + +// vectorBodies are the bodies the vectors freeze, in order. +func vectorBodies(grantKey []byte, boxPK []byte) []struct { + name, signer, t string + body any +} { + boot := bytes.Repeat([]byte{0xb0}, BootBytes) + wall := int64(1_791_820_800_000) // 2026-10-12 17:20:00 UTC + h := func(seq uint64, uptime int64) Header { + return Header{Seq: seq, Boot: boot, UptimeMs: uptime, WallMs: i64(wall + uptime - 60_000)} + } + meter := SourceID(grantKey, "p1meter") + inverter := SourceID(grantKey, "sungrow") + charger := SourceID(grantKey, "easee") + garage := LoadpointID(grantKey, "garage") + + return []struct { + name, signer, t string + body any + }{ + {"challenge", "", MsgChallenge, Challenge{V: Version, Nonce: bytes.Repeat([]byte{0x11}, NonceBytes)}}, + {"hello", "box", MsgHello, Hello{ + Header: h(1, 60_000), V: Version, PK: boxPK, + Nonce: bytes.Repeat([]byte{0x11}, NonceBytes), EKM: bytes.Repeat([]byte{0x22}, EKMBytes), + BoxNonce: bytes.Repeat([]byte{0x33}, NonceBytes), Build: "0.142.0", Clock: ClockNTP, + Caps: []string{MsgLive, MsgLP, MsgPrices, MsgHB, MsgRoster}, RosterSeq: 4, + GrantID: bytes.Repeat([]byte{0x44}, GrantIDBytes), + }}, + {"hello_ok", "service", MsgHelloOK, HelloOK{ + BoxNonce: bytes.Repeat([]byte{0x33}, NonceBytes), EKM: bytes.Repeat([]byte{0x22}, EKMBytes), + Have: map[string]string{}, RosterSeq: 3, + }}, + {"roster", "box", MsgRoster, Roster{ + Header: h(2, 60_010), RosterSeq: 4, TTLS: 86_400, + Devices: []Device{ + {ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopeHistoryRead, protocol.ScopePlanRead, + protocol.ScopeHealthRead, protocol.ScopeEventsRead}, Epoch: 1}, + {ID: "Zf09xQ2a", Key: bytes.Repeat([]byte{0x66}, 32), Role: "viewer", + Scopes: []string{protocol.ScopeLiveRead}, Epoch: 2}, + }, + Grant: Grant{ID: bytes.Repeat([]byte{0x44}, GrantIDBytes), + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopeEventsRead}, + Purposes: []string{protocol.CloudPurposeServe}, Controller: ControllerSourceful}, + }}, + {"live full", "box", MsgLive, Live{ + Header: h(3, 60_020), + Fields: map[string]int64{"1": 0, "2": 1840, "3": -2310, "4": 950, "5": 612, "6": 1100, "10": 2100}, + FieldSources: map[string][]string{ + "2": {meter}, "3": {inverter}, "4": {inverter}, "5": {inverter}, + "6": {charger, inverter, meter}, "10": {charger}, + }, + Sources: map[string]Source{ + meter: {Kind: "driver", State: protocol.SourceLive, LastOkMs: 59_100, StaleAfterMs: 10_000}, + inverter: {Kind: "driver", State: protocol.SourceLive, LastOkMs: 57_400, StaleAfterMs: 15_000}, + charger: {Kind: "driver", State: protocol.SourceLagging, LastOkMs: 1_000, StaleAfterMs: 30_000}, + }, + SrcRev: 1, + Ages: map[string]int64{meter: 920, inverter: 2620, charger: 59_020}, + }}, + {"live delta", "box", MsgLive, Live{ + Header: h(4, 64_500), + Fields: map[string]int64{"1": 0, "2": 1795, "3": -2290, "4": 950, "5": 612, "6": 1035, "10": 2100}, + SrcRev: 1, + Ages: map[string]int64{meter: 400, inverter: 2100}, + }}, + {"lp", "box", MsgLP, LP{ + Header: h(5, 64_510), ControlRev: 812, + Loadpoints: []Loadpoint{{ + ID: garage, Src: charger, Plugged: true, SocPermille: i64(430), SocSource: "inferred", + SocAgeS: i64(1800), TargetPermille: i64(800), ReadyByMs: i64(1_791_867_600_000), + Recurring: true, PowerW: 2100, SessionWh: 3400, Session: "s1", + CommandedW: i64(2070), CommandedReason: "plan", PlanPending: false, + NextStartMs: i64(1_791_850_000_000), NextEndMs: i64(1_791_857_200_000), NextWh: i64(7400), + }}, + Feedback: []Feedback{{ + Kind: "ev", Src: charger, Status: "following", Reason: "plan", Severity: "ok", + Evidence: "measured", Fresh: true, ActualW: i64(2100), AgeMs: i64(900), + }}, + }}, + {"prices", "box", MsgPrices, Prices{ + Header: h(6, 64_520), Zone: "SE3", Currency: "SEK", + T0Ms: 1_791_820_800_000, SlotMs: 900_000, + SpotMinor: []*int64{i64(42), i64(40), nil, i64(-3)}, + TotalMinor: []*int64{i64(118), i64(115), nil, i64(71)}, + }}, + {"hb", "box", MsgHB, HB{ + Header: h(7, 69_020), Clock: ClockNTP, SrcRev: 1, LiveSeq: 4, LpSeq: 5, + Ages: map[string]int64{meter: 1_400, inverter: 3_100}, + }}, + {"watch", "", MsgWatch, Watch{Hz: 1}}, + {"resync", "", MsgResync, Resync{What: []string{ResyncFull}}}, + {"bye", "box", MsgBye, Bye{Header: h(8, 70_000), Why: ByeRevoked, Purge: true}}, + {"purged", "service", MsgPurged, Purged{AtMs: 1_791_821_000_000, What: PurgedHome, + GrantID: bytes.Repeat([]byte{0x44}, GrantIDBytes)}}, + {"event connected", "box", MsgEvent, Event{ + Header: h(9, 71_000), EventID: "Q2ZrMjR0dzVh", Ts: i64(wall + 71_000 - 60_000 - 400), + Kind: protocol.PushChargingConnected, Severity: SeverityInfo, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event interrupted", "box", MsgEvent, Event{ + Header: h(10, 72_000), EventID: "aW50cnB0ZDAx", Ts: i64(wall + 72_000 - 60_000 - 300), + Kind: protocol.PushChargingInterrupted, Severity: SeverityWarning, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event session complete", "box", MsgEvent, Event{ + Header: h(11, 73_000), EventID: "ZG9uZTAwMDAx", Ts: i64(wall + 73_000 - 60_000 - 200), + Kind: protocol.PushChargingSessionComplete, Severity: SeverityInfo, + Args: map[string]string{"kwh": "18.4"}, Src: charger, LP: garage, + }}, + } +} + +func TestGoldenVectors(t *testing.T) { + box := testKey(t, "ftw.cloud vector box") + service := testKey(t, "ftw.cloud vector service") + home, boxPK := homeOf(t, box) + _, servicePK := homeOf(t, service) + grantKey := bytes.Repeat([]byte{0x77}, GrantKeyBytes) + + var stored vectorFile + if !*update { + raw, err := os.ReadFile(vectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if err := json.Unmarshal(raw, &stored); err != nil { + t.Fatal(err) + } + } + + out := vectorFile{ + BoxKey: vectorKey{D: hex.EncodeToString(box.D.FillBytes(make([]byte, 32))), PK: hex.EncodeToString(boxPK), Home: home}, + ServiceKey: vectorKey{D: hex.EncodeToString(service.D.FillBytes(make([]byte, 32))), PK: hex.EncodeToString(servicePK)}, + GrantKey: hex.EncodeToString(grantKey), + } + for _, id := range []vectorID{{"src", "p1meter", ""}, {"src", "sungrow", ""}, {"src", "easee", ""}, {"lp", "garage", ""}} { + if id.Kind == "src" { + id.ID = SourceID(grantKey, id.Name) + } else { + id.ID = LoadpointID(grantKey, id.Name) + } + out.IDs = append(out.IDs, id) + } + + for i, v := range vectorBodies(grantKey, boxPK) { + body, err := Marshal(v.body) + if err != nil { + t.Fatal(err) + } + input, err := SignedInput(home, v.t, body) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(input) + vec := vectorSigned{Name: v.name, Signer: v.signer, T: v.t, Body: hex.EncodeToString(body), + SignedInputSHA256: hex.EncodeToString(digest[:])} + + var sig []byte + switch { + case v.signer == "" || *update: + if v.signer != "" { + key := box + if v.signer == "service" { + key = service + } + if sig, err = Sign(rand.Reader, key, home, v.t, body); err != nil { + t.Fatal(err) + } + } + default: + if i >= len(stored.Messages) || stored.Messages[i].Name != v.name { + t.Fatalf("vector %d is not %q in %s; regenerate with -update", i, v.name, vectorsPath) + } + sig, _ = hex.DecodeString(stored.Messages[i].Sig) + } + env := Envelope{T: v.t, B: body, Sig: sig} + if v.signer != "" { + pub := &box.PublicKey + if v.signer == "service" { + pub = &service.PublicKey + } + if err := Verify(pub, home, env); err != nil { + t.Fatalf("%s: stored signature does not verify: %v", v.name, err) + } + } + raw, err := env.Encode() + if err != nil { + t.Fatal(err) + } + frame, err := EncodeFrame(raw) + if err != nil { + t.Fatal(err) + } + vec.Sig, vec.Envelope, vec.Frame = hex.EncodeToString(sig), hex.EncodeToString(raw), hex.EncodeToString(frame) + out.Messages = append(out.Messages, vec) + + // What decodes from the frame is what was encoded. + payload, err := DecodeFrame(frame) + if err != nil { + t.Fatal(err) + } + back, err := DecodeEnvelope(payload) + if err != nil || back.T != v.t || !bytes.Equal(back.B, body) || !bytes.Equal(back.Sig, sig) { + t.Fatalf("%s: round trip: %v", v.name, err) + } + } + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(vectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, _ := os.ReadFile(vectorsPath) + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the wire change is intended, "+ + "regenerate with -update and pair the change with srcfl/ftw-cloud", vectorsPath) + } +} diff --git a/go/protocol/cloudwire/view.go b/go/protocol/cloudwire/view.go new file mode 100644 index 000000000..218a4c101 --- /dev/null +++ b/go/protocol/cloudwire/view.go @@ -0,0 +1,364 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "encoding/hex" + "errors" + "fmt" + + "github.com/srcfl/ftw/go/protocol" +) + +// The phone's half of the freshness rules, as a reference. +// +// The service cannot sign, so it cannot make data look fresher than the box +// graded it; it can only delay, drop or replay, and each of those must age the +// data or mark it stale on the phone. View is that phone-side logic in Go: the +// vectors in docs/protocol-cloud.md run against it here, and the Swift port +// replays the same cases. Nothing in the box or the service uses it. + +// Timing rules from the protocol's Freshness section. +const ( + // RuleNLeadMs is how far ahead of the phone clock a box's wallMs may be + // before the phone stops trusting the box clock for that boot. + RuleNLeadMs = 2_000 + // RuleULifetimeMs is how long an uptime offset learned over Noise stays + // usable. + RuleULifetimeMs = 24 * 60 * 60 * 1000 + // ruleUDriftDivisor sets the margin rule U adds to every age: 0.01 % of + // the time since the offset was learned. + ruleUDriftDivisor = 10_000 +) + +// ErrNotNewer is a message the phone already has something newer than. +var ErrNotNewer = errors.New("cloudwire: older than what the phone holds") + +// View is what a phone holds for one home. +type View struct { + home string + key *ecdsa.PublicKey + + boot []byte + seq map[string]uint64 + noRuleN bool + newest int64 // latest anchored send time held, phone clock + + table map[string]Source + tableRev uint64 + fieldSrc map[string][]string + live *Live + lp *LP + m *anchor + + offsets map[string]uptimeOffset +} + +// anchor is M: the newest anchored live or hb. +type anchor struct { + seq uint64 + hb bool + boot []byte + wallSend int64 // send time by rule N, or -1 + uptimeMs int64 + srcRev uint64 + liveSeq uint64 + lpSeq uint64 + ages map[string]int64 +} + +type uptimeOffset struct { + offsetMs int64 + learnedAtMs int64 +} + +// NewView starts an empty view of one home, with the box key the phone +// learned over Noise. Never a key from the service. +func NewView(home string, boxKey *ecdsa.PublicKey) *View { + return &View{home: home, key: boxKey, seq: map[string]uint64{}, offsets: map[string]uptimeOffset{}} +} + +// LearnOffset records rule U's anchor from a Noise session with the box: +// phone time minus box uptime, the smallest seen per boot. +func (v *View) LearnOffset(boot []byte, phoneMs, uptimeMs int64) { + k := hex.EncodeToString(boot) + off := phoneMs - uptimeMs + if cur, ok := v.offsets[k]; ok && cur.offsetMs <= off { + cur.learnedAtMs = phoneMs + v.offsets[k] = cur + return + } + v.offsets[k] = uptimeOffset{offsetMs: off, learnedAtMs: phoneMs} +} + +// Accept takes one box envelope that arrived at arrivalMs on the phone clock. +// It returns an error, and changes nothing, for a message it drops. +func (v *View) Accept(e Envelope, arrivalMs int64) error { + if err := Verify(v.key, v.home, e); err != nil { + return err + } + if _, ok := ClassScope[e.T]; !ok { + return fmt.Errorf("cloudwire: %q is not a class a phone reads", e.T) + } + var h Header + if err := Unmarshal(e.B, &h); err != nil { + return err + } + + // Decode the class body before anything changes, so a message that is + // dropped leaves the view as it was. + var ( + live *Live + lp *LP + hb *HB + ) + switch e.T { + case MsgLive: + live = new(Live) + if err := Unmarshal(e.B, live); err != nil { + return err + } + case MsgLP: + lp = new(LP) + if err := Unmarshal(e.B, lp); err != nil { + return err + } + case MsgHB: + hb = new(HB) + if err := Unmarshal(e.B, hb); err != nil { + return err + } + } + + lead := h.WallMs != nil && *h.WallMs > arrivalMs+RuleNLeadMs + newBoot := !bytes.Equal(h.Boot, v.boot) + if newBoot { + // Another boot replaces this one only with an anchored message sent + // after the newest one held, so a replay of an older boot cannot + // wipe a newer one. + send, ok := v.sendTime(h.Boot, wallSend(h, arrivalMs, lead), h.UptimeMs, arrivalMs) + if (v.boot != nil && (!ok || send <= v.newest)) || h.Seq == 0 { + return ErrNotNewer + } + v.reset(h.Boot) + } else if h.Seq <= v.seq[e.T] { + return ErrNotNewer + } + if lead { + v.noRuleN = true + } + ws := wallSend(h, arrivalMs, v.noRuleN) + send, anchored := v.sendTime(h.Boot, ws, h.UptimeMs, arrivalMs) + + switch { + case live != nil: + v.live = live + if live.Sources != nil { + v.table, v.tableRev, v.fieldSrc = live.Sources, live.SrcRev, live.FieldSources + } + if anchored { + v.setM(&anchor{seq: h.Seq, boot: h.Boot, wallSend: ws, uptimeMs: h.UptimeMs, + srcRev: live.SrcRev, liveSeq: h.Seq, ages: live.Ages}) + } + case lp != nil: + v.lp = lp + case hb != nil: + if anchored { + v.setM(&anchor{seq: h.Seq, hb: true, boot: h.Boot, wallSend: ws, uptimeMs: h.UptimeMs, + srcRev: hb.SrcRev, liveSeq: hb.LiveSeq, lpSeq: hb.LpSeq, ages: hb.Ages}) + } + } + v.seq[e.T] = h.Seq + if anchored && send > v.newest { + v.newest = send + } + return nil +} + +func (v *View) setM(a *anchor) { + if v.m == nil || a.seq > v.m.seq { + v.m = a + } +} + +func (v *View) reset(boot []byte) { + v.boot = append([]byte(nil), boot...) + v.seq = map[string]uint64{} + v.noRuleN = false + v.table, v.tableRev, v.fieldSrc = nil, 0, nil + v.live, v.lp, v.m = nil, nil, nil +} + +// wallSend is rule N: the earlier of wallMs and arrival, or -1 when the box +// sent no wall time or rule N is off for this boot. +func wallSend(h Header, arrivalMs int64, off bool) int64 { + if h.WallMs == nil || off { + return -1 + } + return min(*h.WallMs, arrivalMs) +} + +// sendTime picks the earlier of rule N and rule U. ok is false when neither +// applies at nowMs. +func (v *View) sendTime(boot []byte, wall, uptimeMs, nowMs int64) (int64, bool) { + send, ok := wall, wall >= 0 + if u, uok := v.ruleU(boot, uptimeMs, nowMs); uok && (!ok || u < send) { + send, ok = u, true + } + return send, ok +} + +func (v *View) ruleU(boot []byte, uptimeMs, nowMs int64) (int64, bool) { + off, ok := v.offsets[hex.EncodeToString(boot)] + if !ok || nowMs-off.learnedAtMs > RuleULifetimeMs { + return 0, false + } + return off.offsetMs + uptimeMs, true +} + +// Grades is how a phone would show what it holds at one moment. +type Grades struct { + // Anchored is false when no send time can be trusted: the phone shows + // "cloud, age unverified" and never live. + Anchored bool + // Sources is each source's effective state. + Sources map[string]protocol.SourceState + // Fields is each live field's state, keyed by field id. + Fields map[string]protocol.SourceState + // Loadpoints is each loadpoint's state, keyed by its opaque id. + Loadpoints map[string]protocol.SourceState +} + +// Grade applies the protocol's state rules at nowMs. +func (v *View) Grade(nowMs int64) Grades { + g := Grades{ + Sources: map[string]protocol.SourceState{}, + Fields: map[string]protocol.SourceState{}, + Loadpoints: map[string]protocol.SourceState{}, + } + var t int64 + if v.m != nil { + wall := v.m.wallSend + if v.noRuleN { + wall = -1 + } + send, ok := v.sendTime(v.m.boot, wall, v.m.uptimeMs, nowMs) + if ok { + g.Anchored = true + t = max(0, nowMs-send) + if u, uok := v.ruleU(v.m.boot, v.m.uptimeMs, nowMs); uok && u == send { + off := v.offsets[hex.EncodeToString(v.m.boot)] + t += (nowMs - off.learnedAtMs) / ruleUDriftDivisor + } + } + } + + tableBehind := g.Anchored && v.m.srcRev > v.tableRev + for id, s := range v.table { + state := worse(s.State, protocol.SourceLive) + switch { + case !g.Anchored || tableBehind: + state = worse(state, protocol.SourceStale) + default: + if age, ok := v.m.ages[id]; ok { + state = worse(state, ladder(age+t, s.StaleAfterMs)) + } + } + g.Sources[id] = state + } + + if v.live != nil { + fieldsBehind := !g.Anchored || (v.m.hb && v.m.liveSeq > v.seq[MsgLive]) + for f := range v.live.Fields { + state := protocol.SourceLive + srcs := v.fieldSrc[f] + if len(srcs) == 0 { + if t > 2*v.heartbeatMs() { + state = protocol.SourceStale + } + } + for _, s := range srcs { + st, ok := g.Sources[s] + if !ok { + st = protocol.SourceStale + } + state = worse(state, st) + } + if fieldsBehind { + state = worse(state, protocol.SourceStale) + } + g.Fields[f] = state + } + } + + if v.lp != nil { + lpBehind := !g.Anchored || (v.m.hb && v.m.lpSeq > v.seq[MsgLP]) + for _, lp := range v.lp.Loadpoints { + state, ok := g.Sources[lp.Src] + if !ok { + state = protocol.SourceStale + } + if lpBehind { + state = worse(state, protocol.SourceStale) + } + g.Loadpoints[lp.ID] = state + } + } + return g +} + +// heartbeatMs is H: half the smallest staleAfterMs, between 2 and 15 s. +func (v *View) heartbeatMs() int64 { + return Heartbeat(v.table) +} + +// Heartbeat is H for a source table: clamp(smallest staleAfterMs ÷ 2, 2 s, +// 15 s), and 15 s when the table holds no source with a cadence. +func Heartbeat(table map[string]Source) int64 { + smallest := int64(-1) + for _, s := range table { + if s.StaleAfterMs > 0 && (smallest < 0 || s.StaleAfterMs < smallest) { + smallest = s.StaleAfterMs + } + } + if smallest < 0 { + return 15_000 + } + return min(max(smallest/2, 2_000), 15_000) +} + +// ladder grades an age the way the box grades its own sources. +func ladder(age, staleAfter int64) protocol.SourceState { + switch { + case staleAfter <= 0: + return protocol.SourceNever + case age <= staleAfter: + return protocol.SourceLive + case age <= 2*staleAfter: + return protocol.SourceLagging + case age <= 6*staleAfter: + return protocol.SourceStale + default: + return protocol.SourceDown + } +} + +var stateRank = map[protocol.SourceState]int{ + protocol.SourceLive: 0, protocol.SourceLagging: 1, protocol.SourceStale: 2, + protocol.SourceDown: 3, protocol.SourceNever: 4, +} + +// worse is the worse of two states. A state this phone does not know ranks +// as never, whichever side it is on, so it can never read as live. +func worse(a, b protocol.SourceState) protocol.SourceState { + if _, ok := stateRank[a]; !ok { + a = protocol.SourceNever + } + if _, ok := stateRank[b]; !ok { + b = protocol.SourceNever + } + if stateRank[b] > stateRank[a] { + return b + } + return a +} diff --git a/go/protocol/cloudwire/view_test.go b/go/protocol/cloudwire/view_test.go new file mode 100644 index 000000000..4495cb54f --- /dev/null +++ b/go/protocol/cloudwire/view_test.go @@ -0,0 +1,373 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "errors" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// The freshness cases from docs/protocol-cloud.md, run against View. Times are +// in ms on one clock unless a case says the box clock is off. Rule N with +// 100 ms in transit unless stated. + +const transit = 100 + +type fakeBox struct { + t *testing.T + key *ecdsa.PrivateKey + home string + boot []byte + seq uint64 + // start is the wall time at which uptime was zero. clockLead moves the + // box's wall clock against the phone's. + start, clockLead int64 + noClock bool +} + +func newFakeBox(t *testing.T, bootByte byte) *fakeBox { + key := testKey(t, "freshness box") + home, _ := homeOf(t, key) + return &fakeBox{t: t, key: key, home: home, boot: bytes.Repeat([]byte{bootByte}, BootBytes), start: 1_000_000} +} + +func (b *fakeBox) header(at int64) Header { + b.seq++ + h := Header{Seq: b.seq, Boot: b.boot, UptimeMs: at - b.start} + if !b.noClock { + w := at + b.clockLead + h.WallMs = &w + } + return h +} + +func (b *fakeBox) seal(t string, body any) Envelope { + env, err := Seal(b.key, b.home, t, body) + if err != nil { + b.t.Fatal(err) + } + return env +} + +// fullLive is one source "m", S = 10 s, read at the given age. +func (b *fakeBox) fullLive(at, age int64, state protocol.SourceState, srcRev uint64) Envelope { + return b.seal(MsgLive, Live{ + Header: b.header(at), Fields: map[string]int64{"2": 1000, "1": 0}, + FieldSources: map[string][]string{"2": {"m"}}, + Sources: map[string]Source{"m": {Kind: "driver", State: state, LastOkMs: at - b.start - age, StaleAfterMs: 10_000}}, + SrcRev: srcRev, Ages: map[string]int64{"m": age}, + }) +} + +func (b *fakeBox) hb(at, age int64, srcRev, liveSeq, lpSeq uint64) Envelope { + return b.seal(MsgHB, HB{Header: b.header(at), Clock: ClockNTP, SrcRev: srcRev, + LiveSeq: liveSeq, LpSeq: lpSeq, Ages: map[string]int64{"m": age}}) +} + +func (b *fakeBox) view() *View { return NewView(b.home, &b.key.PublicKey) } + +func accept(t *testing.T, v *View, e Envelope, arrival int64) { + t.Helper() + if err := v.Accept(e, arrival); err != nil { + t.Fatalf("accept %s at %d: %v", e.T, arrival, err) + } +} + +func wantSource(t *testing.T, v *View, at int64, want protocol.SourceState) { + t.Helper() + if got := v.Grade(at).Sources["m"]; got != want { + t.Fatalf("at %d: source is %s, want %s", at, got, want) + } +} + +// 1. A quiet live source stays live. +func TestFreshnessQuietSourceStaysLive(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + for at := t0 + 5_000; at <= t0+120_000; at += 5_000 { + accept(t, v, box.hb(at, 4_500, 1, 1, 0), at+transit) + for probe := at + transit; probe < at+5_000+transit; probe += 500 { + wantSource(t, v, probe, protocol.SourceLive) + if f := v.Grade(probe).Fields["2"]; f != protocol.SourceLive { + t.Fatalf("field 2 is %s", f) + } + } + } +} + +// 2. A dead uplink ages on time. +func TestFreshnessDeadUplinkAgesOnTime(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + last := t0 + 5_000 + accept(t, v, box.hb(last, 2_000, 1, 1, 0), last+transit) + // Send time is the box's wallMs, so t counts from `last`. + wantSource(t, v, last+8_000, protocol.SourceLive) + wantSource(t, v, last+8_001, protocol.SourceLagging) + wantSource(t, v, last+18_000, protocol.SourceLagging) + wantSource(t, v, last+18_001, protocol.SourceStale) + wantSource(t, v, last+58_000, protocol.SourceStale) + wantSource(t, v, last+58_001, protocol.SourceDown) +} + +// 3. A replay ages out, and a phone that holds newer drops it. +func TestFreshnessReplayAgesOut(t *testing.T) { + box := newFakeBox(t, 1) + t0 := int64(2_000_000) + live := box.fullLive(t0, 1_000, protocol.SourceLive, 1) + hb := box.hb(t0+5_000, 2_000, 1, 1, 0) + + fresh := box.view() + hour := int64(3_600_000) + accept(t, fresh, live, t0+hour) + accept(t, fresh, hb, t0+hour+10) + wantSource(t, fresh, t0+hour+20, protocol.SourceDown) + if f := fresh.Grade(t0 + hour + 20).Fields["2"]; f != protocol.SourceDown { + t.Fatalf("replayed field is %s", f) + } + + current := box.view() + accept(t, current, live, t0+transit) + accept(t, current, hb, t0+5_000+transit) + if err := current.Accept(live, t0+hour); !errors.Is(err, ErrNotNewer) { + t.Fatalf("replayed live accepted: %v", err) + } +} + +// 4. A withheld table goes stale. +func TestFreshnessWithheldTableGoesStale(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + _ = box.fullLive(t0+3_000, 12_000, protocol.SourceLagging, 2) // dropped by the service + accept(t, v, box.hb(t0+5_000, 1_000, 2, 2, 0), t0+5_000+transit) + wantSource(t, v, t0+5_200, protocol.SourceStale) +} + +// 5. No anchor, never live. +func TestFreshnessNoAnchorNeverLive(t *testing.T) { + box := newFakeBox(t, 1) + box.noClock = true + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + accept(t, v, box.hb(t0+5_000, 500, 1, 1, 0), t0+5_000+transit) + g := v.Grade(t0 + 5_200) + if g.Anchored || g.Sources["m"] != protocol.SourceStale || g.Fields["2"] != protocol.SourceStale { + t.Fatalf("unanchored data graded %+v", g) + } +} + +// 6. A fast box clock loses rule N. +func TestFreshnessFastClockLosesRuleN(t *testing.T) { + box := newFakeBox(t, 1) + box.clockLead = 3_000 + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + if g := v.Grade(t0 + 200); g.Anchored || g.Sources["m"] == protocol.SourceLive { + t.Fatalf("fast clock graded %+v", g) + } + // The boot stays off rule N even when later messages look fine. + box.clockLead = 0 + accept(t, v, box.hb(t0+5_000, 500, 1, 1, 0), t0+5_000+transit) + if g := v.Grade(t0 + 5_200); g.Anchored { + t.Fatal("rule N came back within the boot") + } +} + +// 7. A small clock lead gains nothing. +func TestFreshnessSmallLeadGainsNothing(t *testing.T) { + box := newFakeBox(t, 1) + box.clockLead = 1_500 + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 2_000, protocol.SourceLive, 1), t0+transit) + // Send time is the arrival, t0+100: age 2000 + t reaches S = 10 s at + // t0+8100, not 1.5 s later. + wantSource(t, v, t0+8_100, protocol.SourceLive) + wantSource(t, v, t0+8_101, protocol.SourceLagging) +} + +// 8. A changed byte is dropped. +func TestFreshnessChangedByteIsDropped(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + env := box.fullLive(2_000_000, 1_000, protocol.SourceLive, 1) + env.B = append([]byte(nil), env.B...) + env.B[len(env.B)-1] ^= 1 + if err := v.Accept(env, 2_000_100); !errors.Is(err, ErrSignature) { + t.Fatalf("changed byte: %v", err) + } +} + +// 9 and 10. A dropped live or a held-back lp goes stale. +func TestFreshnessDroppedLiveAndLpGoStale(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + accept(t, v, box.seal(MsgLP, LP{Header: box.header(t0 + 10), + Loadpoints: []Loadpoint{{ID: "lp1", Src: "m", Plugged: true}}}), t0+10+transit) + heldLive, heldLP := box.seq-1, box.seq + _ = box.seal(MsgLive, Live{Header: box.header(t0 + 3_000)}) // dropped + _ = box.seal(MsgLP, LP{Header: box.header(t0 + 3_100)}) // dropped + accept(t, v, box.hb(t0+5_000, 1_000, 1, heldLive+2, heldLP+2), t0+5_000+transit) + g := v.Grade(t0 + 5_200) + if g.Fields["2"] != protocol.SourceStale || g.Fields["1"] != protocol.SourceStale { + t.Fatalf("fields after a dropped live: %v", g.Fields) + } + if g.Loadpoints["lp1"] != protocol.SourceStale { + t.Fatalf("loadpoint after a held-back lp: %v", g.Loadpoints) + } +} + +// 12. A reboot clears the old table. +func TestFreshnessRebootClearsTheTable(t *testing.T) { + x := newFakeBox(t, 1) + v := x.view() + t0 := int64(2_000_000) + accept(t, v, x.fullLive(t0, 1_000, protocol.SourceLive, 9), t0+transit) + + y := newFakeBox(t, 2) + y.start = t0 + 30_000 + _ = y.fullLive(t0+60_000, 1_000, protocol.SourceLive, 3) // dropped + accept(t, v, y.hb(t0+61_000, 1_000, 3, 1, 0), t0+61_000+transit) + g := v.Grade(t0 + 61_200) + if len(g.Sources) != 0 || len(g.Fields) != 0 { + t.Fatalf("boot X's table survived boot Y: %+v", g) + } + // An older message from boot X can no longer take the view back. + if err := v.Accept(x.hb(t0+1_000, 1_000, 9, 1, 0), t0+61_300); !errors.Is(err, ErrNotNewer) { + t.Fatalf("old boot replaced the new one: %v", err) + } +} + +// 13. Rule U drift counts against freshness. +func TestFreshnessRuleUDrift(t *testing.T) { + box := newFakeBox(t, 1) + box.noClock = true + v := box.view() + learned := int64(2_000_000) + v.LearnOffset(box.boot, learned, learned-box.start) + + tenHours := int64(10 * 3_600_000) + at := learned + tenHours + accept(t, v, box.fullLive(at, 0, protocol.SourceLive, 1), at+transit) + // The margin is 3.6 s: a reading sent with age 0 reaches S = 10 s 3.6 s + // early. + wantSource(t, v, at+6_400, protocol.SourceLive) + wantSource(t, v, at+6_401, protocol.SourceLagging) + + day := learned + RuleULifetimeMs + 1 + if g := v.Grade(day); g.Anchored { + t.Fatal("rule U outlived its 24 hours") + } +} + +// 14. A snapshot with mixed seq. +func TestFreshnessSnapshotWithMixedSeq(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + box.seq = 4 + prices := box.seal(MsgPrices, Prices{Header: box.header(t0)}) + box.seq = 2989 + live := box.fullLive(t0+10, 1_000, protocol.SourceLive, 1) + accept(t, v, live, t0+20) + box.seq = 2999 + accept(t, v, box.fullLive(t0+20, 1_000, protocol.SourceLive, 1), t0+30) + accept(t, v, prices, t0+40) +} + +// 16. The box's state wins. +func TestFreshnessBoxStateWins(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 0, protocol.SourceDown, 1), t0+transit) + wantSource(t, v, t0+1_000, protocol.SourceDown) +} + +func TestHeartbeatIsClamped(t *testing.T) { + cases := []struct { + table map[string]Source + want int64 + }{ + {nil, 15_000}, + {map[string]Source{"a": {StaleAfterMs: 10_000}}, 5_000}, + {map[string]Source{"a": {StaleAfterMs: 1_000}}, 2_000}, + {map[string]Source{"a": {StaleAfterMs: 120_000}, "b": {StaleAfterMs: 0}}, 15_000}, + } + for _, c := range cases { + if got := Heartbeat(c.table); got != c.want { + t.Errorf("Heartbeat(%v) = %d, want %d", c.table, got, c.want) + } + } +} + +// 11. The byte budget runs out: the box sends no live and no hb, and the +// data ages exactly as a dead uplink's does. +func TestFreshnessBudgetSpentAgesOnTime(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 2_000, protocol.SourceLive, 1), t0+transit) + // Nothing more arrives; only events would. + wantSource(t, v, t0+8_000, protocol.SourceLive) + wantSource(t, v, t0+8_001, protocol.SourceLagging) + wantSource(t, v, t0+58_001, protocol.SourceDown) +} + +// A message the phone drops changes nothing, even one from a later boot that +// would otherwise have replaced the one held. +func TestADroppedMessageLeavesTheViewAsItWas(t *testing.T) { + x := newFakeBox(t, 1) + v := x.view() + t0 := int64(2_000_000) + accept(t, v, x.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + before := v.Grade(t0 + 1_000) + + y := newFakeBox(t, 2) + y.start = t0 + broken := y.seal(MsgLive, map[string]any{"seq": 1, "boot": y.boot, "uptimeMs": 5_000, + "wallMs": t0 + 5_000, "fields": "not a map"}) + if err := v.Accept(broken, t0+5_100); err == nil { + t.Fatal("an undecodable live was accepted") + } + zero := y.seal(MsgHB, HB{Header: Header{Seq: 0, Boot: y.boot, UptimeMs: 5_000, WallMs: &[]int64{t0 + 5_000}[0]}}) + if err := v.Accept(zero, t0+5_100); !errors.Is(err, ErrNotNewer) { + t.Fatalf("seq 0 from a new boot: %v", err) + } + ahead := x.seal(MsgHB, HB{Header: Header{Seq: 0, Boot: x.boot, UptimeMs: 5_000, WallMs: &[]int64{t0 + 60_000}[0]}}) + _ = v.Accept(ahead, t0+5_100) // dropped, and must not turn rule N off + if got := v.Grade(t0 + 1_000); got.Anchored != before.Anchored || got.Sources["m"] != before.Sources["m"] || + got.Fields["2"] != before.Fields["2"] { + t.Fatalf("a dropped message changed the view: %+v, was %+v", got, before) + } +} + +// A state this phone does not know ranks as never: neither the source nor +// a field that follows it can read as live. +func TestAnUnknownStateIsNeverLive(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.seal(MsgLive, Live{ + Header: box.header(t0), Fields: map[string]int64{"2": 1000}, + FieldSources: map[string][]string{"2": {"m"}}, + Sources: map[string]Source{"m": {Kind: "driver", State: "dormant", StaleAfterMs: 10_000}}, + SrcRev: 1, Ages: map[string]int64{}, + }), t0+transit) + g := v.Grade(t0 + 200) + if g.Sources["m"] != protocol.SourceNever || g.Fields["2"] != protocol.SourceNever { + t.Fatalf("an unknown state graded as %s, field %s", g.Sources["m"], g.Fields["2"]) + } +} diff --git a/go/protocol/cloudwire/wire.go b/go/protocol/cloudwire/wire.go new file mode 100644 index 000000000..806233be7 --- /dev/null +++ b/go/protocol/cloudwire/wire.go @@ -0,0 +1,394 @@ +// Package cloudwire is the wire format between an FTW box and FTW Cloud: the +// unpadded frame, the signed envelope, the ids, the message bodies and the +// scope each class belongs to. docs/protocol-cloud.md is the normative text; +// this package and its golden vectors hold the bytes. +// +// FTW Cloud reads what the box sends. It never controls. Nothing here can +// express a command: the service may send the box five message types, none of +// which asks for anything beyond a faster cadence, a resend or a receipt. +// +// Like the rest of go/protocol, this package imports nothing from the box, so +// the service that imports it links no control, planner or storage code. +package cloudwire + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "io" + "math/big" + "strings" + + "github.com/fxamacker/cbor/v2" +) + +// Fixed strings and sizes. Each one is part of what both ends sign or check, +// so none of them is configurable. +const ( + // Version is byte 0 of every frame and the v of challenge and hello. + Version = 1 + // Lane is byte 1 of every frame. FTW Cloud uses lane 1 without the app + // protocol's bulk buckets, because lane 0 is always padded. + Lane = 1 + // HeaderBytes is ver, lane, flags, rsvd and a u16 length. + HeaderBytes = 6 + // MaxPayload is what the u16 length field can describe. + MaxPayload = 0xffff + // MaxServiceFrame is the largest message the service may send the box. + // Anything larger closes the uplink. + MaxServiceFrame = 4096 + + // SignTag starts every signed input: "ftw.cloud.v1" and a zero byte. + SignTag = "ftw.cloud.v1\x00" + // HomeTag starts the hash a home id is cut from. + HomeTag = "ftw.cloud.home.v1" + // EKMLabel is the TLS exporter label both ends bind the handshake to. + // RFC 5705 asks for EXPERIMENTAL on a label nobody registered. + EKMLabel = "EXPERIMENTAL-ftw-cloud-v1" + // EKMBytes is the length of the exporter value. + EKMBytes = 32 + + // SigBytes is an ES256 signature as r ‖ s, each 32 bytes big-endian. + SigBytes = 64 + // PublicKeyBytes is an uncompressed SEC1 P-256 point. + PublicKeyBytes = 65 + // NonceBytes is the size of challenge.nonce and hello.boxNonce. + NonceBytes = 32 + // BootBytes is the size of the per-start boot id. + BootBytes = 16 + // GrantIDBytes is the size of a grant id. + GrantIDBytes = 16 + // GrantKeyBytes is the size of the key opaque ids are cut with. + GrantKeyBytes = 32 +) + +// Errors a receiver acts on by closing the connection. They never cross the +// wire. +var ( + ErrFrame = errors.New("cloudwire: not an FTW Cloud frame") + ErrEnvelope = errors.New("cloudwire: not an FTW Cloud envelope") + ErrSignature = errors.New("cloudwire: signature does not verify") + ErrKey = errors.New("cloudwire: not a P-256 public key") +) + +// -------------------------------------------------------------------------- +// CBOR +// -------------------------------------------------------------------------- + +// Bodies are core-deterministic CBOR with text keys, so a body encoded twice +// from equal values is equal bytes. A nil slice or map is an empty one on the +// wire, so "no sources are blocking dispatch" is [] and never null; a nil +// pointer is null, which is how a field says "not known". +var ( + encMode cbor.EncMode + decMode cbor.DecMode +) + +func init() { + opts := cbor.CoreDetEncOptions() + opts.NilContainers = cbor.NilContainerAsEmpty + var err error + if encMode, err = opts.EncMode(); err != nil { + panic(err) + } + // Unknown keys are ignored, so a newer box works with an older service. + // A duplicate key is two claims about one field and is refused. + if decMode, err = (cbor.DecOptions{DupMapKey: cbor.DupMapKeyEnforcedAPF}).DecMode(); err != nil { + panic(err) + } +} + +// Marshal encodes a body deterministically. +func Marshal(v any) ([]byte, error) { + b, err := encMode.Marshal(v) + if err != nil { + return nil, fmt.Errorf("cloudwire: encode body: %w", err) + } + return b, nil +} + +// Unmarshal decodes a body. Unknown keys are dropped without complaint. +func Unmarshal(b []byte, v any) error { + if len(b) == 0 { + return fmt.Errorf("%w: empty body", ErrEnvelope) + } + if err := decMode.Unmarshal(b, v); err != nil { + return fmt.Errorf("cloudwire: decode body: %w", err) + } + return nil +} + +// -------------------------------------------------------------------------- +// Frames +// -------------------------------------------------------------------------- + +// EncodeFrame puts one envelope in one frame: the app protocol's layout on +// lane 1, flags 0, and no padding, so the message is exactly 6 + len bytes. +func EncodeFrame(envelope []byte) ([]byte, error) { + if len(envelope) > MaxPayload { + return nil, fmt.Errorf("%w: %d bytes do not fit the length field", ErrFrame, len(envelope)) + } + out := make([]byte, HeaderBytes+len(envelope)) + out[0] = Version + out[1] = Lane + binary.BigEndian.PutUint16(out[4:6], uint16(len(envelope))) + copy(out[HeaderBytes:], envelope) + return out, nil +} + +// DecodeFrame returns the envelope bytes of one frame. It is strict where the +// app protocol is lenient: lane 0, a flag, a reserved bit or a byte after the +// declared length is not a frame this protocol sends, so it is refused. +func DecodeFrame(msg []byte) ([]byte, error) { + switch { + case len(msg) < HeaderBytes: + return nil, fmt.Errorf("%w: %d bytes", ErrFrame, len(msg)) + case msg[0] != Version: + return nil, fmt.Errorf("%w: version %d", ErrFrame, msg[0]) + case msg[1] != Lane: + return nil, fmt.Errorf("%w: lane %d", ErrFrame, msg[1]) + case msg[2] != 0 || msg[3] != 0: + return nil, fmt.Errorf("%w: flags %#x %#x", ErrFrame, msg[2], msg[3]) + } + if n := int(binary.BigEndian.Uint16(msg[4:6])); n != len(msg)-HeaderBytes { + return nil, fmt.Errorf("%w: declares %d bytes, carries %d", ErrFrame, n, len(msg)-HeaderBytes) + } + return msg[HeaderBytes:], nil +} + +// -------------------------------------------------------------------------- +// Envelope +// -------------------------------------------------------------------------- + +// Envelope is one message: {t, b, sig}. B is the body's exact bytes, carried +// as a byte string so the signature covers what was sent and the service can +// forward it unchanged. Sig is empty on everything the box did not sign +// except hello_ok and purged, which the service signs. +type Envelope struct { + T string + B []byte + Sig []byte +} + +// Envelope keys in core-deterministic order: shorter keys first, then bytes. +var ( + keyB = []byte{0x61, 'b'} + keyT = []byte{0x61, 't'} + keySig = []byte{0x63, 's', 'i', 'g'} +) + +// Encode writes the envelope as a three-entry map. Written by hand so an +// empty signature is an empty byte string, never null. +func (e Envelope) Encode() ([]byte, error) { + if e.T == "" { + return nil, fmt.Errorf("%w: no type", ErrEnvelope) + } + t, err := encMode.Marshal(e.T) + if err != nil { + return nil, err + } + b, err := encMode.Marshal(nonNil(e.B)) + if err != nil { + return nil, err + } + sig, err := encMode.Marshal(nonNil(e.Sig)) + if err != nil { + return nil, err + } + out := make([]byte, 0, 1+len(keyB)+len(b)+len(keyT)+len(t)+len(keySig)+len(sig)) + out = append(out, 0xa3) + out = append(out, keyB...) + out = append(out, b...) + out = append(out, keyT...) + out = append(out, t...) + out = append(out, keySig...) + out = append(out, sig...) + return out, nil +} + +func nonNil(b []byte) []byte { + if b == nil { + return []byte{} + } + return b +} + +// DecodeEnvelope parses one envelope. t must be text and b and sig byte +// strings; anything else is not an envelope. Unknown keys are ignored. +func DecodeEnvelope(raw []byte) (Envelope, error) { + var fields map[string]cbor.RawMessage + if err := decMode.Unmarshal(raw, &fields); err != nil { + return Envelope{}, fmt.Errorf("%w: %v", ErrEnvelope, err) + } + var e Envelope + if err := decodeKey(fields, "t", &e.T); err != nil { + return Envelope{}, err + } + if err := decodeKey(fields, "b", &e.B); err != nil { + return Envelope{}, err + } + if err := decodeKey(fields, "sig", &e.Sig); err != nil { + return Envelope{}, err + } + if e.T == "" { + return Envelope{}, fmt.Errorf("%w: empty type", ErrEnvelope) + } + return e, nil +} + +func decodeKey[T string | []byte](fields map[string]cbor.RawMessage, key string, out *T) error { + raw, ok := fields[key] + if !ok { + return fmt.Errorf("%w: no %q", ErrEnvelope, key) + } + // Major type 3 is text, 2 is a byte string. Checked by hand because the + // decoder would otherwise turn a text string into bytes or the reverse, + // and an envelope whose body is text is not the envelope that was signed. + want := byte(0x40) + if _, isText := any(*out).(string); isText { + want = 0x60 + } + if len(raw) == 0 || raw[0]&0xe0 != want { + return fmt.Errorf("%w: %q has the wrong type", ErrEnvelope, key) + } + if err := decMode.Unmarshal(raw, out); err != nil { + return fmt.Errorf("%w: %q: %v", ErrEnvelope, key, err) + } + return nil +} + +// -------------------------------------------------------------------------- +// Signatures +// -------------------------------------------------------------------------- + +// SignedInput is what an envelope's signature covers: SignTag, then the +// deterministic CBOR array [home, t, b]. The home id is inside, so a message +// signed for one home never verifies for another. +func SignedInput(home, t string, b []byte) ([]byte, error) { + arr, err := encMode.Marshal([]any{home, t, nonNil(b)}) + if err != nil { + return nil, err + } + return append([]byte(SignTag), arr...), nil +} + +// Seal encodes body, signs it for home with key and returns the envelope. +// A nil key leaves the signature empty, which is how the service sends +// everything but hello_ok and purged. +func Seal(key *ecdsa.PrivateKey, home, t string, body any) (Envelope, error) { + b, err := Marshal(body) + if err != nil { + return Envelope{}, err + } + return SealBytes(key, home, t, b) +} + +// SealBytes is Seal for a body that is already encoded. +func SealBytes(key *ecdsa.PrivateKey, home, t string, b []byte) (Envelope, error) { + env := Envelope{T: t, B: b} + if key == nil { + return env, nil + } + sig, err := Sign(rand.Reader, key, home, t, b) + if err != nil { + return Envelope{}, err + } + env.Sig = sig + return env, nil +} + +// Sign returns the ES256 signature over SignedInput as r ‖ s. +func Sign(random io.Reader, key *ecdsa.PrivateKey, home, t string, b []byte) ([]byte, error) { + input, err := SignedInput(home, t, b) + if err != nil { + return nil, err + } + digest := sha256.Sum256(input) + r, s, err := ecdsa.Sign(random, key, digest[:]) + if err != nil { + return nil, fmt.Errorf("cloudwire: sign: %w", err) + } + sig := make([]byte, SigBytes) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return sig, nil +} + +// Verify checks an envelope's signature for home, over the bytes received. +func Verify(pub *ecdsa.PublicKey, home string, e Envelope) error { + if pub == nil || len(e.Sig) != SigBytes { + return ErrSignature + } + input, err := SignedInput(home, e.T, e.B) + if err != nil { + return err + } + digest := sha256.Sum256(input) + r := new(big.Int).SetBytes(e.Sig[:32]) + s := new(big.Int).SetBytes(e.Sig[32:]) + if !ecdsa.Verify(pub, digest[:], r, s) { + return ErrSignature + } + return nil +} + +// -------------------------------------------------------------------------- +// Keys and ids +// -------------------------------------------------------------------------- + +// PublicKeyBytesOf is a key's 65-byte uncompressed point. +func PublicKeyBytesOf(pub *ecdsa.PublicKey) ([]byte, error) { + b, err := pub.Bytes() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrKey, err) + } + return b, nil +} + +// ParsePublicKey reads a 65-byte uncompressed P-256 point. +func ParsePublicKey(b []byte) (*ecdsa.PublicKey, error) { + if len(b) != PublicKeyBytes { + return nil, fmt.Errorf("%w: %d bytes", ErrKey, len(b)) + } + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), b) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrKey, err) + } + return pub, nil +} + +var homeEncoding = base32.StdEncoding.WithPadding(base32.NoPadding) + +// HomeID is the home's name at the service: lowercase base32 without padding +// of the first 16 bytes of SHA-256(HomeTag ‖ pk), 26 characters. The id is a +// hash of the key, so a second box cannot claim a home that is not its own. +func HomeID(pk []byte) string { + sum := sha256.Sum256(append([]byte(HomeTag), pk...)) + return strings.ToLower(homeEncoding.EncodeToString(sum[:16])) +} + +// SourceID is a source's opaque id: the first 8 bytes of +// HMAC-SHA256(grantKey, "src\0" ‖ driver name), base64url without padding. +// The service sees a stable id per grant and never the driver's name. +func SourceID(grantKey []byte, driver string) string { + return opaqueID(grantKey, "src\x00", driver) +} + +// LoadpointID is a loadpoint's opaque id, cut the same way with "lp\0". +func LoadpointID(grantKey []byte, loadpoint string) string { + return opaqueID(grantKey, "lp\x00", loadpoint) +} + +func opaqueID(key []byte, tag, name string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(tag)) + mac.Write([]byte(name)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:8]) +} diff --git a/go/protocol/cloudwire/wire_test.go b/go/protocol/cloudwire/wire_test.go new file mode 100644 index 000000000..daf2a5658 --- /dev/null +++ b/go/protocol/cloudwire/wire_test.go @@ -0,0 +1,303 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "errors" + "testing" +) + +// testKey derives a fixed P-256 key from a label, so vectors and tests do not +// depend on a random draw. +func testKey(t testing.TB, label string) *ecdsa.PrivateKey { + t.Helper() + for i := byte(0); ; i++ { + d := sha256.Sum256(append([]byte(label), i)) + key, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), d[:]) + if err == nil { + return key + } + } +} + +func homeOf(t testing.TB, key *ecdsa.PrivateKey) (string, []byte) { + t.Helper() + pk, err := PublicKeyBytesOf(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + return HomeID(pk), pk +} + +func TestFrameIsHeaderAndEnvelopeAndNothingElse(t *testing.T) { + env := []byte{0xa3, 1, 2, 3} + frame, err := EncodeFrame(env) + if err != nil { + t.Fatal(err) + } + want := []byte{1, 1, 0, 0, 0, 4, 0xa3, 1, 2, 3} + if !bytes.Equal(frame, want) { + t.Fatalf("frame = % x, want % x", frame, want) + } + got, err := DecodeFrame(frame) + if err != nil || !bytes.Equal(got, env) { + t.Fatalf("decode = % x, %v", got, err) + } +} + +func TestDecodeFrameRefusesWhatThisProtocolNeverSends(t *testing.T) { + good := []byte{1, 1, 0, 0, 0, 2, 0xa0, 0xa0} + cases := map[string][]byte{ + "short": {1, 1, 0}, + "version": {2, 1, 0, 0, 0, 2, 0xa0, 0xa0}, + "lane 0": {1, 0, 0, 0, 0, 2, 0xa0, 0xa0}, + "flag": {1, 1, 2, 0, 0, 2, 0xa0, 0xa0}, + "reserved": {1, 1, 0, 1, 0, 2, 0xa0, 0xa0}, + "padding": append(append([]byte{}, good...), 0), + "overrun": {1, 1, 0, 0, 0, 9, 0xa0}, + "short length": {1, 1, 0, 0, 0, 1, 0xa0, 0xa0}, + } + for name, msg := range cases { + if _, err := DecodeFrame(msg); !errors.Is(err, ErrFrame) { + t.Errorf("%s: err = %v, want ErrFrame", name, err) + } + } + if _, err := DecodeFrame(good); err != nil { + t.Fatalf("good frame refused: %v", err) + } +} + +func TestEnvelopeKeepsAnEmptySignatureAsBytes(t *testing.T) { + raw, err := Envelope{T: MsgWatch, B: []byte{0xa1, 0x62, 'h', 'z', 1}}.Encode() + if err != nil { + t.Fatal(err) + } + // {"b": h'a16268 7a01', "t": "watch", "sig": h''} + want := []byte{0xa3, 0x61, 'b', 0x45, 0xa1, 0x62, 'h', 'z', 1, 0x61, 't', 0x65, 'w', 'a', 't', 'c', 'h', 0x63, 's', 'i', 'g', 0x40} + if !bytes.Equal(raw, want) { + t.Fatalf("envelope = % x\nwant % x", raw, want) + } + back, err := DecodeEnvelope(raw) + if err != nil { + t.Fatal(err) + } + if back.T != MsgWatch || len(back.Sig) != 0 || !bytes.Equal(back.B, want[4:9]) { + t.Fatalf("decoded %+v", back) + } +} + +func TestDecodeEnvelopeRefusesTheWrongShapes(t *testing.T) { + mk := func(v any) []byte { + b, err := Marshal(v) + if err != nil { + t.Fatal(err) + } + return b + } + cases := map[string][]byte{ + "not a map": mk([]int{1}), + "no type": mk(map[string]any{"b": []byte{1}, "sig": []byte{}}), + "no body": mk(map[string]any{"t": "hb", "sig": []byte{}}), + "no sig": mk(map[string]any{"t": "hb", "b": []byte{1}}), + "text body": mk(map[string]any{"t": "hb", "b": "a0", "sig": []byte{}}), + "bytes type": mk(map[string]any{"t": []byte("hb"), "b": []byte{1}, "sig": []byte{}}), + "empty type": mk(map[string]any{"t": "", "b": []byte{1}, "sig": []byte{}}), + "text sig": mk(map[string]any{"t": "hb", "b": []byte{1}, "sig": ""}), + "garbage": {0xff, 0x00}, + "duplicate t": {0xa4, 0x61, 'b', 0x41, 0, 0x61, 't', 0x61, 'x', 0x61, 't', 0x61, 'y', 0x63, 's', 'i', 'g', 0x40}, + "truncated": {0xa3, 0x61, 'b'}, + "integer body": mk(map[string]any{"t": "hb", "b": 5, "sig": []byte{}}), + } + for name, raw := range cases { + if _, err := DecodeEnvelope(raw); err == nil { + t.Errorf("%s: decoded", name) + } + } +} + +func TestSignatureCoversHomeTypeAndEveryBodyByte(t *testing.T) { + key := testKey(t, "box") + home, _ := homeOf(t, key) + env, err := Seal(key, home, MsgHB, HB{Header: Header{Seq: 7, Boot: make([]byte, BootBytes)}, Clock: ClockNTP}) + if err != nil { + t.Fatal(err) + } + if err := Verify(&key.PublicKey, home, env); err != nil { + t.Fatalf("own signature: %v", err) + } + + for i := range env.B { + changed := env + changed.B = append([]byte(nil), env.B...) + changed.B[i] ^= 0x01 + if err := Verify(&key.PublicKey, home, changed); !errors.Is(err, ErrSignature) { + t.Fatalf("byte %d flipped and the signature still verified", i) + } + } + other := env + other.T = MsgLive + if Verify(&key.PublicKey, home, other) == nil { + t.Fatal("the signature does not cover the type") + } + if Verify(&key.PublicKey, "aaaaaaaaaaaaaaaaaaaaaaaaaa", env) == nil { + t.Fatal("the signature does not cover the home") + } + if Verify(&testKey(t, "someone else").PublicKey, home, env) == nil { + t.Fatal("another key verified the box's signature") + } + short := env + short.Sig = env.Sig[:63] + if Verify(&key.PublicKey, home, short) == nil { + t.Fatal("a short signature verified") + } +} + +func TestUnsignedSealLeavesTheSignatureEmpty(t *testing.T) { + env, err := Seal(nil, "home", MsgWatch, Watch{Hz: 1}) + if err != nil { + t.Fatal(err) + } + if len(env.Sig) != 0 { + t.Fatalf("sig = % x", env.Sig) + } +} + +func TestHomeIDIsTwentySixLowercaseCharacters(t *testing.T) { + _, pk := homeOf(t, testKey(t, "box")) + home := HomeID(pk) + if len(home) != 26 { + t.Fatalf("home %q has %d characters", home, len(home)) + } + for _, c := range home { + if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { + t.Fatalf("home %q has %q", home, c) + } + } + if HomeID(pk) != home { + t.Fatal("home id is not a function of the key") + } +} + +func TestOpaqueIDsHideTheNameAndChangeWithTheGrant(t *testing.T) { + a := bytes.Repeat([]byte{1}, GrantKeyBytes) + b := bytes.Repeat([]byte{2}, GrantKeyBytes) + id := SourceID(a, "sungrow") + if len(id) != 11 || bytes.Contains([]byte(id), []byte("sungrow")) { + t.Fatalf("source id %q", id) + } + if SourceID(a, "sungrow") != id { + t.Fatal("source id is not stable within a grant") + } + if SourceID(b, "sungrow") == id { + t.Fatal("a new grant kept the same source id") + } + if LoadpointID(a, "sungrow") == id { + t.Fatal("a loadpoint and a source with one name share an id") + } +} + +func TestPublicKeyRoundTrips(t *testing.T) { + key := testKey(t, "box") + pk, err := PublicKeyBytesOf(&key.PublicKey) + if err != nil || len(pk) != PublicKeyBytes { + t.Fatalf("pk %d bytes, %v", len(pk), err) + } + back, err := ParsePublicKey(pk) + if err != nil || !back.Equal(&key.PublicKey) { + t.Fatalf("parse: %v", err) + } + if _, err := ParsePublicKey(pk[:64]); err == nil { + t.Fatal("a 64-byte key parsed") + } + bad := append([]byte(nil), pk...) + bad[64] ^= 1 + if _, err := ParsePublicKey(bad); err == nil { + t.Fatal("a point off the curve parsed") + } +} + +func TestHeaderIsFlattenedIntoEveryBody(t *testing.T) { + wall := int64(1_700_000_000_000) + raw, err := Marshal(HB{Header: Header{Seq: 3, Boot: []byte{9}, UptimeMs: 10, WallMs: &wall}, Clock: ClockNTP}) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + for _, k := range []string{"seq", "boot", "uptimeMs", "wallMs", "clock", "srcRev", "liveSeq", "lpSeq", "ages"} { + if _, ok := fields[k]; !ok { + t.Errorf("hb has no %q: %v", k, fields) + } + } + if _, ok := fields["Header"]; ok { + t.Error("the header is nested, not flattened") + } + // Without a synced clock, wallMs is absent rather than zero. + raw, _ = Marshal(HB{Header: Header{Seq: 3, Boot: []byte{9}}}) + fields = nil + _ = Unmarshal(raw, &fields) + if _, ok := fields["wallMs"]; ok { + t.Error("wallMs sent without a clock") + } +} + +func TestNilListsAreEmptyAndNilNumbersAreNull(t *testing.T) { + raw, err := Marshal(Feedback{Kind: "battery"}) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + _ = Unmarshal(raw, &fields) + if v, ok := fields["actualW"]; !ok || v != nil { + t.Errorf("actualW = %v (present %v), want null", v, ok) + } + raw, _ = Marshal(Live{}) + fields = nil + _ = Unmarshal(raw, &fields) + if v, ok := fields["dispatchBlockedBy"].([]any); !ok || len(v) != 0 { + t.Errorf("dispatchBlockedBy = %#v, want []", fields["dispatchBlockedBy"]) + } + if _, ok := fields["sources"]; ok { + t.Error("an empty source table was sent") + } +} + +func TestSignUsesTheRandomSource(t *testing.T) { + key := testKey(t, "box") + a, _ := Sign(rand.Reader, key, "h", MsgHB, []byte{1}) + b, _ := Sign(rand.Reader, key, "h", MsgHB, []byte{1}) + if bytes.Equal(a, b) { + t.Fatal("two ECDSA signatures were identical") + } +} + +func FuzzDecodeFrameAndEnvelope(f *testing.F) { + f.Add([]byte{1, 1, 0, 0, 0, 2, 0xa0, 0xa0}) + env, _ := Envelope{T: MsgHB, B: []byte{0xa0}}.Encode() + frame, _ := EncodeFrame(env) + f.Add(frame) + f.Fuzz(func(t *testing.T, msg []byte) { + payload, err := DecodeFrame(msg) + if err != nil { + return + } + e, err := DecodeEnvelope(payload) + if err != nil { + return + } + // Whatever decodes re-encodes to something that decodes the same. + again, err := e.Encode() + if err != nil { + t.Fatalf("re-encode: %v", err) + } + back, err := DecodeEnvelope(again) + if err != nil || back.T != e.T || !bytes.Equal(back.B, e.B) || !bytes.Equal(back.Sig, e.Sig) { + t.Fatalf("round trip changed the envelope: %v", err) + } + }) +} diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 51e01a5a9..3bc79c085 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -79,6 +79,7 @@ const ( CapNetWebrtc = "net.webrtc" CapPriceSpot = "price.spot" CapApiPassthrough = "api.passthrough" + CapCloudLink = "cloud.link" ) // AllCapabilities is every capability name the registry defines. @@ -99,6 +100,7 @@ var AllCapabilities = []string{ CapNetWebrtc, CapPriceSpot, CapApiPassthrough, + CapCloudLink, } // Scopes are one object axis and two verb axes. Roles are a projection over @@ -114,6 +116,8 @@ const ( ScopeHealthRead = "ftw.health.read" // ScopeAssetsRead — Devices and their capabilities. ScopeAssetsRead = "ftw.assets.read" + // ScopeEventsRead — Events behind notifications. + ScopeEventsRead = "ftw.events.read" // ScopeDispatchWrite — Control energy assets. ScopeDispatchWrite = "ftw.dispatch.write" // ScopeModeWrite — Change site operating mode. @@ -131,6 +135,27 @@ var WriteScopes = []string{ ScopeMembersWrite, } +// CloudScopes are the scopes an FTW Cloud grant may name. +var CloudScopes = []string{ + ScopeLiveRead, + ScopeHistoryRead, + ScopePlanRead, + ScopeHealthRead, + ScopeEventsRead, +} + +// What an FTW Cloud grant lets the service use the data for. +const ( + CloudPurposeServe = "serve" + CloudPurposeStats = "stats" +) + +// CloudPurposes is every purpose an FTW Cloud grant may name. +var CloudPurposes = []string{ + CloudPurposeServe, + CloudPurposeStats, +} + // RegistryOps is the app's copy of the operations a cmd frame may name, // each mapped to the scope its grant must carry. defaultOps() in command.go // remains the authority on what this box accepts — this exists only so diff --git a/go/protocol/messages.go b/go/protocol/messages.go index f296c6e0e..7876a89aa 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -136,6 +136,30 @@ type HelloOK struct { // Subscribed says the optional subscription carried by Hello was accepted. // False also covers an older box, where this additive field is absent. Subscribed bool `cbor:"subscribed,omitempty"` + // Cloud is what the phone needs to read this home from FTW Cloud. Every + // role gets it, and only while an owner has FTW Cloud on. Absent means + // off, or an older box. + Cloud *CloudSession `cbor:"cloud,omitempty"` +} + +// CloudSession lets a phone check and read what FTW Cloud serves it. It comes +// from the box over Noise and never from the service, so the service cannot +// sign for the box, swap its key or rename a source. +type CloudSession struct { + // PK is the box's FTW Cloud public key, the 65-byte uncompressed P-256 + // point. Every box envelope from the service verifies with it. + PK []byte `cbor:"pk"` + // Home is the home id at the service, cut from PK. + Home string `cbor:"home"` + // Boot is the uplink's current 16-byte boot id. With hello_ok's + // clock.uptimeMs it anchors the times in box envelopes: both count from + // the same process start. + Boot []byte `cbor:"boot"` + // Sources maps each opaque source id to its source id in this protocol, + // the driver name. + Sources map[string]string `cbor:"sources"` + // Loadpoints maps each opaque loadpoint id to the loadpoint id. + Loadpoints map[string]string `cbor:"loadpoints"` } // HintAppUpdate tells the app it is behind. It is a hint, not an error: the diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index 95e50bbd5..bb6a93f20 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -10,11 +10,14 @@ const PushCatalogueVersion = 1 const ( PushChargingConnected = "charging.connected" PushChargingSessionComplete = "charging.session_complete" + PushChargingGoalAtRisk = "charging.goal_at_risk" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" PushDriverOffline = "driver.offline" PushFuseOverLimit = "fuse.over_limit" PushBoxUnreachable = "box.unreachable" + PushCloudGranted = "cloud.granted" + PushCloudRevoked = "cloud.revoked" ) // PushSentence is one lock-screen sentence, exactly as the app wrote it. @@ -28,9 +31,12 @@ type PushSentence struct { var PushSentences = map[string]PushSentence{ PushChargingConnected: {Title: "Car plugged in", Body: "Open to see charging status, check the battery level and set a ready time."}, PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, + PushChargingGoalAtRisk: {Title: "The car may not be ready", Body: "It will not reach {target} % by {by} at this pace."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, PushDriverOffline: {Title: "A device went quiet", Body: "{name} stopped answering."}, PushFuseOverLimit: {Title: "The house is drawing too much", Body: "{phase} is over the fuse rating."}, PushBoxUnreachable: {Title: "Your box is out of reach", Body: "It stopped answering. Power cut, or the internet at home is down."}, + PushCloudGranted: {Title: "FTW Cloud is on", Body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."}, + PushCloudRevoked: {Title: "FTW Cloud is off", Body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."}, }