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..8560885d2 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -45,3 +45,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/cloud_link.go b/go/cmd/ftw/cloud_link.go new file mode 100644 index 000000000..92120aab9 --- /dev/null +++ b/go/cmd/ftw/cloud_link.go @@ -0,0 +1,347 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "math" + "path/filepath" + "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 +} + +// 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..58cf37b9b --- /dev/null +++ b/go/cmd/ftw/cloud_link_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/loadpoint" + "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) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 60f44120c..95a9686cd 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2438,6 +2438,12 @@ 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) + // 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 +2477,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 +2628,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. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index d289fb96b..23f9ebee1 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -107,6 +107,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 @@ -423,6 +427,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..92ba4e6af --- /dev/null +++ b/go/internal/api/api_cloud.go @@ -0,0 +1,179 @@ +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. + +// 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 +} + +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) +} + +// 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()) +} + +// 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..fb064be2f --- /dev/null +++ b/go/internal/api/api_cloud_test.go @@ -0,0 +1,176 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/cloudlink" +) + +type stubCloud struct { + granted []cloudlink.GrantRequest + revoked []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 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}, + } { + 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) + } + } +} 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/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..0d5bf20fe --- /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) { + 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/grant.go b/go/internal/cloudlink/grant.go new file mode 100644 index 000000000..22a83e33c --- /dev/null +++ b/go/internal/cloudlink/grant.go @@ -0,0 +1,187 @@ +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"` + // 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..7f4f15823 --- /dev/null +++ b/go/internal/cloudlink/link.go @@ -0,0 +1,570 @@ +// 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 ( + "context" + "crypto/ecdsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "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") + +// 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 +} + +// 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 +} + +// 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) +} + +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"` +} + +// 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, + 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 +} + +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 { + 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: + } + } +} + +// 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} { + 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..8a66da293 --- /dev/null +++ b/go/internal/cloudlink/link_test.go @@ -0,0 +1,725 @@ +package cloudlink + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "log/slog" + "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) + } +} diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go new file mode 100644 index 000000000..04d528a70 --- /dev/null +++ b/go/internal/cloudlink/sender.go @@ -0,0 +1,642 @@ +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.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/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 48c5cb74a..52a38c122 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -18,6 +18,8 @@ const ( 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/cloud.go b/go/internal/notifications/cloud.go new file mode 100644 index 000000000..f2e62af18 --- /dev/null +++ b/go/internal/notifications/cloud.go @@ -0,0 +1,36 @@ +package notifications + +// 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)}) +} 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..e481fe4bd 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -103,6 +103,12 @@ func DefaultRules() []config.NotificationRule { // 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}, } } @@ -117,6 +123,7 @@ func KnownRuleTypes() []string { EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, + PushCloudGranted, PushCloudRevoked, } } diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go new file mode 100644 index 000000000..42acd3829 --- /dev/null +++ b/go/protocol/cloudwire/allowlist.go @@ -0,0 +1,52 @@ +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"}, + + 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, event, 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{}, + 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/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/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json new file mode 100644 index 000000000..d578b866a --- /dev/null +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -0,0 +1,166 @@ +{ + "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" + } + ] +} diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go new file mode 100644 index 000000000..a263773a4 --- /dev/null +++ b/go/protocol/cloudwire/vectors_test.go @@ -0,0 +1,279 @@ +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)}}, + } +} + +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..9df86a017 --- /dev/null +++ b/go/protocol/cloudwire/view.go @@ -0,0 +1,347 @@ +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 + } + + if !bytes.Equal(h.Boot, v.boot) { + // 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. + lead := h.WallMs != nil && *h.WallMs > arrivalMs+RuleNLeadMs + send, ok := v.sendTime(h.Boot, wallSend(h, arrivalMs, lead), h.UptimeMs, arrivalMs) + if v.boot != nil && (!ok || send <= v.newest) { + return ErrNotNewer + } + v.reset(h.Boot) + } + if h.Seq <= v.seq[e.T] { + return ErrNotNewer + } + if h.WallMs != nil && *h.WallMs > arrivalMs+RuleNLeadMs { + v.noRuleN = true + } + ws := wallSend(h, arrivalMs, v.noRuleN) + send, anchored := v.sendTime(h.Boot, ws, h.UptimeMs, arrivalMs) + + switch e.T { + case MsgLive: + var l Live + if err := Unmarshal(e.B, &l); err != nil { + return err + } + v.live = &l + if l.Sources != nil { + v.table, v.tableRev, v.fieldSrc = l.Sources, l.SrcRev, l.FieldSources + } + if anchored { + v.setM(&anchor{seq: h.Seq, boot: h.Boot, wallSend: ws, uptimeMs: h.UptimeMs, + srcRev: l.SrcRev, liveSeq: h.Seq, ages: l.Ages}) + } + case MsgLP: + var p LP + if err := Unmarshal(e.B, &p); err != nil { + return err + } + v.lp = &p + case MsgHB: + var b HB + if err := Unmarshal(e.B, &b); err != nil { + return err + } + if anchored { + v.setM(&anchor{seq: h.Seq, hb: true, boot: h.Boot, wallSend: ws, uptimeMs: h.UptimeMs, + srcRev: b.SrcRev, liveSeq: b.LiveSeq, lpSeq: b.LpSeq, ages: b.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 := s.State + 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, +} + +func worse(a, b protocol.SourceState) protocol.SourceState { + ra, ok := stateRank[a] + if !ok { + return protocol.SourceNever + } + if stateRank[b] > ra { + 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..06ce85158 --- /dev/null +++ b/go/protocol/cloudwire/view_test.go @@ -0,0 +1,314 @@ +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) + } + } +} 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/push_gen.go b/go/protocol/push_gen.go index 95e50bbd5..c0454eea2 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -15,6 +15,8 @@ const ( 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. @@ -33,4 +35,6 @@ var PushSentences = map[string]PushSentence{ 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."}, }